@fuzdev/fuz_app 0.143.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/auth/CLAUDE.md +8 -0
  2. package/dist/auth/cell_action_specs.d.ts +13 -0
  3. package/dist/auth/cell_action_specs.d.ts.map +1 -1
  4. package/dist/auth/cell_action_specs.js +13 -0
  5. package/dist/auth/cell_actions.d.ts +7 -0
  6. package/dist/auth/cell_actions.d.ts.map +1 -1
  7. package/dist/auth/cell_actions.js +30 -0
  8. package/dist/auth/cell_ref_admission.d.ts +51 -0
  9. package/dist/auth/cell_ref_admission.d.ts.map +1 -0
  10. package/dist/auth/cell_ref_admission.js +69 -0
  11. package/dist/auth/deps.d.ts +6 -3
  12. package/dist/auth/deps.d.ts.map +1 -1
  13. package/dist/db/CLAUDE.md +40 -23
  14. package/dist/db/cell_ddl.d.ts +3 -2
  15. package/dist/db/cell_ddl.d.ts.map +1 -1
  16. package/dist/db/cell_ddl.js +3 -2
  17. package/dist/db/cell_queries.d.ts +31 -6
  18. package/dist/db/cell_queries.d.ts.map +1 -1
  19. package/dist/db/cell_queries.js +45 -18
  20. package/dist/db/fact_ddl.d.ts +30 -4
  21. package/dist/db/fact_ddl.d.ts.map +1 -1
  22. package/dist/db/fact_ddl.js +44 -4
  23. package/dist/db/fact_holder_queries.d.ts +70 -0
  24. package/dist/db/fact_holder_queries.d.ts.map +1 -0
  25. package/dist/db/fact_holder_queries.js +79 -0
  26. package/dist/db/fact_queries.d.ts +19 -14
  27. package/dist/db/fact_queries.d.ts.map +1 -1
  28. package/dist/db/fact_queries.js +31 -10
  29. package/dist/db/fact_store.d.ts +65 -9
  30. package/dist/db/fact_store.d.ts.map +1 -1
  31. package/dist/db/fact_store.js +49 -20
  32. package/dist/server/fact_security_headers.d.ts +54 -0
  33. package/dist/server/fact_security_headers.d.ts.map +1 -0
  34. package/dist/server/fact_security_headers.js +53 -0
  35. package/dist/server/serve_fact_route.d.ts +33 -7
  36. package/dist/server/serve_fact_route.d.ts.map +1 -1
  37. package/dist/server/serve_fact_route.js +36 -32
  38. package/dist/server/x_accel.d.ts +106 -30
  39. package/dist/server/x_accel.d.ts.map +1 -1
  40. package/dist/server/x_accel.js +533 -86
  41. package/dist/testing/CLAUDE.md +25 -1
  42. package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -1
  43. package/dist/testing/cross_backend/cell_crud.js +6 -3
  44. package/dist/testing/cross_backend/cell_gated_create.d.ts.map +1 -1
  45. package/dist/testing/cross_backend/cell_gated_create.js +25 -16
  46. package/dist/testing/cross_backend/cell_ref_admission.d.ts +4 -0
  47. package/dist/testing/cross_backend/cell_ref_admission.d.ts.map +1 -0
  48. package/dist/testing/cross_backend/cell_ref_admission.js +268 -0
  49. package/dist/testing/cross_backend/expected_schema.json +66 -10
  50. package/dist/testing/cross_backend/fact_serving.d.ts +19 -1
  51. package/dist/testing/cross_backend/fact_serving.d.ts.map +1 -1
  52. package/dist/testing/cross_backend/fact_serving.js +31 -9
  53. package/dist/testing/cross_backend/testing_reset_actions.d.ts +6 -2
  54. package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
  55. package/dist/testing/cross_backend/testing_reset_actions.js +13 -3
  56. package/package.json +3 -3
@@ -1,8 +1,8 @@
1
1
  /**
2
- * PG-backed `FactStore` implementation.
2
+ * PG-backed `HeldFactStore` implementation.
3
3
  *
4
4
  * Wraps the raw queries in `db/fact_queries.ts` with the lifecycle the
5
- * `FactStore` interface promises:
5
+ * `HeldFactStore` interface promises:
6
6
  *
7
7
  * - sync hash on `put`; `put_stream` hashes as it streams
8
8
  * - idempotent insert (`ON CONFLICT DO NOTHING` in the queries layer)
@@ -33,13 +33,63 @@
33
33
  * `external_url` has any other shape, and any external row on a store with no
34
34
  * disk CAS configured, as unavailable: a warning and `null`.
35
35
  *
36
+ * ## Holders
37
+ *
38
+ * Every put names its holder (`HeldFactPutOptions.holder`): the actor whose
39
+ * request carried the bytes, or `null` for a system put (seeds, server-side
40
+ * imports). A named holder gets a `fact_holder` row on every put — a
41
+ * deduplicated put included, since bringing identical bytes is possession. The
42
+ * `fact` row, its refs, and the holder row commit in one transaction. Cell ref
43
+ * admission (`auth/cell_ref_admission.ts`) reads the holder rows. The holder is
44
+ * a spine concept, so `PgFactStore` implements `HeldFactStore` — the
45
+ * `@fuzdev/fuz_util` `FactStore` reads plus puts that take `HeldFactPutOptions` —
46
+ * rather than the backend-agnostic `FactStore`, whose puts name no actor.
47
+ *
36
48
  * @module
37
49
  */
