@fuzdev/fuz_app 0.67.1 → 0.68.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/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 +168 -0
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +146 -1
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +218 -4
- 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/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/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 +58 -5
- package/dist/testing/app_server.d.ts +12 -0
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +36 -2
- 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 +76 -0
- package/dist/testing/cross_backend/capabilities.d.ts +31 -0
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +3 -0
- 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_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/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +5 -0
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -0
- 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 +58 -0
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.d.ts +30 -2
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +45 -1
- 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/package.json +2 -2
|
@@ -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);
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -149,7 +149,7 @@ Key module-scope values:
|
|
|
149
149
|
|
|
150
150
|
- `stub_password_deps` — `PasswordHashDeps` hashing via `stub_hash_${password}` and verifying by equality. Deterministic, no Argon2 cost — use for every test not specifically exercising password hashing.
|
|
151
151
|
- `TEST_COOKIE_SECRET` — 64-hex-char deterministic cookie secret. Produces a valid `Keyring` via `create_validated_keyring`. Never used in production — the stub guard plus fixed value is the contract.
|
|
152
|
-
- `fallback_pglite_factory` — module-level PGlite factory `create_test_app_server` uses when no `db` is passed. Reuses the WASM cache via `create_pglite_factory`.
|
|
152
|
+
- `fallback_pglite_factory` — module-level auth-only PGlite factory `create_test_app_server` uses when no `db` is passed. Reuses the WASM cache via `create_pglite_factory`. When `migration_namespaces` is supplied, a memoized sibling factory (keyed by namespace set) migrating `[auth_migration_ns, ...migration_namespaces]` is used instead — same shared WASM, extra tables.
|
|
153
153
|
|
|
154
154
|
Two helpers share the "insert account + actor + roles + API token + session +
|
|
155
155
|
cookie" flow, split by intent:
|
|
@@ -167,7 +167,7 @@ pre-keeper, lock unflipped), use `create_test_app_for_bootstrap` — pair with
|
|
|
167
167
|
Types:
|
|
168
168
|
|
|
169
169
|
- `TestAppServer extends AppBackend` — adds `account`, `actor`, `api_token`, `session_cookie`, `keyring`, `cleanup()`.
|
|
170
|
-
- `TestAppServerOptions` — `session_options` (required), optional `db`, `db_type`, `password`, `username`, `password_value`, `roles`, `audit_factory`. The optional `audit_factory` defaults to `default_audit_factory` (no-listener `create_audit_emitter` over the test backend's `{db, log}`); pass a custom factory to compose `on_audit_event` / `audit_log_config`, wrap with `emit_decorator` (via `create_emit_ordering_audit_factory`), or otherwise replace the emitter. Mirrors `CreateAppBackendOptions` end-to-end — the previous `on_audit_event` / `audit_log_config` sugar was removed alongside the production rename.
|
|
170
|
+
- `TestAppServerOptions` — `session_options` (required), optional `db`, `db_type`, `migration_namespaces`, `password`, `username`, `password_value`, `roles`, `audit_factory`. `migration_namespaces` runs extra namespaces after auth in the auto-created PGlite (mirrors `create_app_backend`); mutually exclusive with `db` (caller-migrated) — passing both throws. The optional `audit_factory` defaults to `default_audit_factory` (no-listener `create_audit_emitter` over the test backend's `{db, log}`); pass a custom factory to compose `on_audit_event` / `audit_log_config`, wrap with `emit_decorator` (via `create_emit_ordering_audit_factory`), or otherwise replace the emitter. Mirrors `CreateAppBackendOptions` end-to-end — the previous `on_audit_event` / `audit_log_config` sugar was removed alongside the production rename.
|
|
171
171
|
- `CreateTestAppOptions extends TestAppServerOptions` — adds `create_route_specs` (required), `rpc_endpoints?: RpcEndpointsSuiteOption` (top-level only — single source of truth, symmetric with the suite-level option), `bootstrap?: BootstrapServerOptions` (top-level only — same precedent as `rpc_endpoints`), and `app_options?: SuiteAppOptions` (`Partial<AppServerOptions>` excluding the five fields the helper manages: `backend`, `session_options`, `create_route_specs`, `rpc_endpoints`, `bootstrap`).
|
|
172
172
|
- `TestAccount` — `{account, actor, session_cookie, api_token, create_session_headers, create_bearer_headers}`.
|
|
173
173
|
- `TestApp` — `{app, backend, surface_spec, surface, route_specs, create_session_headers, create_bearer_headers, create_daemon_token_headers, create_account, cleanup}`.
|
|
@@ -799,7 +799,11 @@ source of truth for wire-shape conformance.
|
|
|
799
799
|
|
|
800
800
|
- `testing/cross_backend/setup.ts` — `SetupTest` / `TestFixture` /
|
|
801
801
|
`TestAccountFixture` / `CreateTestAccountOptions` types,
|
|
802
|
-
`default_in_process_setup(options)` (wraps `create_test_app
|
|
802
|
+
`default_in_process_setup(options)` (wraps `create_test_app`; pass
|
|
803
|
+
`migration_namespaces` for suites needing tables beyond the auth-only
|
|
804
|
+
default — the cell parity suite passes `[CELL_MIGRATION_NS]`, and
|
|
805
|
+
`create_test_app` provisions a per-test fresh db migrating
|
|
806
|
+
`[auth_migration_ns, ...migration_namespaces]`), and
|
|
803
807
|
`default_in_process_suite_options(options)` (emits the full Tier 1 suite
|
|
804
808
|
options bag: the `{setup_test, surface_source, capabilities}` triple plus
|
|
805
809
|
`session_options` / `create_route_specs` / `rpc_endpoints` pass-through;
|
|
@@ -842,8 +846,16 @@ source of truth for wire-shape conformance.
|
|
|
842
846
|
|
|
843
847
|
- `testing/cross_backend/capabilities.ts` — `BackendCapabilities` vocabulary
|
|
844
848
|
(`bearer_auth` / `trusted_proxy` / `login_rate_limit` / `ws` / `sse` /
|
|
845
|
-
`
|
|
846
|
-
|
|
849
|
+
`cell_crud` / `cell_relations` / `account_lifecycle` / `in_process_only`),
|
|
850
|
+
`test_if(cond, name, fn)`
|
|
851
|
+
for capability-gated cases, and `in_process_capabilities` preset. `cell_crud`
|
|
852
|
+
gates the CRUD parity suite, `cell_relations` the relation / ACL / audit
|
|
853
|
+
parity suite — both `true` on every backend that live-mounts the full cell
|
|
854
|
+
surface (TS spine binary, in-process app, Rust stub). A backend mounting only
|
|
855
|
+
plain CRUD would declare `cell_crud: true, cell_relations: false`.
|
|
856
|
+
`account_lifecycle` gates `describe_account_lifecycle_cross_tests` (the
|
|
857
|
+
`account_delete` / `account_undelete` / `account_purge` parity suite) — also
|
|
858
|
+
off the declared surface like cells, `true` on every spine.
|
|
847
859
|
|
|
848
860
|
### `cross_backend/standard.ts` — `describe_standard_cross_process_tests`
|
|
849
861
|
|
|
@@ -925,6 +937,47 @@ only — wire from a `*.cross.test.ts`. fuz_app's own wiring is
|
|
|
925
937
|
`src/test/cross_backend/sse.cross.test.ts`; only the TS spines advertise
|
|
926
938
|
`sse` (they wire `audit_log_sse`), so the Rust `spine_stub` cases `.skip`.
|
|
927
939
|
|
|
940
|
+
### `cross_backend/cell_crud.ts` + `cell_relations.ts` — cell parity suites
|
|
941
|
+
|
|
942
|
+
The cell-layer parity coverage is split across two sibling suites. Cells
|
|
943
|
+
can't ride the generic `describe_rpc_round_trip_tests` (stateful verbs need a
|
|
944
|
+
real id threaded across calls; `cell_get` has a top-level `.refine()`), so —
|
|
945
|
+
like ws/sse — the full cell surface **live-mounts** on the spine RPC path but
|
|
946
|
+
stays **off** `create_spine_surface_spec`, and these dedicated suites are the
|
|
947
|
+
cell validators (`describe_standard_cross_process_tests`' generic round-trip
|
|
948
|
+
never sees them). Both parse every success response against the verb's Zod
|
|
949
|
+
**output** schema, so a TS↔Rust envelope drift fails the suite — not just a
|
|
950
|
+
payload-field drift. Call-site primitives (`rpc_call` / `error_reason` /
|
|
951
|
+
`expect_output` + the shared `CellCrossTestOptions`) live in
|
|
952
|
+
`cross_backend/cell_cross_helpers.ts`.
|
|
953
|
+
|
|
954
|
+
- **`describe_cell_crud_cross_tests`** (gates on `capabilities.cell_crud`) —
|
|
955
|
+
the create → get → update → delete → list lifecycle threading the id, plus
|
|
956
|
+
the CRUD authz matrix (owner CRUD; anon-public-only / private-404; non-owner
|
|
957
|
+
edit/read/delete → 404 IDOR mask; admin reaches any; dup active `path` → 409;
|
|
958
|
+
`path` write by non-admin → 403 on create + update; `cell_get` with no
|
|
959
|
+
id/path → `invalid_params`; null-auth `cell_list` `created_by` →
|
|
960
|
+
`invalid_params`).
|
|
961
|
+
- **`describe_cell_relations_cross_tests`** (gates on
|
|
962
|
+
`capabilities.cell_relations`) — the verbs beyond CRUD: grant lifecycle
|
|
963
|
+
(actor-shaped editor grant enables edit, manage-tier `cell_grant_list`,
|
|
964
|
+
revoke), the now-reachable `cell_visibility_manage_only` 403 (editor-grant
|
|
965
|
+
holder can't flip visibility), field set / forward+reverse list / idempotent
|
|
966
|
+
delete, item insert / ordered forward+reverse list / move / idempotent
|
|
967
|
+
delete, clone shallow (shares edges) vs deep (clones children), and
|
|
968
|
+
manage-tier `cell_audit_list` (owner reads the timeline; a viewer-grant
|
|
969
|
+
holder who can `cell_get` still gets the IDOR 404). Only **actor-shaped**
|
|
970
|
+
grants are exercised — role-shaped principals need a closed role registry the
|
|
971
|
+
Rust spine deliberately lacks.
|
|
972
|
+
|
|
973
|
+
Both gate `true` on TS + Rust (cells run on both, no `.skip`). Cross-process
|
|
974
|
+
wiring is `src/test/cross_backend/cell.cross.test.ts` (both suites); the
|
|
975
|
+
in-process legs (plain `gro test`) are `src/test/auth/cell_crud_parity.db.test.ts`
|
|
976
|
+
|
|
977
|
+
- `cell_relations_parity.db.test.ts`, sharing the full-surface
|
|
978
|
+
`create_cell_parity_setup` (`cell_parity_helpers.ts`) which mounts every cell
|
|
979
|
+
verb and registers `cell_audit_events` through the audit factory.
|
|
980
|
+
|
|
928
981
|
### Cross-process plumbing (consumed by `*.cross.test.ts` suites)
|
|
929
982
|
|
|
930
983
|
- `testing/cross_backend/backend_config.ts` — `BackendConfig` +
|
|
@@ -18,6 +18,7 @@ import { type Keyring } from '../auth/keyring.js';
|
|
|
18
18
|
import type { Db, DbType } from '../db/db.js';
|
|
19
19
|
import type { PasswordHashDeps } from '../auth/password.js';
|
|
20
20
|
import { type SessionOptions } from '../auth/session_cookie.js';
|
|
21
|
+
import { type MigrationNamespace } from '../db/migrate.js';
|
|
21
22
|
import { type AppBackend, type AuditFactory } from '../server/app_backend.js';
|
|
22
23
|
import { type AppServerOptions, type AppServerContext, type BootstrapServerOptions, type BootstrapLiveOptions } from '../server/app_server.js';
|
|
23
24
|
import type { AppSurface, AppSurfaceSpec } from '../http/surface.js';
|
|
@@ -146,6 +147,17 @@ export interface TestAppServerOptions {
|
|
|
146
147
|
db?: Db;
|
|
147
148
|
/** Database driver type — only used when `db` is provided. Default: `'pglite-memory'`. */
|
|
148
149
|
db_type?: DbType;
|
|
150
|
+
/**
|
|
151
|
+
* Extra migration namespaces run after the builtin auth namespace in the
|
|
152
|
+
* auto-created in-memory PGlite, mirroring `create_app_backend`'s
|
|
153
|
+
* `migration_namespaces`. For suites whose backend needs tables beyond
|
|
154
|
+
* auth — the cell parity suite passes `[CELL_MIGRATION_NS]`. The harness
|
|
155
|
+
* builds + caches a fresh-per-test factory migrating
|
|
156
|
+
* `[auth_migration_ns, ...migration_namespaces]`; the reset-on-`create`
|
|
157
|
+
* gives the same fresh-db isolation as the auth-only default. Mutually
|
|
158
|
+
* exclusive with `db` (which assumes the caller already migrated).
|
|
159
|
+
*/
|
|
160
|
+
migration_namespaces?: ReadonlyArray<MigrationNamespace>;
|
|
149
161
|
/** Password implementation. Default: `stub_password_deps`. Pass `argon2_password_deps` for tests that exercise login. */
|
|
150
162
|
password?: PasswordHashDeps;
|
|
151
163
|
/** Username for the bootstrapped account. Default: `'keeper'`. */
|