@fuzdev/fuz_app 0.74.0 → 0.76.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 +4 -0
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +19 -14
- package/dist/auth/bearer_auth.d.ts +5 -1
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -1
- package/dist/db/CLAUDE.md +4 -3
- package/dist/db/cell_queries.d.ts +0 -23
- package/dist/db/cell_queries.d.ts.map +1 -1
- package/dist/db/cell_queries.js +0 -30
- package/dist/http/route_spec.d.ts +15 -0
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/surface.d.ts +6 -0
- package/dist/http/surface.d.ts.map +1 -1
- package/dist/http/surface.js +1 -0
- package/dist/server/serve_fact_route.d.ts +84 -33
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +242 -141
- package/dist/testing/CLAUDE.md +5 -1
- package/dist/testing/cross_backend/setup.d.ts +33 -0
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +19 -1
- package/dist/testing/cross_backend/standard.d.ts +19 -1
- package/dist/testing/cross_backend/standard.d.ts.map +1 -1
- package/dist/testing/cross_backend/standard.js +2 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +14 -0
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +24 -1
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +78 -0
- package/dist/testing/round_trip.d.ts +19 -1
- package/dist/testing/round_trip.d.ts.map +1 -1
- package/dist/testing/round_trip.js +75 -3
- package/dist/testing/rpc_round_trip.d.ts +23 -1
- package/dist/testing/rpc_round_trip.d.ts.map +1 -1
- package/dist/testing/rpc_round_trip.js +26 -1
- package/package.json +7 -7
|
@@ -1,38 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Content-addressed fact serving — cell-scoped, per-reference reads.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
4
|
+
* Two routes serve fact bytes from the PG-backed fact store:
|
|
5
|
+
*
|
|
6
|
+
* - `GET /api/cells/:cell_id/facts/:hash` — the **per-reference read**.
|
|
7
|
+
* The request names the referencing cell. Authz is scoped to that one
|
|
8
|
+
* reference: `can_view_cell(caller, cell) AND cell.refs includes hash`.
|
|
9
|
+
* This is the path non-admin callers use, and the only path for
|
|
10
|
+
* confidential content.
|
|
11
|
+
* - `GET /api/facts/:hash` — the **bare-hash read**, restricted to admins.
|
|
12
|
+
*
|
|
13
|
+
* Embedded facts stream from the `fact.bytes` PG column; external facts
|
|
14
|
+
* (filesystem-backed `file:<shard>/<rest>` URLs) either return an
|
|
15
|
+
* `X-Accel-Redirect` header pointing into nginx's internal facts location
|
|
16
|
+
* (production) or stream from disk via the filesystem `FactExternalFetcher`
|
|
17
|
+
* (dev / tests). The runtime mode is selected by the optional
|
|
18
|
+
* `x_accel_redirect_prefix` factory option — set in prod, unset in dev.
|
|
12
19
|
*
|
|
13
20
|
* REST, not RPC: binary responses don't fit the JSON-RPC envelope.
|
|
14
21
|
*
|
|
15
|
-
* ## Authorization
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
22
|
+
* ## Authorization — authz lives on the cell→fact edge, not the hash
|
|
23
|
+
*
|
|
24
|
+
* Facts are global, content-addressed, owner-less bytes: identical bytes
|
|
25
|
+
* from different owners dedup to **one** `fact` row. Keying access control
|
|
26
|
+
* on the bare hash therefore unions visibility across every owner that
|
|
27
|
+
* references it — A's private bytes leak the instant B references identical
|
|
28
|
+
* bytes from a public cell. The fix is to scope authz to the
|
|
29
|
+
* `(cell, hash)` edge: a caller reads a fact *through a specific cell it
|
|
30
|
+
* can view that references the hash*. Dedup becomes a pure storage
|
|
31
|
+
* optimization with zero authz consequence — whether two owners' bytes
|
|
32
|
+
* share a `fact` row is invisible to the read check.
|
|
33
|
+
*
|
|
34
|
+
* The cell-scoped route resolves the named cell, requires
|
|
35
|
+
* `can_view_cell(caller, cell)`, and requires `cell.refs` to include the
|
|
36
|
+
* hash. B publishing identical bytes from B's public cell makes them
|
|
37
|
+
* readable *via B's cell* — it never touches A's private reference.
|
|
38
|
+
*
|
|
39
|
+
* The bare-hash route is **admin-only**: an admin's reach already spans
|
|
40
|
+
* every cell, so serving by bare hash grants no escalation. Non-admin
|
|
41
|
+
* callers are rejected at the auth phase and never reach the handler.
|
|
42
|
+
* (Explicitly-public facts — a producer opting bytes into world-readable
|
|
43
|
+
* status — are a future refinement; there is no such concept today, so
|
|
44
|
+
* the bare-hash route stays strictly admin-gated.)
|
|
45
|
+
*
|
|
46
|
+
* Auth shape on the cell-scoped route is `{account: 'none', actor: 'none'}`
|
|
47
|
+
* — the dispatcher's authorization phase is skipped for pure-public routes,
|
|
48
|
+
* so the handler builds the `RequestContext` itself from `c.var.account_id`
|
|
49
|
+
* (populated by the `/api/*` session middleware) by resolving the caller's
|
|
50
|
+
* single actor and loading their role_grants. Unauthed callers pass through
|
|
51
|
+
* with `req_ctx: null` and are admitted only by a `cell.visibility ===
|
|
52
|
+
* 'public'` cell. Multi-actor accounts fall through with `req_ctx: null`
|
|
53
|
+
* — there's no `acting?` slot on a pure-public route, so multi-actor
|
|
54
|
+
* callers are treated as anonymous.
|
|
55
|
+
*
|
|
56
|
+
* 404 is the universal "not viewable" response: missing fact, missing or
|
|
57
|
+
* unviewable cell, or the cell doesn't reference the hash. We deliberately
|
|
58
|
+
* don't distinguish 403 from 404 — neither the existence of a fact nor the
|
|
59
|
+
* existence of a cell→fact edge should leak through the public surface.
|
|
60
|
+
*
|
|
61
|
+
* Content-addressed serving of inline `blake3:` images (a markdown doc cell
|
|
62
|
+
* with embedded image refs) works through this model: the referencing cell
|
|
63
|
+
* is the doc cell, so serving goes view-doc-cell → doc-cell-refs-include-hash
|
|
64
|
+
* → serve.
|
|
36
65
|
*
|
|
37
66
|
* ## Defense-in-depth
|
|
38
67
|
*
|
|
@@ -47,151 +76,223 @@ import { createReadStream } from 'node:fs';
|
|
|
47
76
|
import { Readable } from 'node:stream';
|
|
48
77
|
import { join } from 'node:path';
|
|
49
78
|
import { FactHashSchema } from '@fuzdev/fuz_util/fact_hash.js';
|
|
79
|
+
import { Uuid } from '@fuzdev/fuz_util/id.js';
|
|
50
80
|
import { z } from 'zod';
|
|
51
|
-
import { build_request_context } from '../auth/request_context.js';
|
|
81
|
+
import { build_request_context, get_request_context, has_role, } from '../auth/request_context.js';
|
|
82
|
+
import { ROLE_ADMIN } from '../auth/role_schema.js';
|
|
83
|
+
import { ActingActor } from '../http/auth_shape.js';
|
|
52
84
|
import { ACCOUNT_ID_KEY } from '../hono_context.js';
|
|
53
85
|
import { query_actors_by_account } from '../auth/account_queries.js';
|
|
54
86
|
import { get_route_params } from '../http/route_spec.js';
|
|
55
87
|
import { ERROR_INVALID_ROUTE_PARAMS } from '../http/error_schemas.js';
|
|
56
88
|
import { query_get_fact, query_get_fact_meta } from '../db/fact_queries.js';
|
|
57
|
-
import {
|
|
89
|
+
import { query_cell_get } from '../db/cell_queries.js';
|
|
58
90
|
import { query_cell_grant_list_for_cell } from '../db/cell_grant_queries.js';
|
|
59
91
|
import { can_view_cell } from '../auth/cell_authorize.js';
|
|
60
92
|
import { parse_file_fact_url } from './file_fact_url.js';
|
|
61
93
|
/** `Cache-Control` for fact responses — 5 min revocation window. */
|
|
62
94
|
const CACHE_CONTROL = 'private, max-age=300';
|
|
63
95
|
/**
|
|
64
|
-
* Path-param schema
|
|
65
|
-
* malformed-hash 400s through the framework's
|
|
66
|
-
* error shape (`ERROR_INVALID_ROUTE_PARAMS`),
|
|
67
|
-
* validator expects.
|
|
96
|
+
* Path-param schema for the bare-hash route. Matching the canonical
|
|
97
|
+
* fact-hash form here pushes malformed-hash 400s through the framework's
|
|
98
|
+
* standard params-validation error shape (`ERROR_INVALID_ROUTE_PARAMS`),
|
|
99
|
+
* which the round-trip validator expects.
|
|
100
|
+
*/
|
|
101
|
+
const bare_hash_params_schema = z.strictObject({
|
|
102
|
+
hash: FactHashSchema,
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* Path-param schema for the cell-scoped route. `cell_id` validates as a
|
|
106
|
+
* `Uuid`; `hash` as the canonical fact-hash form. Malformed values 400 via
|
|
107
|
+
* the standard params-validation shape.
|
|
68
108
|
*/
|
|
69
|
-
const
|
|
109
|
+
const cell_fact_params_schema = z.strictObject({
|
|
110
|
+
cell_id: Uuid,
|
|
70
111
|
hash: FactHashSchema,
|
|
71
112
|
});
|
|
113
|
+
/** Shared error-schema entry: tighten the auto-derived 400 to the literal emitted by params validation. */
|
|
114
|
+
const params_400_error = {
|
|
115
|
+
400: z.looseObject({
|
|
116
|
+
error: z.literal(ERROR_INVALID_ROUTE_PARAMS),
|
|
117
|
+
issues: z.array(z.unknown()),
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Serve a fact's bytes by hash, after the caller has been authorized for it.
|
|
122
|
+
*
|
|
123
|
+
* This is the shared tail of both routes — it never re-checks authz, so it
|
|
124
|
+
* MUST only be called once the caller has been admitted (admin on the
|
|
125
|
+
* bare-hash route, viewable referencing cell on the cell-scoped route).
|
|
126
|
+
* Reuses the embedded-stream / `X-Accel-Redirect` / disk-stream logic.
|
|
127
|
+
*
|
|
128
|
+
* Returns 404 when the fact's metadata is missing or its embedded bytes
|
|
129
|
+
* have vanished (a race), so the response shape is identical whether the
|
|
130
|
+
* fact is genuinely absent or merely unviewable.
|
|
131
|
+
*/
|
|
132
|
+
const serve_fact_bytes = async (c, route, hash, config) => {
|
|
133
|
+
const { facts_dir, x_accel_redirect_prefix, log } = config;
|
|
134
|
+
const meta = await query_get_fact_meta({ db: route.db }, hash);
|
|
135
|
+
if (!meta) {
|
|
136
|
+
return c.body(null, 404);
|
|
137
|
+
}
|
|
138
|
+
const content_type = meta.content_type ?? 'application/octet-stream';
|
|
139
|
+
const size = String(meta.size);
|
|
140
|
+
if (meta.external_url === null) {
|
|
141
|
+
// Embedded — bytes live in the PG row.
|
|
142
|
+
const row = await query_get_fact({ db: route.db }, hash);
|
|
143
|
+
if (!row || row.bytes === null) {
|
|
144
|
+
// Race: meta said embedded but bytes vanished. Treat as not-found.
|
|
145
|
+
log.warn(`serve_fact: embedded bytes missing for ${hash} (meta said embedded, row=${row ? 'present' : 'null'})`);
|
|
146
|
+
return c.body(null, 404);
|
|
147
|
+
}
|
|
148
|
+
const bytes = to_uint8(row.bytes);
|
|
149
|
+
return c.body(bytes, 200, {
|
|
150
|
+
'Content-Type': content_type,
|
|
151
|
+
'Content-Length': size,
|
|
152
|
+
'Cache-Control': CACHE_CONTROL,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// External — defense-in-depth re-validate the URL before trusting it
|
|
156
|
+
// to address the filesystem.
|
|
157
|
+
const parsed = parse_file_fact_url(meta.external_url);
|
|
158
|
+
if (!parsed) {
|
|
159
|
+
log.error(`serve_fact: rejecting malformed external_url for ${hash}: ${meta.external_url}`);
|
|
160
|
+
return c.body(null, 404);
|
|
161
|
+
}
|
|
162
|
+
const { shard, rest } = parsed;
|
|
163
|
+
if (x_accel_redirect_prefix !== undefined) {
|
|
164
|
+
// Production: hand off to nginx via the internal facts location.
|
|
165
|
+
return c.body(null, 200, {
|
|
166
|
+
'Content-Type': content_type,
|
|
167
|
+
'Content-Length': size,
|
|
168
|
+
'Cache-Control': CACHE_CONTROL,
|
|
169
|
+
'X-Accel-Redirect': `${x_accel_redirect_prefix}${shard}/${rest}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Dev / tests: stream from disk. `createReadStream` errors land on the
|
|
173
|
+
// returned ReadableStream, which Hono surfaces as a 500 to the client.
|
|
174
|
+
const file_path = join(facts_dir, shard, rest);
|
|
175
|
+
const node_stream = createReadStream(file_path);
|
|
176
|
+
const web_stream = Readable.toWeb(node_stream);
|
|
177
|
+
return c.body(web_stream, 200, {
|
|
178
|
+
'Content-Type': content_type,
|
|
179
|
+
'Content-Length': size,
|
|
180
|
+
'Cache-Control': CACHE_CONTROL,
|
|
181
|
+
});
|
|
182
|
+
};
|
|
72
183
|
/**
|
|
73
|
-
*
|
|
184
|
+
* Resolve the caller's `RequestContext` on a pure-public route from the
|
|
185
|
+
* session-middleware-set account id. Multi-actor accounts return `null`
|
|
186
|
+
* (no `acting?` slot on a public route to disambiguate); single-actor
|
|
187
|
+
* accounts resolve their actor and role_grants for owner / grant / admin
|
|
188
|
+
* admission paths. Unauthenticated callers return `null`.
|
|
189
|
+
*/
|
|
190
|
+
const build_public_request_context = async (c, route) => {
|
|
191
|
+
const account_id = c.get(ACCOUNT_ID_KEY);
|
|
192
|
+
if (!account_id)
|
|
193
|
+
return null;
|
|
194
|
+
const actors = await query_actors_by_account({ db: route.db }, account_id);
|
|
195
|
+
if (actors.length !== 1)
|
|
196
|
+
return null;
|
|
197
|
+
return build_request_context({ db: route.db }, account_id, actors[0].id);
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Build the cell-scoped `GET /api/cells/:cell_id/facts/:hash` `RouteSpec`
|
|
201
|
+
* — the per-reference read.
|
|
202
|
+
*
|
|
203
|
+
* Resolves the named cell (404 if missing / soft-deleted), requires
|
|
204
|
+
* `can_view_cell(caller, cell)` AND `cell.refs` to include the hash
|
|
205
|
+
* (else 404, masked), then serves the bytes. Authz is scoped to this one
|
|
206
|
+
* `(cell, hash)` edge — never unioned across the fact's other referrers.
|
|
74
207
|
*
|
|
75
208
|
* Pure-public auth — the handler builds the per-request `RequestContext`
|
|
76
|
-
* from `c.var.account_id` and enforces visibility per-
|
|
77
|
-
* cell-walk above.
|
|
209
|
+
* from `c.var.account_id` and enforces visibility per-reference.
|
|
78
210
|
*/
|
|
79
|
-
export const
|
|
211
|
+
export const create_serve_cell_fact_route_spec = (options) => {
|
|
80
212
|
const { facts_dir, x_accel_redirect_prefix, log } = options;
|
|
213
|
+
const config = { facts_dir, x_accel_redirect_prefix, log };
|
|
81
214
|
return {
|
|
82
215
|
method: 'GET',
|
|
83
|
-
path: '/api/facts/:hash',
|
|
216
|
+
path: '/api/cells/:cell_id/facts/:hash',
|
|
84
217
|
auth: { account: 'none', actor: 'none' },
|
|
85
|
-
description: 'Serve content-addressed fact bytes. 404 unless
|
|
86
|
-
params:
|
|
218
|
+
description: 'Serve content-addressed fact bytes through a named referencing cell. 404 unless the cell admits the caller via can_view_cell AND references the hash (per-reference, never union-of-referrers).',
|
|
219
|
+
params: cell_fact_params_schema,
|
|
87
220
|
input: z.null(),
|
|
88
221
|
// The body is a binary stream; no JSON output schema applies.
|
|
89
222
|
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
|
-
},
|
|
223
|
+
errors: params_400_error,
|
|
100
224
|
handler: async (c, route) => {
|
|
101
|
-
const { hash } = get_route_params(c);
|
|
102
|
-
|
|
103
|
-
|
|
225
|
+
const { cell_id, hash } = get_route_params(c);
|
|
226
|
+
// Resolve the named cell. Missing / soft-deleted → 404 (masked).
|
|
227
|
+
const cell = await query_cell_get({ db: route.db }, cell_id);
|
|
228
|
+
if (!cell) {
|
|
104
229
|
return c.body(null, 404);
|
|
105
230
|
}
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
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.
|
|
231
|
+
// The cell→fact edge: the cell must actually reference the hash.
|
|
232
|
+
// `cell.refs` is auto-derived from `data` on every write, so this is
|
|
233
|
+
// the authoritative "does this cell reference these bytes" check.
|
|
234
|
+
// Missing edge → 404 (masked), never "exists elsewhere".
|
|
235
|
+
if (!cell.refs?.includes(hash)) {
|
|
149
236
|
return c.body(null, 404);
|
|
150
237
|
}
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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}`);
|
|
238
|
+
// Per-reference view check — scoped to *this* cell only.
|
|
239
|
+
const req_ctx = await build_public_request_context(c, route);
|
|
240
|
+
// Unauthenticated callers skip the grant fetch entirely — no grant
|
|
241
|
+
// can admit a null req_ctx (the only admit path is the
|
|
242
|
+
// public-visibility branch in `can_view_cell`, which doesn't read
|
|
243
|
+
// grants).
|
|
244
|
+
const grants = req_ctx ? await query_cell_grant_list_for_cell({ db: route.db }, cell.id) : null;
|
|
245
|
+
if (!can_view_cell(req_ctx, cell, grants)) {
|
|
246
|
+
// 404 (not 403) so an unviewable cell→fact edge doesn't leak.
|
|
173
247
|
return c.body(null, 404);
|
|
174
248
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
249
|
+
return serve_fact_bytes(c, route, hash, config);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Build the admin-only bare-hash `GET /api/facts/:hash` `RouteSpec`.
|
|
255
|
+
*
|
|
256
|
+
* An admin's reach already spans every cell, so serving by bare hash grants
|
|
257
|
+
* no escalation — the union concern that made this route a cross-owner leak
|
|
258
|
+
* for non-admins is vacuous for an admin. Non-admin callers are rejected at
|
|
259
|
+
* the auth phase (403) and never reach the handler. Confidential non-admin
|
|
260
|
+
* reads always go through the cell-scoped route above.
|
|
261
|
+
*
|
|
262
|
+
* Auth is `{account: 'required', actor: 'required', roles: ['admin']}` —
|
|
263
|
+
* the dispatcher's authorization phase resolves the acting actor and the
|
|
264
|
+
* post-authorization guard enforces the admin role before the handler runs.
|
|
265
|
+
* The handler re-checks `has_role(_, admin)` as defense-in-depth so a future
|
|
266
|
+
* mounting/auth-shape regression fails closed rather than serving by bare
|
|
267
|
+
* hash to a non-admin.
|
|
268
|
+
*/
|
|
269
|
+
export const create_serve_fact_route_spec = (options) => {
|
|
270
|
+
const { facts_dir, x_accel_redirect_prefix, log } = options;
|
|
271
|
+
const config = { facts_dir, x_accel_redirect_prefix, log };
|
|
272
|
+
return {
|
|
273
|
+
method: 'GET',
|
|
274
|
+
path: '/api/facts/:hash',
|
|
275
|
+
auth: { account: 'required', actor: 'required', roles: [ROLE_ADMIN] },
|
|
276
|
+
description: 'Serve content-addressed fact bytes by bare hash — admin only. Non-admin reads go through GET /api/cells/:cell_id/facts/:hash (per-reference).',
|
|
277
|
+
params: bare_hash_params_schema,
|
|
278
|
+
// `actor: 'required'` (implied by the admin role gate) needs the
|
|
279
|
+
// authorization phase to resolve an acting actor — registry-time
|
|
280
|
+
// invariant 2 requires the `acting?` slot. On a GET it lives on
|
|
281
|
+
// `query` (a multi-actor admin disambiguates via `?acting=<actor>`).
|
|
282
|
+
query: z.strictObject({ acting: ActingActor }),
|
|
283
|
+
input: z.null(),
|
|
284
|
+
// The body is a binary stream; no JSON output schema applies.
|
|
285
|
+
output: z.null(),
|
|
286
|
+
errors: params_400_error,
|
|
287
|
+
handler: async (c, route) => {
|
|
288
|
+
const { hash } = get_route_params(c);
|
|
289
|
+
// Defense-in-depth: the auth phase already gated this on the admin
|
|
290
|
+
// role, but re-check the resolved context so a mounting/auth-shape
|
|
291
|
+
// regression fails closed instead of serving by bare hash.
|
|
292
|
+
if (!has_role(get_request_context(c), ROLE_ADMIN)) {
|
|
293
|
+
return c.body(null, 404);
|
|
184
294
|
}
|
|
185
|
-
|
|
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
|
-
});
|
|
295
|
+
return serve_fact_bytes(c, route, hash, config);
|
|
195
296
|
},
|
|
196
297
|
};
|
|
197
298
|
};
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -574,7 +574,11 @@ auth-related routes (login/logout/verify/sessions/tokens/password/signup)
|
|
|
574
574
|
and asserts `DEFAULT_INTEGRATION_ERROR_COVERAGE` (20%). Bootstrap is
|
|
575
575
|
excluded because no describe block in this suite drives it — its declared
|
|
576
576
|
codes would always be uncovered. Consumer-specific routes aren't exercised
|
|
577
|
-
here either — they don't count against the baseline.
|
|
577
|
+
here either — they don't count against the baseline. 403 authorization
|
|
578
|
+
denials (the credential-channel gate on `/logout` + `/password`, the invite
|
|
579
|
+
gate on `/signup`) are likewise excluded via `ignore_statuses: [403]` — they're
|
|
580
|
+
exercised by the conformance + attack-surface suites, not this lifecycle suite.
|
|
581
|
+
Override with
|
|
578
582
|
`error_coverage_min?: number` (set to `0` to skip the assertion — useful for
|
|
579
583
|
minimal route sets whose declared error codes outpace the suite's
|
|
580
584
|
denial-path drivers).
|
|
@@ -140,6 +140,19 @@ export interface TestFixtureBase {
|
|
|
140
140
|
* for suites that don't declare any.
|
|
141
141
|
*/
|
|
142
142
|
readonly extra_accounts: Readonly<Record<string, ExtraAccountFixture>>;
|
|
143
|
+
/**
|
|
144
|
+
* Additional actors seeded on the **keeper** account (beyond its single
|
|
145
|
+
* bootstrap `actor`), in declaration order. Populated from the
|
|
146
|
+
* `extra_actors` option. Empty unless a suite declares any. Use to drive
|
|
147
|
+
* the multi-actor `acting`-selector branches: with `extra_actors` non-empty
|
|
148
|
+
* the keeper has >1 actor, so a keeper request omitting `acting` resolves
|
|
149
|
+
* to `actor_required` with these ids in its `available[]` list. Each id is
|
|
150
|
+
* a valid `acting` value the keeper can supply explicitly.
|
|
151
|
+
*/
|
|
152
|
+
readonly extra_actors: ReadonlyArray<{
|
|
153
|
+
readonly id: Uuid;
|
|
154
|
+
readonly name: string;
|
|
155
|
+
}>;
|
|
143
156
|
/**
|
|
144
157
|
* Forge an *expired server-side session* for the keeper account and
|
|
145
158
|
* return the ready-to-send `Cookie` header value (`name=value`). The
|
|
@@ -187,6 +200,13 @@ export interface InProcessSetupOptions extends CreateTestAppOptions {
|
|
|
187
200
|
* `describe_standard_admin_integration_tests`) is the primary user.
|
|
188
201
|
*/
|
|
189
202
|
readonly extra_accounts?: ReadonlyArray<ExtraAccountSpec>;
|
|
203
|
+
/**
|
|
204
|
+
* Additional actor names to seed on the bootstrapped keeper — exposed on
|
|
205
|
+
* `fixture.extra_actors`. See `CrossProcessSetupOptions.extra_actors` /
|
|
206
|
+
* `TestFixtureBase.extra_actors`. Seeded directly against the live backend
|
|
207
|
+
* DB (in-process has no wire hop).
|
|
208
|
+
*/
|
|
209
|
+
readonly extra_actors?: ReadonlyArray<string>;
|
|
190
210
|
}
|
|
191
211
|
/**
|
|
192
212
|
* Build a `SetupTest` that creates a fresh `TestApp` per call via
|
|
@@ -321,6 +341,14 @@ export interface CrossProcessSetupOptions {
|
|
|
321
341
|
* transaction as the keeper.
|
|
322
342
|
*/
|
|
323
343
|
readonly extra_accounts?: ReadonlyArray<ExtraAccountSpec>;
|
|
344
|
+
/**
|
|
345
|
+
* Additional actor names to seed on the keeper account, beyond its
|
|
346
|
+
* single bootstrap actor — exposed on `fixture.extra_actors`. Declare
|
|
347
|
+
* to put the keeper into a multi-actor state so the `actor_required` /
|
|
348
|
+
* explicit-`acting` branches are reachable cross-process. See
|
|
349
|
+
* `TestFixtureBase.extra_actors`.
|
|
350
|
+
*/
|
|
351
|
+
readonly extra_actors?: ReadonlyArray<string>;
|
|
324
352
|
}
|
|
325
353
|
/**
|
|
326
354
|
* Capture a backend's schema snapshot over the `_testing_schema_snapshot`
|
|
@@ -399,6 +427,11 @@ export interface DefaultInProcessSuiteOptions {
|
|
|
399
427
|
* transport.
|
|
400
428
|
*/
|
|
401
429
|
extra_accounts?: ReadonlyArray<ExtraAccountSpec>;
|
|
430
|
+
/**
|
|
431
|
+
* Additional actor names to seed on the bootstrapped keeper — exposed on
|
|
432
|
+
* `fixture.extra_actors`. See `TestFixtureBase.extra_actors`.
|
|
433
|
+
*/
|
|
434
|
+
extra_actors?: ReadonlyArray<string>;
|
|
402
435
|
/**
|
|
403
436
|
* Pre-built `AppSurfaceSpec` — overrides the default which calls
|
|
404
437
|
* `create_test_app_surface_spec` against the same factory inputs.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAuB9B,OAAO,EAAC,IAAI,EAAC,MAAM,wBAAwB,CAAC;AAE5C,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,0BAA0B,CAAC;AACxD,OAAO,KAAK,EAAC,gBAAgB,EAAE,sBAAsB,EAAC,MAAM,4BAA4B,CAAC;AACzF,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"setup.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/setup.ts"],"names":[],"mappings":"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"}
|
|
@@ -21,6 +21,7 @@ import '../assert_dev_env.js';
|
|
|
21
21
|
import { z } from 'zod';
|
|
22
22
|
import { Uuid } from '@fuzdev/fuz_util/id.js';
|
|
23
23
|
import { ROLE_KEEPER } from '../../auth/role_schema.js';
|
|
24
|
+
import { query_create_actor } from '../../auth/account_queries.js';
|
|
24
25
|
import { DAEMON_TOKEN_HEADER } from '../../auth/daemon_token.js';
|
|
25
26
|
import { USERNAME_LENGTH_MAX } from '../../primitive_schemas.js';
|
|
26
27
|
import { create_test_app, create_test_account_with_credentials, mint_test_session, DEFAULT_TEST_PASSWORD, } from '../app_server.js';
|
|
@@ -109,6 +110,15 @@ export const default_in_process_setup = (options) => async () => {
|
|
|
109
110
|
});
|
|
110
111
|
extra_accounts[spec.username] = build_extra_account_fixture(seeded, cookie_name);
|
|
111
112
|
}
|
|
113
|
+
// Seed additional keeper actors directly against the same DB. Mirrors
|
|
114
|
+
// the cross-process `_testing_reset` `extra_actors` path; no production
|
|
115
|
+
// wire mints a second actor, so this bootstrap-cradle insert is the
|
|
116
|
+
// only way into a multi-actor keeper state.
|
|
117
|
+
const extra_actors = [];
|
|
118
|
+
for (const name of options.extra_actors ?? []) {
|
|
119
|
+
const seeded_actor = await query_create_actor({ db: test_app.backend.deps.db }, test_app.backend.account.id, name);
|
|
120
|
+
extra_actors.push({ id: seeded_actor.id, name: seeded_actor.name });
|
|
121
|
+
}
|
|
112
122
|
return {
|
|
113
123
|
transport: in_process_fetch_transport(test_app.app),
|
|
114
124
|
// In-process the wrapper is stateless and never auto-adds Origin —
|
|
@@ -122,6 +132,7 @@ export const default_in_process_setup = (options) => async () => {
|
|
|
122
132
|
create_daemon_token_headers: test_app.create_daemon_token_headers,
|
|
123
133
|
create_account: test_app.create_account,
|
|
124
134
|
extra_accounts,
|
|
135
|
+
extra_actors,
|
|
125
136
|
// Forge directly against the live backend's DB + keyring — no wire
|
|
126
137
|
// hop needed in-process.
|
|
127
138
|
mint_expired_session: async () => {
|
|
@@ -270,6 +281,7 @@ const TestingResetResponseShape = z.object({
|
|
|
270
281
|
api_token: z.string(),
|
|
271
282
|
session_cookie: z.string(),
|
|
272
283
|
})),
|
|
284
|
+
extra_actors: z.array(z.object({ id: Uuid, name: z.string() })),
|
|
273
285
|
});
|
|
274
286
|
/**
|
|
275
287
|
* Fire the `_testing_reset` RPC action over the keeper's daemon-token
|
|
@@ -286,6 +298,7 @@ const fire_testing_reset = async (handle, options) => {
|
|
|
286
298
|
...(spec.password_value !== undefined && { password_value: spec.password_value }),
|
|
287
299
|
roles: [...spec.roles],
|
|
288
300
|
})),
|
|
301
|
+
extra_actors: [...(options.extra_actors ?? [])],
|
|
289
302
|
}, handle.config.name, { [DAEMON_TOKEN_HEADER]: handle.daemon_token });
|
|
290
303
|
const parsed = TestingResetResponseShape.safeParse(raw);
|
|
291
304
|
if (!parsed.success) {
|
|
@@ -299,6 +312,7 @@ const fire_testing_reset = async (handle, options) => {
|
|
|
299
312
|
session_cookie: parsed.data.session_cookie,
|
|
300
313
|
},
|
|
301
314
|
extra_accounts: parsed.data.extra_accounts,
|
|
315
|
+
extra_actors: parsed.data.extra_actors,
|
|
302
316
|
};
|
|
303
317
|
};
|
|
304
318
|
/**
|
|
@@ -463,11 +477,13 @@ const create_keeper_transport = (handle, cookie_name, session_cookie) => create_
|
|
|
463
477
|
export const default_cross_process_setup = (handle, options) => {
|
|
464
478
|
const extra_keeper_roles = options?.extra_keeper_roles ?? [];
|
|
465
479
|
const extra_account_specs = options?.extra_accounts ?? [];
|
|
480
|
+
const extra_actor_names = options?.extra_actors ?? [];
|
|
466
481
|
const { cookie_name } = handle.config;
|
|
467
482
|
return async () => {
|
|
468
|
-
const { keeper, extra_accounts: seeded_extras } = await fire_testing_reset(handle, {
|
|
483
|
+
const { keeper, extra_accounts: seeded_extras, extra_actors, } = await fire_testing_reset(handle, {
|
|
469
484
|
extra_keeper_roles,
|
|
470
485
|
extra_accounts: extra_account_specs,
|
|
486
|
+
extra_actors: extra_actor_names,
|
|
471
487
|
});
|
|
472
488
|
// Rebuild the keeper transport with the new session cookie — the
|
|
473
489
|
// reset action wiped the `globalSetup` keeper's auth_session row,
|
|
@@ -550,6 +566,7 @@ export const default_cross_process_setup = (handle, options) => {
|
|
|
550
566
|
create_daemon_token_headers,
|
|
551
567
|
create_account,
|
|
552
568
|
extra_accounts,
|
|
569
|
+
extra_actors,
|
|
553
570
|
// Forge over the wire — the cross-process driver has no keyring,
|
|
554
571
|
// so `_testing_mint_session` mints the backdated row + signs the
|
|
555
572
|
// cookie server-side over the keeper's daemon-token channel.
|
|
@@ -613,6 +630,7 @@ export const default_in_process_suite_options = (options) => ({
|
|
|
613
630
|
app_options: options.app_options,
|
|
614
631
|
roles: [ROLE_KEEPER, ...(options.extra_keeper_roles ?? [])],
|
|
615
632
|
extra_accounts: options.extra_accounts,
|
|
633
|
+
extra_actors: options.extra_actors,
|
|
616
634
|
}),
|
|
617
635
|
surface_source: options.surface_source ??
|
|
618
636
|
create_test_app_surface_spec({
|