38
50
  import type { QueryDeps } from './query_deps.js';
51
+ import type { Uuid } from '../primitive_schemas.js';
39
52
  import type { Logger } from '@fuzdev/fuz_util/log.ts';
40
53
  import type { FactHash } from '@fuzdev/fuz_util/hash_schemas.ts';
41
54
  import type { FactMeta, FactPutOptions, FactStore, PutStreamOutcome } from '@fuzdev/fuz_util/fact_store.ts';
42
55
  import { type FactDiskStorageDeps } from './fact_disk_storage.js';
56
+ /**
57
+ * Options for `HeldFactStore.put` / `put_stream`: the `@fuzdev/fuz_util`
58
+ * `FactPutOptions` plus the required `holder`.
59
+ *
60
+ * `holder` is the actor that brought these bytes, recorded in `fact_holder`
61
+ * whether or not the fact already existed. `null` is a system put (seeds,
62
+ * server-side imports) that records no holder — a cell write can newly
63
+ * reference such a fact only as a global admin or as an actor that puts the
64
+ * same bytes itself (refs a cell already carries stay admitted through update
65
+ * and clone). Required, so a put on behalf of a caller can't silently skip the
66
+ * possession record. Twin of the Rust `PutOptions::holder`.
67
+ */
68
+ export interface HeldFactPutOptions extends FactPutOptions {
69
+ holder: Uuid | null;
70
+ }
71
+ /**
72
+ * A fact store whose puts record possession: the `@fuzdev/fuz_util`
73
+ * `FactStore` reads, plus `put` / `put_stream` taking `HeldFactPutOptions` with
74
+ * its required `holder`.
75
+ *
76
+ * The holder is required — a store typed by the generic `FactStore` cannot
77
+ * record possession, so cell ref admission (`auth/cell_ref_admission.ts`)
78
+ * could never admit a ref to a fact put through it. `AppDeps.fact_store` is
79
+ * typed by this interface; `PgFactStore` implements it.
80
+ */
81
+ export interface HeldFactStore extends Omit<FactStore, 'put' | 'put_stream'> {
82
+ /**
83
+ * Idempotent like `FactStore.put`; records `options.holder` (when non-null)
84
+ * on a fresh insert and a dedup hit alike.
85
+ */
86
+ put: (bytes: Uint8Array, options: HeldFactPutOptions) => Promise<FactHash>;
87
+ /**
88
+ * Idempotent like `FactStore.put_stream`; records `options.holder` (when
89
+ * non-null) on a fresh insert and a dedup hit alike.
90
+ */
91
+ put_stream: (stream: ReadableStream<Uint8Array>, max_bytes: number, options: HeldFactPutOptions) => Promise<PutStreamOutcome>;
92
+ }
43
93
  /** Default embedded-vs-referenced cutoff (1 MiB). */
44
94
  export declare const FACT_EMBEDDED_THRESHOLD_DEFAULT: number;
45
95
  /**
@@ -70,10 +120,11 @@ export interface PgFactStoreDeps {
70
120
  log?: Logger;
71
121
  }
72
122
  /**
73
- * PG-backed `FactStore`. Delegates to `db/fact_queries.ts` for I/O and adds
74
- * the lifecycle layer described in the module doc.
123
+ * PG-backed fact store. Delegates to `db/fact_queries.ts` for I/O and adds
124
+ * the lifecycle layer described in the module doc. Implements `HeldFactStore`:
125
+ * the `@fuzdev/fuz_util` `FactStore` reads, puts taking `HeldFactPutOptions`.
75
126
  */
76
- export declare class PgFactStore implements FactStore {
127
+ export declare class PgFactStore implements HeldFactStore {
77
128
  #private;
78
129
  constructor(options: PgFactStoreDeps);
79
130
  /**
@@ -82,9 +133,12 @@ export declare class PgFactStore implements FactStore {
82
133
  * are configured) at `<facts_dir>/<shard>/<rest>` with a `file:` URL. Oversize
83
134
  * without a disk root throws: the store has nowhere to put bytes it cannot
84
135
  * embed. Idempotent — `ON CONFLICT DO NOTHING` + content-addressed disk
85
- * filenames make a re-write a no-op.
136
+ * filenames make a re-write a no-op. `options.holder`, when set, is recorded
137
+ * as a holder on a fresh insert and a dedup hit alike.
138
+ *
139
+ * @mutates fact_holder - records `options.holder` (when non-null) in the transaction that writes the `fact` row
86
140
  */
87
- put(bytes: Uint8Array, options?: FactPutOptions): Promise<FactHash>;
141
+ put(bytes: Uint8Array, options: HeldFactPutOptions): Promise<FactHash>;
88
142
  /**
89
143
  * Stream bytes into the store with bounded memory, returning the finalized
90
144
  * digests + size. Delegates the byte path to `stream_fact_to_disk` (hash
@@ -100,9 +154,11 @@ export declare class PgFactStore implements FactStore {
100
154
  *
101
155
  * Requires `fs` (and, for the over-threshold spill, `disk_root`) to be
102
156
  * configured. The streaming twin of `put`; mirrors the Rust
103
- * `FactStore::put_stream`.
157
+ * `FactStore::put_stream`. Records `options.holder` the way `put` does.
158
+ *
159
+ * @mutates fact_holder - records `options.holder` (when non-null) in the transaction that writes the `fact` row
104
160
  */
105
- put_stream(stream: ReadableStream<Uint8Array>, max_bytes: number, options?: FactPutOptions): Promise<PutStreamOutcome>;
161
+ put_stream(stream: ReadableStream<Uint8Array>, max_bytes: number, options: HeldFactPutOptions): Promise<PutStreamOutcome>;
106
162
  /**
107
163
  * Retrieve bytes. Embedded reads return PG bytes directly. An external
108
164
  * read comes from the disk CAS and nowhere else: the bytes at the row's
@@ -1 +1 @@
1
- {"version":3,"file":"fact_store.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAOtD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AACjE,OAAO,KAAK,EACX,QAAQ,EACR,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,MAAM,gCAAgC,CAAC;AAYxC,OAAO,EAIN,KAAK,mBAAmB,EACxB,MAAM,wBAAwB,CAAC;AAEhC,qDAAqD;AACrD,eAAO,MAAM,+BAA+B,QAAc,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,mBAAmB,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;GAGG;AACH,qBAAa,WAAY,YAAW,SAAS;;gBAOhC,OAAO,EAAE,eAAe;IAQpC;;;;;;;OAOG;IACG,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;IA+BzE;;;;;;;;;;;;;;;;OAgBG;IACG,UAAU,CACf,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,cAAc,GACtB,OAAO,CAAC,gBAAgB,CAAC;IA6B5B;;;;;;;;;OASG;IACG,GAAG,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA0C/C,GAAG,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAIrC,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAWlD,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAIxD;;;;;;;;;;;;;;;;;;OAkBG;IACG,MAAM,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CAG3F"}
1
+ {"version":3,"file":"fact_store.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/db/fact_store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAEpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAOtD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AACjE,OAAO,KAAK,EACX,QAAQ,EACR,cAAc,EACd,SAAS,EACT,gBAAgB,EAChB,MAAM,gCAAgC,CAAC;AAcxC,OAAO,EAIN,KAAK,mBAAmB,EACxB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,kBAAmB,SAAQ,cAAc;IACzD,MAAM,EAAE,IAAI,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAc,SAAQ,IAAI,CAAC,SAAS,EAAE,KAAK,GAAG,YAAY,CAAC;IAC3E;;;OAGG;IACH,GAAG,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,kBAAkB,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC3E;;;OAGG;IACH,UAAU,EAAE,CACX,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kBAAkB,KACvB,OAAO,CAAC,gBAAgB,CAAC,CAAC;CAC/B;AAED,qDAAqD;AACrD,eAAO,MAAM,+BAA+B,QAAc,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,eAAe;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,EAAE,CAAC,EAAE,mBAAmB,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,qBAAa,WAAY,YAAW,aAAa;;gBAOpC,OAAO,EAAE,eAAe;IAQpC;;;;;;;;;;OAUG;IACG,GAAG,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,QAAQ,CAAC;IAgC5E;;;;;;;;;;;;;;;;;;OAkBG;IACG,UAAU,CACf,MAAM,EAAE,cAAc,CAAC,UAAU,CAAC,EAClC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kBAAkB,GACzB,OAAO,CAAC,gBAAgB,CAAC;IA+C5B;;;;;;;;;OASG;IACG,GAAG,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IA0C/C,GAAG,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAIrC,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAWlD,QAAQ,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAIxD;;;;;;;;;;;;;;;;;;OAkBG;IACG,MAAM,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;CAG3F"}
@@ -1,8 +1,8 @@
1
1
  /**
2
- * PG-backed `FactStore` implementation.
2
+ * PG-backed `HeldFactStore` implementation.
3
3
  *
4
4
  * Wraps the raw queries in `db/fact_queries.ts` with the lifecycle the
5
- * `FactStore` interface promises:
5
+ * `HeldFactStore` interface promises:
6
6
  *
7
7
  * - sync hash on `put`; `put_stream` hashes as it streams
8
8
  * - idempotent insert (`ON CONFLICT DO NOTHING` in the queries layer)
@@ -33,18 +33,32 @@
33
33
  * `external_url` has any other shape, and any external row on a store with no
34
34
  * disk CAS configured, as unavailable: a warning and `null`.
35
35
  *
36
+ * ## Holders
37
+ *
38
+ * Every put names its holder (`HeldFactPutOptions.holder`): the actor whose
39
+ * request carried the bytes, or `null` for a system put (seeds, server-side
40
+ * imports). A named holder gets a `fact_holder` row on every put — a
41
+ * deduplicated put included, since bringing identical bytes is possession. The
42
+ * `fact` row, its refs, and the holder row commit in one transaction. Cell ref
43
+ * admission (`auth/cell_ref_admission.ts`) reads the holder rows. The holder is
44
+ * a spine concept, so `PgFactStore` implements `HeldFactStore` — the
45
+ * `@fuzdev/fuz_util` `FactStore` reads plus puts that take `HeldFactPutOptions` —
46
+ * rather than the backend-agnostic `FactStore`, whose puts name no actor.
47
+ *
36
48
  * @module
37
49
  */
38
50
  import { to_error_message } from '@fuzdev/fuz_util/error.ts';
39
51
  import { fact_hash_bytes, fact_hash_verify, fact_hash_extract_refs } from '@fuzdev/fuz_util/fact_hash.ts';
40
52
  import { query_delete_fact, query_get_fact, query_get_fact_meta, query_get_fact_refs, query_has_fact, query_put_fact, query_put_fact_refs } from './fact_queries.js';
53
+ import { query_put_fact_holder } from './fact_holder_queries.js';
41
54
  import { is_file_fact_url } from './file_fact_url.js';
42
55
  import { read_fact_bytes_from_disk, stream_fact_to_disk, write_fact_bytes_to_disk } from './fact_disk_storage.js';
43
56
  /** Default embedded-vs-referenced cutoff (1 MiB). */
44
57
  export const FACT_EMBEDDED_THRESHOLD_DEFAULT = 1024 * 1024;
45
58
  /**
46
- * PG-backed `FactStore`. Delegates to `db/fact_queries.ts` for I/O and adds
47
- * the lifecycle layer described in the module doc.
59
+ * PG-backed fact store. Delegates to `db/fact_queries.ts` for I/O and adds
60
+ * the lifecycle layer described in the module doc. Implements `HeldFactStore`:
61
+ * the `@fuzdev/fuz_util` `FactStore` reads, puts taking `HeldFactPutOptions`.
48
62
  */
49
63
  export class PgFactStore {
50
64
  #deps;
@@ -65,7 +79,10 @@ export class PgFactStore {
65
79
  * are configured) at `<facts_dir>/<shard>/<rest>` with a `file:` URL. Oversize
66
80
  * without a disk root throws: the store has nowhere to put bytes it cannot
67
81
  * embed. Idempotent — `ON CONFLICT DO NOTHING` + content-addressed disk
68
- * filenames make a re-write a no-op.
82
+ * filenames make a re-write a no-op. `options.holder`, when set, is recorded
83
+ * as a holder on a fresh insert and a dedup hit alike.
84
+ *
85
+ * @mutates fact_holder - records `options.holder` (when non-null) in the transaction that writes the `fact` row
69
86
  */
70
87
  async put(bytes, options) {
71
88
  const hash = fact_hash_bytes(bytes);
@@ -82,16 +99,13 @@ export class PgFactStore {
82
99
  row_bytes = bytes;
83
100
  row_external_url = null;
84
101
  }
85
- const inserted = await query_put_fact(this.#deps, {
102
+ await this.#insert_row({
86
103
  hash,
87
104
  bytes: row_bytes,
88
105
  external_url: row_external_url,
89
- content_type: options?.content_type ?? null,
106
+ content_type: options.content_type ?? null,
90
107
  size: bytes.length
91
- });
92
- if (inserted) {
93
- await query_put_fact_refs(this.#deps, hash, resolve_refs(bytes, options));
94
- }
108
+ }, resolve_refs(bytes, options), options.holder);
95
109
  return hash;
96
110
  }
97
111
  /**
@@ -109,7 +123,9 @@ export class PgFactStore {
109
123
  *
110
124
  * Requires `fs` (and, for the over-threshold spill, `disk_root`) to be
111
125
  * configured. The streaming twin of `put`; mirrors the Rust
112
- * `FactStore::put_stream`.
126
+ * `FactStore::put_stream`. Records `options.holder` the way `put` does.
127
+ *
128
+ * @mutates fact_holder - records `options.holder` (when non-null) in the transaction that writes the `fact` row
113
129
  */
114
130
  async put_stream(stream, max_bytes, options) {
115
131
  if (this.#fs === undefined) {
@@ -118,18 +134,31 @@ export class PgFactStore {
118
134
  const streamed = await stream_fact_to_disk(this.#fs, this.#disk_root, stream, max_bytes, this.#embedded_threshold);
119
135
  const row_bytes = streamed.placement.kind === 'embedded' ? streamed.placement.bytes : null;
120
136
  const row_external_url = streamed.placement.kind === 'disk' ? streamed.placement.external_url : null;
121
- const inserted = await query_put_fact(this.#deps, {
137
+ await this.#insert_row({
122
138
  hash: streamed.hash,
123
139
  bytes: row_bytes,
124
140
  external_url: row_external_url,
125
- content_type: options?.content_type ?? null,
141
+ content_type: options.content_type ?? null,
126
142
  size: streamed.size
127
- });
128
- if (inserted && options?.refs && options.refs.length > 0) {
129
- await query_put_fact_refs(this.#deps, streamed.hash, options.refs);
130
- }
143
+ }, options.refs ?? [], options.holder);
131
144
  return { hash: streamed.hash, sha256: streamed.sha256, size: streamed.size };
132
145
  }
146
+ /**
147
+ * Insert the `fact` row, then — in the same transaction — its refs (fresh
148
+ * insert only) and the holder row (every put, a dedup hit included). Shared
149
+ * by `put` and `put_stream`; twin of the Rust `PgFactStore::insert_row`.
150
+ */
151
+ async #insert_row(row, refs, holder) {
152
+ await this.#deps.db.transaction(async (db) => {
153
+ const inserted = await query_put_fact({ db }, row);
154
+ if (inserted && refs.length > 0) {
155
+ await query_put_fact_refs({ db }, row.hash, refs);
156
+ }
157
+ if (holder !== null) {
158
+ await query_put_fact_holder({ db }, holder, row.hash);
159
+ }
160
+ });
161
+ }
133
162
  /**
134
163
  * Retrieve bytes. Embedded reads return PG bytes directly. An external
135
164
  * read comes from the disk CAS and nowhere else: the bytes at the row's
@@ -219,9 +248,9 @@ export class PgFactStore {
219
248
  * from JSON content; otherwise no refs.
220
249
  */
221
250
  const resolve_refs = (bytes, options) => {
222
- if (options?.refs !== undefined)
251
+ if (options.refs !== undefined)
223
252
  return options.refs;
224
- if (options?.content_type !== 'application/json')
253
+ if (options.content_type !== 'application/json')
225
254
  return [];
226
255
  let value;
227
256
  try {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The untrusted-content hardening headers every served fact carries.
3
+ *
4
+ * Fact bytes are served with a producer-supplied `content_type` emitted
5
+ * verbatim, inline. To stop a fact stored as (or sniffable to) `text/html` from
6
+ * executing as stored XSS for any reader of a referencing cell, every fact
7
+ * response carries `FACT_SECURITY_HEADERS`: `X-Content-Type-Options: nosniff` +
8
+ * `Content-Security-Policy: default-src 'none'; sandbox`.
9
+ *
10
+ * The embedded and disk-stream branches of `server/serve_fact_route.ts` set them
11
+ * on the response directly. On the `X-Accel-Redirect` branch nginx serves the
12
+ * bytes from its internal facts location, and a location with its own
13
+ * `add_header` stops the server-level headers from applying — so that location
14
+ * must carry the set itself, as the lines `fact_security_header_directives`
15
+ * renders. The X-Accel validator (`server/x_accel.ts`) refuses a facts location
16
+ * without them.
17
+ *
18
+ * The Rust twin is `fuz_fact_serving::fact_security_headers`; both pin the same
19
+ * literals.
20
+ *
21
+ * @module
22
+ */
23
+ /**
24
+ * `X-Content-Type-Options` for fact responses — block MIME sniffing so an
25
+ * `application/octet-stream` fact can't be sniffed into executable HTML.
26
+ */
27
+ export declare const FACT_X_CONTENT_TYPE_OPTIONS = "nosniff";
28
+ /**
29
+ * `Content-Security-Policy` for fact responses. A fact stored as (or sniffable
30
+ * to) `text/html` would otherwise execute as stored XSS for any reader of a
31
+ * referencing cell. `default-src 'none'; sandbox` neutralizes script execution
32
+ * and sub-resource loads even when a fact is rendered inline; harmless for
33
+ * directly-served images (they load no sub-resources).
34
+ */
35
+ export declare const FACT_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
36
+ /**
37
+ * The fact hardening headers, in render order. `Content-Disposition:
38
+ * attachment` is deliberately absent — it would force-download and break
39
+ * legitimate inline image rendering.
40
+ */
41
+ export declare const FACT_SECURITY_HEADERS: {
42
+ readonly 'X-Content-Type-Options': "nosniff";
43
+ readonly 'Content-Security-Policy': "default-src 'none'; sandbox";
44
+ };
45
+ /**
46
+ * The exact nginx `add_header … always;` lines the internal facts location must
47
+ * carry, rendered from `FACT_SECURITY_HEADERS`. `always` makes nginx add the
48
+ * header on every status, not just 2xx/3xx. The X-Accel validator refuses a
49
+ * facts location missing any of them.
50
+ *
51
+ * @returns one `add_header` directive per header, each ending in `always;`
52
+ */
53
+ export declare const fact_security_header_directives: () => Array<string>;
54
+ //# sourceMappingURL=fact_security_headers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fact_security_headers.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/fact_security_headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;GAGG;AACH,eAAO,MAAM,2BAA2B,YAAY,CAAC;AAErD;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,gCAAgC,CAAC;AAE1E;;;;GAIG;AACH,eAAO,MAAM,qBAAqB;;;CAGxB,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,QAAO,KAAK,CAAC,MAAM,CAG7D,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The untrusted-content hardening headers every served fact carries.
3
+ *
4
+ * Fact bytes are served with a producer-supplied `content_type` emitted
5
+ * verbatim, inline. To stop a fact stored as (or sniffable to) `text/html` from
6
+ * executing as stored XSS for any reader of a referencing cell, every fact
7
+ * response carries `FACT_SECURITY_HEADERS`: `X-Content-Type-Options: nosniff` +
8
+ * `Content-Security-Policy: default-src 'none'; sandbox`.
9
+ *
10
+ * The embedded and disk-stream branches of `server/serve_fact_route.ts` set them
11
+ * on the response directly. On the `X-Accel-Redirect` branch nginx serves the
12
+ * bytes from its internal facts location, and a location with its own
13
+ * `add_header` stops the server-level headers from applying — so that location
14
+ * must carry the set itself, as the lines `fact_security_header_directives`
15
+ * renders. The X-Accel validator (`server/x_accel.ts`) refuses a facts location
16
+ * without them.
17
+ *
18
+ * The Rust twin is `fuz_fact_serving::fact_security_headers`; both pin the same
19
+ * literals.
20
+ *
21
+ * @module
22
+ */
23
+ /**
24
+ * `X-Content-Type-Options` for fact responses — block MIME sniffing so an
25
+ * `application/octet-stream` fact can't be sniffed into executable HTML.
26
+ */
27
+ export const FACT_X_CONTENT_TYPE_OPTIONS = 'nosniff';
28
+ /**
29
+ * `Content-Security-Policy` for fact responses. A fact stored as (or sniffable
30
+ * to) `text/html` would otherwise execute as stored XSS for any reader of a
31
+ * referencing cell. `default-src 'none'; sandbox` neutralizes script execution
32
+ * and sub-resource loads even when a fact is rendered inline; harmless for
33
+ * directly-served images (they load no sub-resources).
34
+ */
35
+ export const FACT_CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
36
+ /**
37
+ * The fact hardening headers, in render order. `Content-Disposition:
38
+ * attachment` is deliberately absent — it would force-download and break
39
+ * legitimate inline image rendering.
40
+ */
41
+ export const FACT_SECURITY_HEADERS = {
42
+ 'X-Content-Type-Options': FACT_X_CONTENT_TYPE_OPTIONS,
43
+ 'Content-Security-Policy': FACT_CONTENT_SECURITY_POLICY
44
+ };
45
+ /**
46
+ * The exact nginx `add_header … always;` lines the internal facts location must
47
+ * carry, rendered from `FACT_SECURITY_HEADERS`. `always` makes nginx add the
48
+ * header on every status, not just 2xx/3xx. The X-Accel validator refuses a
49
+ * facts location missing any of them.
50
+ *
51
+ * @returns one `add_header` directive per header, each ending in `always;`
52
+ */
53
+ export const fact_security_header_directives = () => Object.entries(FACT_SECURITY_HEADERS).map(([name, value]) => `add_header ${name} "${value}" always;`);
@@ -20,7 +20,8 @@
20
20
  * `XAccelConfig` (built via
21
21
  * `create_x_accel_config` in `server/x_accel.ts`) set in prod, unset in dev.
22
22
  * The handle can only be obtained by proving the facts nginx location is
23
- * `internal;`, so X-Accel serving can't be enabled against a public location.
23
+ * `internal;` and carries the fact hardening headers, so X-Accel serving can't
24
+ * be enabled against a public or header-less location.
24
25
  *
25
26
  * REST, not RPC: binary responses don't fit the JSON-RPC envelope.
26
27
  *
@@ -41,6 +42,25 @@
41
42
  * hash. B publishing identical bytes from B's public cell makes them
42
43
  * readable *via B's cell* — it never touches A's private reference.
43
44
  *
45
+ * The edge is only as safe as what may create it. A cell's `refs` derive
46
+ * from its `data`, and any account that can write a cell writes its `data` —
47
+ * so this route alone would make a known hash a read capability: name it in
48
+ * a cell you own, flip the cell public, and the bytes serve to the world,
49
+ * while a 200 against a 404 says whether the fact exists. What closes that is
50
+ * **possession-gated ref admission** on the cell verbs
51
+ * (`auth/cell_ref_admission.ts`): a non-admin `cell_create` / `cell_update` /
52
+ * `cell_clone` may introduce a ref only to a fact its acting actor holds —
53
+ * one it put the bytes of, recorded in `fact_holder` on every put, a
54
+ * deduplicated put included — and is refused `403 cell_ref_not_held`
55
+ * otherwise, the same answer whether or not a fact is stored. Refs a cell
56
+ * already carries stay admitted through updates and clones, so a viewer who
57
+ * clones a cell carries its refs: what a viewer can read through a cell, it
58
+ * can republish — no more than downloading the bytes and uploading them
59
+ * again. A serve is a snapshot of the edge at read time; revoking someone's
60
+ * access to a cell does not withdraw refs they already wrote elsewhere.
61
+ * Writers that bypass the verbs (seeds, raw inserts) are trusted and not
62
+ * admitted.
63
+ *
44
64
  * The bare-hash route is **admin-only**: an admin's reach already spans
45
65
  * every cell, so serving by bare hash grants no escalation. Non-admin
46
66
  * callers are rejected at the auth phase and never reach the handler.
@@ -64,10 +84,11 @@
64
84
  * don't distinguish 403 from 404 — neither the existence of a fact nor the
65
85
  * existence of a cell→fact edge should leak through the public surface.
66
86
  *
67
- * Content-addressed serving of inline `blake3:` images (a markdown doc cell
68
- * with embedded image refs) works through this model: the referencing cell
69
- * is the doc cell, so serving goes view-doc-cell → doc-cell-refs-include-hash
70
- * → serve.
87
+ * A ref is a `data` string value that is exactly a fact hash
88
+ * (`derive_cell_refs`) — a hash inside prose or markup is not one. So a doc
89
+ * cell that shows images stores each image's hash as its own string value
90
+ * (an attachment list, say) beside the text that places them; serving then
91
+ * goes view-doc-cell → doc-cell-refs-include-hash → serve.
71
92
  *
72
93
  * ## Untrusted-content hardening
73
94
  *
@@ -76,8 +97,13 @@
76
97
  * from executing as stored XSS for any reader of a referencing cell, **every**
77
98
  * served-fact response carries `X-Content-Type-Options: nosniff` +
78
99
  * `Content-Security-Policy: default-src 'none'; sandbox` (the
79
- * `FACT_SECURITY_HEADERS` set, applied in all three serve branches). The same
80
- * pair, byte-identical, is set by the Rust twin (`fuz_fact_serving::serve`).
100
+ * `FACT_SECURITY_HEADERS` set from `server/fact_security_headers.ts`,
101
+ * byte-identical with the Rust twin `fuz_fact_serving`). The embedded and
102
+ * disk-stream branches set them on the response. On the `X-Accel-Redirect`
103
+ * branch nginx serves the bytes, and the headers the client sees come from the
104
+ * internal facts location, not this response — that location must carry the
105
+ * lines `fact_security_header_directives` renders, which `create_x_accel_config`
106
+ * enforces.
81
107
  * `Content-Disposition: attachment` is deliberately **not** added — it would
82
108
  * force-download and break legitimate inline image rendering.
83
109
  *
@@ -1 +1 @@
1
- {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8FG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAkEjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAkHD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAuDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}
1
+ {"version":3,"file":"serve_fact_route.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/server/serve_fact_route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwHG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAetD,OAAO,EAAuC,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAE5F,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAuCjD,MAAM,WAAW,+BAA+B;IAC/C;;;;;;OAMG;IACH,IAAI,EAAE,OAAO,CAAC;IACd,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;IAClB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,GAAG,EAAE,MAAM,CAAC;CACZ;AAoHD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAC7C,SAAS,+BAA+B,KACtC,SAuDF,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,4BAA4B,GACxC,SAAS,+BAA+B,KACtC,SA2CF,CAAC"}
@@ -20,7 +20,8 @@
20
20
  * `XAccelConfig` (built via
21
21
  * `create_x_accel_config` in `server/x_accel.ts`) set in prod, unset in dev.
22
22
  * The handle can only be obtained by proving the facts nginx location is
23
- * `internal;`, so X-Accel serving can't be enabled against a public location.
23
+ * `internal;` and carries the fact hardening headers, so X-Accel serving can't
24
+ * be enabled against a public or header-less location.
24
25
  *
25
26
  * REST, not RPC: binary responses don't fit the JSON-RPC envelope.
26
27
  *
@@ -41,6 +42,25 @@
41
42
  * hash. B publishing identical bytes from B's public cell makes them
42
43
  * readable *via B's cell* — it never touches A's private reference.
43
44
  *
45
+ * The edge is only as safe as what may create it. A cell's `refs` derive
46
+ * from its `data`, and any account that can write a cell writes its `data` —
47
+ * so this route alone would make a known hash a read capability: name it in
48
+ * a cell you own, flip the cell public, and the bytes serve to the world,
49
+ * while a 200 against a 404 says whether the fact exists. What closes that is
50
+ * **possession-gated ref admission** on the cell verbs
51
+ * (`auth/cell_ref_admission.ts`): a non-admin `cell_create` / `cell_update` /
52
+ * `cell_clone` may introduce a ref only to a fact its acting actor holds —
53
+ * one it put the bytes of, recorded in `fact_holder` on every put, a
54
+ * deduplicated put included — and is refused `403 cell_ref_not_held`
55
+ * otherwise, the same answer whether or not a fact is stored. Refs a cell
56
+ * already carries stay admitted through updates and clones, so a viewer who
57
+ * clones a cell carries its refs: what a viewer can read through a cell, it
58
+ * can republish — no more than downloading the bytes and uploading them
59
+ * again. A serve is a snapshot of the edge at read time; revoking someone's
60
+ * access to a cell does not withdraw refs they already wrote elsewhere.
61
+ * Writers that bypass the verbs (seeds, raw inserts) are trusted and not
62
+ * admitted.
63
+ *
44
64
  * The bare-hash route is **admin-only**: an admin's reach already spans
45
65
  * every cell, so serving by bare hash grants no escalation. Non-admin
46
66
  * callers are rejected at the auth phase and never reach the handler.
@@ -64,10 +84,11 @@
64
84
  * don't distinguish 403 from 404 — neither the existence of a fact nor the
65
85
  * existence of a cell→fact edge should leak through the public surface.
66
86
  *
67
- * Content-addressed serving of inline `blake3:` images (a markdown doc cell
68
- * with embedded image refs) works through this model: the referencing cell
69
- * is the doc cell, so serving goes view-doc-cell → doc-cell-refs-include-hash
70
- * → serve.
87
+ * A ref is a `data` string value that is exactly a fact hash
88
+ * (`derive_cell_refs`) — a hash inside prose or markup is not one. So a doc
89
+ * cell that shows images stores each image's hash as its own string value
90
+ * (an attachment list, say) beside the text that places them; serving then
91
+ * goes view-doc-cell → doc-cell-refs-include-hash → serve.
71
92
  *
72
93
  * ## Untrusted-content hardening
73
94
  *
@@ -76,8 +97,13 @@
76
97
  * from executing as stored XSS for any reader of a referencing cell, **every**
77
98
  * served-fact response carries `X-Content-Type-Options: nosniff` +
78
99
  * `Content-Security-Policy: default-src 'none'; sandbox` (the
79
- * `FACT_SECURITY_HEADERS` set, applied in all three serve branches). The same
80
- * pair, byte-identical, is set by the Rust twin (`fuz_fact_serving::serve`).
100
+ * `FACT_SECURITY_HEADERS` set from `server/fact_security_headers.ts`,
101
+ * byte-identical with the Rust twin `fuz_fact_serving`). The embedded and
102
+ * disk-stream branches set them on the response. On the `X-Accel-Redirect`
103
+ * branch nginx serves the bytes, and the headers the client sees come from the
104
+ * internal facts location, not this response — that location must carry the
105
+ * lines `fact_security_header_directives` renders, which `create_x_accel_config`
106
+ * enforces.
81
107
  * `Content-Disposition: attachment` is deliberately **not** added — it would
82
108
  * force-download and break legitimate inline image rendering.
83
109
  *
@@ -111,33 +137,9 @@ import { query_cell_grant_list_for_cell } from '../db/cell_grant_queries.js';
111
137
  import { can_view_cell } from '../auth/cell_authorize.js';
112
138
  import { cell_steward_admits } from '../auth/cell_steward.js';
113
139
  import { parse_file_fact_url } from '../db/file_fact_url.js';
140
+ import { FACT_SECURITY_HEADERS } from './fact_security_headers.js';
114
141
  /** `Cache-Control` for fact responses — 5 min revocation window. */
115
142
  const CACHE_CONTROL = 'private, max-age=300';
116
- /**
117
- * `X-Content-Type-Options` for fact responses — block MIME sniffing so an
118
- * `application/octet-stream` fact can't be sniffed into executable HTML.
119
- */
120
- const X_CONTENT_TYPE_OPTIONS = 'nosniff';
121
- /**
122
- * `Content-Security-Policy` for fact responses. Fact bytes carry a
123
- * producer-supplied `content_type` served verbatim inline, so a fact stored as
124
- * (or sniffable to) `text/html` would otherwise execute as stored XSS for any
125
- * reader of a referencing cell. `default-src 'none'; sandbox` neutralizes
126
- * script execution and sub-resource loads even when a fact is rendered inline;
127
- * harmless for directly-served images (they load no sub-resources). The same
128
- * pair, byte-identical, is set by the Rust twin (`fuz_fact_serving::serve`).
129
- */
130
- const CONTENT_SECURITY_POLICY = "default-src 'none'; sandbox";
131
- /**
132
- * The untrusted-content hardening headers applied to every served-fact
133
- * response across all three branches (embedded, `X-Accel-Redirect`,
134
- * disk-stream). `Content-Disposition: attachment` is deliberately omitted — it
135
- * would force-download and break legitimate inline image rendering.
136
- */
137
- const FACT_SECURITY_HEADERS = {
138
- 'X-Content-Type-Options': X_CONTENT_TYPE_OPTIONS,
139
- 'Content-Security-Policy': CONTENT_SECURITY_POLICY
140
- };
141
143
  /**
142
144
  * Path-param schema for the bare-hash route. Matching the canonical
143
145
  * fact-hash form here pushes malformed-hash 400s through the framework's
@@ -214,6 +216,8 @@ const serve_fact_bytes = async (c, route, hash, config) => {
214
216
  const { shard, rest } = parsed;
215
217
  if (x_accel !== undefined) {
216
218
  // Production: hand off to nginx via the validated `internal;` facts location.
219
+ // nginx answers with that location's own `add_header` set, not these
220
+ // headers, which is why the validator requires the fact pair there.
217
221
  return c.body(null, 200, {
218
222
  'Content-Type': content_type,
219
223
  'Content-Length': size,