@fuzdev/fuz_app 0.108.0 → 0.109.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 (45) hide show
  1. package/README.md +4 -0
  2. package/dist/actions/CLAUDE.md +1 -1
  3. package/dist/auth/CLAUDE.md +1 -1
  4. package/dist/auth/audit_emitter.d.ts +9 -5
  5. package/dist/auth/audit_emitter.d.ts.map +1 -1
  6. package/dist/auth/audit_log_schema.d.ts +7 -1
  7. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  8. package/dist/auth/audit_log_schema.js +16 -1
  9. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  10. package/dist/auth/bootstrap_routes.js +11 -1
  11. package/dist/auth/migrations.d.ts +7 -0
  12. package/dist/auth/migrations.d.ts.map +1 -1
  13. package/dist/auth/migrations.js +25 -8
  14. package/dist/db/CLAUDE.md +9 -5
  15. package/dist/db/cell_audit_queries.d.ts +4 -2
  16. package/dist/db/cell_audit_queries.d.ts.map +1 -1
  17. package/dist/db/cell_audit_queries.js +4 -2
  18. package/dist/db/pg_error.d.ts +12 -0
  19. package/dist/db/pg_error.d.ts.map +1 -1
  20. package/dist/db/pg_error.js +15 -1
  21. package/dist/http/CLAUDE.md +78 -18
  22. package/dist/http/db_routes.d.ts +96 -3
  23. package/dist/http/db_routes.d.ts.map +1 -1
  24. package/dist/http/db_routes.js +263 -46
  25. package/dist/http/error_schemas.d.ts +12 -1
  26. package/dist/http/error_schemas.d.ts.map +1 -1
  27. package/dist/http/error_schemas.js +12 -1
  28. package/dist/http/pending_effects.d.ts +5 -5
  29. package/dist/http/pending_effects.d.ts.map +1 -1
  30. package/dist/http/pending_effects.js +1 -1
  31. package/dist/testing/CLAUDE.md +21 -0
  32. package/dist/testing/audit_completeness.d.ts.map +1 -1
  33. package/dist/testing/audit_completeness.js +5 -1
  34. package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
  35. package/dist/testing/cross_backend/cell_relations.js +53 -1
  36. package/dist/testing/cross_backend/query_shape.d.ts +10 -0
  37. package/dist/testing/cross_backend/query_shape.d.ts.map +1 -0
  38. package/dist/testing/cross_backend/query_shape.js +128 -0
  39. package/dist/ui/CLAUDE.md +9 -4
  40. package/dist/ui/table_state.svelte.d.ts +18 -5
  41. package/dist/ui/table_state.svelte.d.ts.map +1 -1
  42. package/dist/ui/table_state.svelte.js +22 -7
  43. package/dist/ui/ui_format.d.ts.map +1 -1
  44. package/dist/ui/ui_format.js +4 -0
  45. package/package.json +1 -1
@@ -61,7 +61,7 @@
61
61
  * land even when the handler fails) belong on the eager `pending_effects` queue
62
62
  * instead.
63
63
  *
64
- * @param ctx - context carrying `log` and the `post_commit_effects` queue
64
+ * @param ctx - context carrying the `post_commit_effects` queue
65
65
  * @param fn - side effect to run after commit; may return `void` or `Promise<void>`
66
66
  * @mutates `ctx.post_commit_effects` - appends `fn` verbatim
67
67
  */
@@ -1397,6 +1397,27 @@ in-process `auth/origin_parity.db.test.ts` + the cross-process
1397
1397
  Rust spine returned a plain-text body — now converged to the canonical TS
1398
1398
  `{error: "forbidden_origin"}` via `fuz_http::forbidden_origin_response()`.
1399
1399
 
1400
+ ### Query-string shape parity — `cross_backend/query_shape.ts`
1401
+
1402
+ `describe_query_shape_cross_tests({setup_test, capabilities, rpc_path?})` —
1403
+ the imperative duplicate-key / unknown-key / phase-order query-semantics
1404
+ suite over the three spine surfaces that read a query:
1405
+ `GET /api/account/status` (no query schema — duplicated `acting` first-wins,
1406
+ unknown keys ignored, anonymous junk query → 401 never a query 400), the
1407
+ JSON-RPC GET endpoint (`method` / `id` / `params` first-wins — a last-wins
1408
+ reader dispatches a different method per backend on the same request), and
1409
+ `GET /api/facts/:hash` (gated on `capabilities.fact_serving`; strict schema —
1410
+ unknown key / malformed `acting` → 400 `invalid_query_params` **before** the
1411
+ 401 guard, duplicated `acting` first-wins). Imperative because duplicated
1412
+ query keys can't be expressed through the structured RPC helpers. Pinned
1413
+ after the Rust spine's derived-serde `Query` extractors were converged onto
1414
+ `fuz_http`'s `query_first` / `assert_known_query_keys` (a pre-handler
1415
+ "duplicate field" 400 is an anonymously observable backend fingerprint).
1416
+ Cross-process only — the per-surface in-process TS pins live beside each
1417
+ module's own tests (`account_status.test.ts`, `action_rpc.test.ts`,
1418
+ `serve_fact_route.db.test.ts`); fuz_app's own wiring is
1419
+ `cross_backend/query_shape.cross.test.ts`.
1420
+
1400
1421
  ### Readiness probe parity — `cross_backend/ready.ts`
1401
1422
 
1402
1423
  `describe_ready_cross_tests({setup_test, capabilities, ready_path?})` — the
@@ -1 +1 @@
1
- {"version":3,"file":"audit_completeness.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_completeness.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AA4B7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAUhE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AA2B1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC5C;;;;;OAKG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;OAMG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,uCAAuC;IACvC,YAAY,EAAE,mBAAmB,CAAC;IAClC,yEAAyE;IACzE,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;CACvC;AAgGD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAAI,SAAS,4BAA4B,KAAG,IAymBzF,CAAC"}
1
+ {"version":3,"file":"audit_completeness.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/audit_completeness.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,CAAC;AA4B7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAUhE,OAAO,EAKN,KAAK,uBAAuB,EAC5B,MAAM,kBAAkB,CAAC;AA2B1B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,iCAAiC,CAAC;AAC3E,OAAO,KAAK,EAAE,SAAS,EAAe,MAAM,0BAA0B,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,4BAA4B;IAC5C;;;;;OAKG;IACH,UAAU,EAAE,SAAS,CAAC;IACtB;;;;;;OAMG;IACH,cAAc,EAAE,cAAc,CAAC;IAC/B,uCAAuC;IACvC,YAAY,EAAE,mBAAmB,CAAC;IAClC,yEAAyE;IACzE,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,aAAa,EAAE,uBAAuB,CAAC;CACvC;AAgGD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,iCAAiC,GAAI,SAAS,4BAA4B,KAAG,IA6mBzF,CAAC"}
@@ -561,7 +561,11 @@ export const describe_audit_completeness_tests = (options) => {
561
561
  'role_grant_offer_decline',
562
562
  'role_grant_offer_retract',
563
563
  'role_grant_offer_expire',
564
- 'role_grant_offer_supersede'
564
+ 'role_grant_offer_supersede',
565
+ // `db_admin_row_delete` fires from the generic db-admin browser
566
+ // (`http/db_routes.ts`), which the standard test app doesn't mount —
567
+ // covered in `db_routes.db.test.ts`.
568
+ 'db_admin_row_delete'
565
569
  ]);
566
570
  test('all audit event types are covered or explicitly excluded', () => {
567
571
  const all_covered = new Set([...COVERED_EVENT_TYPES, ...EXCLUDED_EVENT_TYPES]);
@@ -1 +1 @@
1
- {"version":3,"file":"cell_relations.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_relations.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AAqF9B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAsF3D,eAAO,MAAM,mCAAmC,GAAI,SAAS,wBAAwB,KAAG,IA2wBvF,CAAC"}
1
+ {"version":3,"file":"cell_relations.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/cell_relations.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA4F9B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAsF3D,eAAO,MAAM,mCAAmC,GAAI,SAAS,wBAAwB,KAAG,IAi2BvF,CAAC"}
@@ -27,6 +27,12 @@ import '../assert_dev_env.js';
27
27
  * - **clone** — shallow copies item / field *edges* (shared `child_id` /
28
28
  * `target_id`); deep clones each viewable child into a fresh cell at the
29
29
  * same position. Both null `path` and stamp the caller as owner.
30
+ * - **`shared_with: 'me'`** — the grant-driven `cell_list` branch: a grantee
31
+ * lists exactly the cells that admit them via `cell_grant`, their own cells
32
+ * excluded, with the `cell_grants` enrichment keyed by cell id. Plus the two
33
+ * guards — null-auth is `cell_list_shared_with_requires_auth`, and any value
34
+ * other than `'me'` is `invalid_params` on both spines. The cross twin of
35
+ * the in-process `auth/cell_actions.list.db.test.ts` §"shared_with: me".
30
36
  * - **audit** — `cell_audit_list` is manage-tier: the owner reads the cell's
31
37
  * timeline; a viewer-grant holder who can `cell_get` the cell still gets the
32
38
  * IDOR 404 on the timeline (D14).
@@ -53,7 +59,7 @@ import '../assert_dev_env.js';
53
59
  */
54
60
  import { describe, assert } from 'vitest';
55
61
  import { fractional_index_between } from '@fuzdev/fuz_util/fractional_index.ts';
56
- import { CellCreateOutput, CellUpdateOutput, CellCloneOutput, CellGetOutput } from '../../auth/cell_action_specs.js';
62
+ import { CellCreateOutput, CellUpdateOutput, CellCloneOutput, CellGetOutput, CellListOutput } from '../../auth/cell_action_specs.js';
57
63
  import { AuditLogListOutput } from '../../auth/admin_action_specs.js';
58
64
  import { CellGrantCreateOutput, CellGrantListOutput, CellGrantRevokeOutput } from '../../auth/cell_grant_action_specs.js';
59
65
  import { CellFieldDeleteOutput, CellFieldListOutput, CellFieldSetOutput } from '../../auth/cell_field_action_specs.js';
@@ -131,6 +137,52 @@ export const describe_cell_relations_cross_tests = (options) => {
131
137
  assert.ok(!post.ok, 'revoked editor still edited');
132
138
  assert.strictEqual(error_reason(post), 'cell_not_found');
133
139
  });
140
+ test_if(capabilities.cell_relations, "cell_list shared_with: 'me' lists grant-admitted cells, excludes owned, enriches cell_grants", async () => {
141
+ const fixture = await setup_test();
142
+ const owner = await fixture.create_account({ username: 'cell_shared_owner' });
143
+ const viewer = await fixture.create_account({ username: 'cell_shared_viewer' });
144
+ const t = fixture.fresh_transport();
145
+ const owner_h = owner.create_session_headers();
146
+ const viewer_h = viewer.create_session_headers();
147
+ const shared = await create_cell(t, rpc_path, owner_h, { kind: 'note', data: {} });
148
+ // A cell the viewer owns — owner ≠ sharee, so it must NOT appear.
149
+ await create_cell(t, rpc_path, viewer_h, { kind: 'note', data: {} });
150
+ // The grant-admit control: owner-owned but NOT granted to the viewer.
151
+ // Without it the semi-join is untested — the shared-with SQL's two
152
+ // conjuncts (created_by IS DISTINCT FROM caller AND id IN grant-set)
153
+ // would select the same set, so dropping the grant clause changes
154
+ // nothing. This ungranted cell is exactly what dropping it would leak.
155
+ await create_cell(t, rpc_path, owner_h, { kind: 'note', data: {} });
156
+ const grant = expect_output(await cross_rpc_call(t, rpc_path, 'cell_grant_create', {
157
+ cell_id: shared,
158
+ level: 'viewer',
159
+ principal: { kind: 'actor', actor_id: viewer.actor.id }
160
+ }, owner_h), CellGrantCreateOutput).grant;
161
+ const listed = expect_output(await cross_rpc_call(t, rpc_path, 'cell_list', { shared_with: 'me' }, viewer_h), CellListOutput);
162
+ assert.deepStrictEqual(listed.cells.map((c) => c.id), [shared], 'shared_with list is not exactly the grant-admitted cell');
163
+ // The enrichment: the caller's own admit grants, keyed by cell id.
164
+ assert.ok(listed.cell_grants, 'shared_with list omitted the cell_grants enrichment');
165
+ const grants = listed.cell_grants[shared];
166
+ assert.ok(grants, 'cell_grants missing the shared cell key');
167
+ assert.strictEqual(grants.length, 1);
168
+ assert.strictEqual(grants[0].id, grant.id);
169
+ });
170
+ test_if(capabilities.cell_relations, 'null-auth cell_list shared_with → cell_list_shared_with_requires_auth', async () => {
171
+ const fixture = await setup_test();
172
+ const anon = fixture.fresh_transport({ origin: null });
173
+ const bad = await cross_rpc_call(anon, rpc_path, 'cell_list', { shared_with: 'me' }, {});
174
+ assert.ok(!bad.ok, 'anon shared_with filter accepted');
175
+ assert.strictEqual(error_reason(bad), 'cell_list_shared_with_requires_auth');
176
+ });
177
+ test_if(capabilities.cell_relations, "cell_list shared_with other than 'me' → invalid_params", async () => {
178
+ const fixture = await setup_test();
179
+ // TS rejects at the schema (`z.literal('me')`), Rust in the handler
180
+ // guard — both must land on -32602, so a caller can't widen the
181
+ // grant-driven branch to another actor on either spine.
182
+ const bad = await cross_rpc_call(fixture.fresh_transport(), rpc_path, 'cell_list', { shared_with: 'not-me' }, fixture.create_session_headers());
183
+ assert.ok(!bad.ok, "cell_list accepted a shared_with other than 'me'");
184
+ assert.strictEqual(bad.error?.code, -32602);
185
+ });
134
186
  test_if(capabilities.cell_relations, 'editor-grant holder cannot flip visibility → cell_visibility_manage_only', async () => {
135
187
  const fixture = await setup_test();
136
188
  const owner = await fixture.create_account({ username: 'cell_vis_owner' });
@@ -0,0 +1,10 @@
1
+ import '../assert_dev_env.js';
2
+ import type { RpcPathCrossSuiteOptions } from './setup.js';
3
+ /**
4
+ * Options for the query-shape parity suite. The standard RPC-dispatched
5
+ * cross-suite shape (`setup_test` / `capabilities` / `rpc_path`); aliases the
6
+ * shared `RpcPathCrossSuiteOptions` rather than minting a duplicate.
7
+ */
8
+ export type QueryShapeCrossTestOptions = RpcPathCrossSuiteOptions;
9
+ export declare const describe_query_shape_cross_tests: (options: QueryShapeCrossTestOptions) => void;
10
+ //# sourceMappingURL=query_shape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query_shape.d.ts","sourceRoot":"../src/lib/","sources":["../../../src/lib/testing/cross_backend/query_shape.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,CAAC;AA0C9B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAG3D;;;;GAIG;AACH,MAAM,MAAM,0BAA0B,GAAG,wBAAwB,CAAC;AAQlE,eAAO,MAAM,gCAAgC,GAAI,SAAS,0BAA0B,KAAG,IAwHtF,CAAC"}
@@ -0,0 +1,128 @@
1
+ import '../assert_dev_env.js';
2
+ /**
3
+ * Cross-backend query-string shape parity suite.
4
+ *
5
+ * The TS pipeline reads queries through Hono's `c.req.query()` — a duplicated
6
+ * key resolves to its **first** occurrence — and only routes that declare a
7
+ * `z.strictObject` query schema 400 unknown keys, in the pipeline's
8
+ * params → query → 401 → authz → 403 phase order. A derived-serde axum
9
+ * extractor diverges on every axis (a plain-text pre-handler "duplicate
10
+ * field" 400 observable anonymously, silently ignored unknown keys, query
11
+ * rejection ahead of the 401), so each spine surface that reads a query is
12
+ * pinned here on both backends:
13
+ *
14
+ * - **`GET /api/account/status`** — NO query schema: a duplicated `acting`
15
+ * reads first-wins (the persona resolves), unknown keys are ignored, and an
16
+ * anonymous caller with junk query hears 401, never a query 400.
17
+ * - **the JSON-RPC GET endpoint** — `method` / `id` / `params` read
18
+ * first-wins; a duplicated `method` must dispatch the first one on every
19
+ * backend (a last-wins reader dispatches a different method per backend on
20
+ * the same request).
21
+ * - **`GET /api/facts/:hash`** (gated on `capabilities.fact_serving`) —
22
+ * strict query schema: unknown keys and a malformed `acting` are 400
23
+ * `invalid_query_params` even for an anonymous caller (query precedes the
24
+ * 401 guard), while a duplicated `acting` takes the first occurrence.
25
+ *
26
+ * The per-surface in-process TS pins live beside each module's own tests
27
+ * (`account_status.test.ts`, `action_rpc.test.ts`,
28
+ * `serve_fact_route.db.test.ts`); this suite is the cross-impl gate, so it is
29
+ * cross-process only — like `login_security.ts` and `testing_backdoor.ts`.
30
+ *
31
+ * `$lib`-free by contract (relative specifiers only), like the sibling
32
+ * cross-backend suites.
33
+ *
34
+ * @module
35
+ */
36
+ import { describe, test, assert } from 'vitest';
37
+ import { account_verify_action_spec } from '../../auth/account_action_specs.js';
38
+ import { ERROR_INVALID_QUERY_PARAMS } from '../../http/error_schemas.js';
39
+ import { test_if } from './capabilities.js';
40
+ import { SPINE_RPC_PATH } from './spine_surface_constants.js';
41
+ /** The status route every spine serves (bundled into the account family). */
42
+ const STATUS_PATH = '/api/account/status';
43
+ /** A well-formed blake3 hash that references no stored fact. */
44
+ const MISSING_HASH = `blake3:${'a'.repeat(64)}`;
45
+ export const describe_query_shape_cross_tests = (options) => {
46
+ const { setup_test, capabilities } = options;
47
+ const rpc_path = options.rpc_path ?? SPINE_RPC_PATH;
48
+ describe('query-shape parity (duplicate keys, unknown keys, phase order)', () => {
49
+ test_if(capabilities.account_status, 'status: duplicated acting takes the first occurrence (persona resolves)', async () => {
50
+ const fixture = await setup_test();
51
+ // First occurrence is the keeper's real actor; the second is a
52
+ // mismatching-but-well-formed uuid. First-wins resolves the real
53
+ // persona; last-wins would fall through to `actor: null`, and a
54
+ // derived-serde extractor would 400 before the handler ran.
55
+ const nil = '00000000-0000-0000-0000-000000000000';
56
+ const res = await fixture.transport(`${STATUS_PATH}?acting=${fixture.actor.id}&acting=${nil}`, { method: 'GET', headers: fixture.create_session_headers() });
57
+ assert.strictEqual(res.status, 200);
58
+ const body = (await res.json());
59
+ assert.strictEqual(body.actor?.id, fixture.actor.id, 'first acting occurrence must win');
60
+ });
61
+ test_if(capabilities.account_status, 'status: anonymous with duplicated/unknown query keys → 401, never a query 400', async () => {
62
+ const fixture = await setup_test();
63
+ const anon = fixture.fresh_transport();
64
+ // The status route has no query schema — an anonymous prober with a
65
+ // junk query must hear the same 401 as a clean one (a pre-handler
66
+ // query reject here is a backend fingerprint).
67
+ const dup = await anon(`${STATUS_PATH}?acting=a&acting=b`, { method: 'GET' });
68
+ assert.strictEqual(dup.status, 401);
69
+ const unknown = await anon(`${STATUS_PATH}?foo=1&bar=2`, { method: 'GET' });
70
+ assert.strictEqual(unknown.status, 401);
71
+ });
72
+ test_if(capabilities.account_status, 'status: unknown query keys are ignored for an authed caller (no strict schema)', async () => {
73
+ const fixture = await setup_test();
74
+ const res = await fixture.transport(`${STATUS_PATH}?foo=1`, {
75
+ method: 'GET',
76
+ headers: fixture.create_session_headers()
77
+ });
78
+ assert.strictEqual(res.status, 200);
79
+ });
80
+ test('rpc GET: duplicated method and id keys take the first occurrence', async () => {
81
+ const fixture = await setup_test();
82
+ const method = account_verify_action_spec.method;
83
+ // First-wins dispatches `account_verify` with id 7; a last-wins
84
+ // reader dispatches the missing method (method_not_found) with id 8.
85
+ const res = await fixture.transport(`${rpc_path}?method=${method}&method=definitely_missing_method&id=7&id=8`, { method: 'GET', headers: fixture.create_session_headers() });
86
+ assert.strictEqual(res.status, 200);
87
+ const body = (await res.json());
88
+ assert.strictEqual(body.id, 7, 'first id occurrence must win');
89
+ assert.ok(body.result !== undefined, 'first method occurrence must dispatch');
90
+ });
91
+ test_if(capabilities.fact_serving, 'bare-hash facts: unknown query key → 400 invalid_query_params, even anonymous', async () => {
92
+ const fixture = await setup_test();
93
+ // Authed admin (the keeper): the strict query schema refuses the
94
+ // unknown key before the handler can 404 the missing fact.
95
+ const as_admin = await fixture.transport(`/api/facts/${MISSING_HASH}?foo=1`, {
96
+ method: 'GET',
97
+ headers: fixture.create_session_headers()
98
+ });
99
+ assert.strictEqual(as_admin.status, 400);
100
+ const body = (await as_admin.json());
101
+ assert.strictEqual(body.error, ERROR_INVALID_QUERY_PARAMS);
102
+ // Anonymous: query validation precedes the 401 guard (phase
103
+ // order), with the clean anonymous request as the 401 control.
104
+ const anon = fixture.fresh_transport();
105
+ const anon_bad = await anon(`/api/facts/${MISSING_HASH}?foo=1`, { method: 'GET' });
106
+ assert.strictEqual(anon_bad.status, 400);
107
+ const anon_clean = await anon(`/api/facts/${MISSING_HASH}`, { method: 'GET' });
108
+ assert.strictEqual(anon_clean.status, 401);
109
+ });
110
+ test_if(capabilities.fact_serving, 'bare-hash facts: duplicated acting takes the first occurrence; malformed acting → 400', async () => {
111
+ const fixture = await setup_test();
112
+ // First occurrence is the keeper's real actor; the second is
113
+ // garbage. First-wins passes the query phase and reaches the
114
+ // handler's masked 404 (the hash references nothing) — a
115
+ // derived-serde extractor would 400 the duplication instead.
116
+ const dup = await fixture.transport(`/api/facts/${MISSING_HASH}?acting=${fixture.actor.id}&acting=not-a-uuid`, { method: 'GET', headers: fixture.create_session_headers() });
117
+ assert.strictEqual(dup.status, 404, 'duplicated acting must resolve first-wins');
118
+ // A malformed single acting is refused at the query phase.
119
+ const bad = await fixture.transport(`/api/facts/${MISSING_HASH}?acting=not-a-uuid`, {
120
+ method: 'GET',
121
+ headers: fixture.create_session_headers()
122
+ });
123
+ assert.strictEqual(bad.status, 400);
124
+ const body = (await bad.json());
125
+ assert.strictEqual(body.error, ERROR_INVALID_QUERY_PARAMS);
126
+ });
127
+ });
128
+ };
package/dist/ui/CLAUDE.md CHANGED
@@ -244,9 +244,13 @@ destructive actions.
244
244
  - `table_state.svelte.ts` — `TableState`. Paginated DB browser state.
245
245
  Holds one `AsyncSlot` (`list`) + payload fields (`table_name`,
246
246
  `columns`, `rows`, `total`, `offset`, `limit` capped by
247
- `TABLE_LIMIT_MAX = 1000`, `primary_key`). Derived
248
- `showing_start`/`showing_end`/`has_prev`/`has_next`. Methods:
249
- `fetch`, `go_prev`/`go_next`, `delete_row`. `delete_row` uses
247
+ `http/db_routes.ts`'s `DB_TABLE_ROWS_LIMIT_MAX`, `primary_key`,
248
+ `deletable`). Derived
249
+ `showing_start`/`showing_end`/`has_prev`/`has_next`, plus
250
+ `can_delete` (`deletable && primary_key !== null`) — gate the
251
+ delete affordance on that, not on `primary_key`, or a
252
+ policy-excluded table shows a button that only ever refuses.
253
+ Methods: `fetch`, `go_prev`/`go_next`, `delete_row`. `delete_row` uses
250
254
  plain try/catch + scalar `deleting` / `delete_error` fields (no
251
255
  slot — error must survive past `list.run()` retries).
252
256
  - `form_state.svelte.ts` — `FormState`. Enter-advance between
@@ -421,4 +425,5 @@ format_scope, global_label)` helper — `global_label = null` renders no
421
425
  - `format_value(value)` — table-cell stringifier (NULL / undefined /
422
426
  JSON / primitive).
423
427
  - `format_audit_metadata(event_type, metadata)` — event-type-
424
- specific metadata summary (switch across every `AuditEventType`).
428
+ specific metadata summary (bespoke cases for the common event types,
429
+ JSON-stringified fallback for the rest).
@@ -40,9 +40,7 @@
40
40
  * @module
41
41
  */
42
42
  import { AsyncSlot } from './async_slot.svelte.js';
43
- import type { ColumnInfo } from '../http/db_routes.js';
44
- /** Maximum number of rows that can be fetched in a single page. */
45
- export declare const TABLE_LIMIT_MAX = 1000;
43
+ import { type ColumnInfo } from '../http/db_routes.js';
46
44
  export declare class TableState {
47
45
  readonly list: AsyncSlot<void, string>;
48
46
  table_name: string;
@@ -52,15 +50,30 @@ export declare class TableState {
52
50
  offset: number;
53
51
  limit: number;
54
52
  primary_key: string | null;
53
+ /**
54
+ * Whether the server will accept a row `DELETE` on this table. False for a
55
+ * table with no single-column primary key *and* for one the server excludes
56
+ * by policy (the audit trail, singleton bookkeeping rows), which `primary_key`
57
+ * alone can't distinguish — it reports the key shape, not the policy.
58
+ */
59
+ deletable: boolean;
55
60
  deleting: string | null;
56
61
  delete_error: string | null;
62
+ /**
63
+ * Whether to offer a delete affordance at all. Gate rendering on this rather
64
+ * than on `primary_key`, or a policy-excluded table shows a button whose only
65
+ * outcome is a `table_not_deletable` refusal.
66
+ */
67
+ readonly can_delete: boolean;
57
68
  readonly showing_start: number;
58
69
  readonly showing_end: number;
59
70
  readonly has_prev: boolean;
60
71
  readonly has_next: boolean;
61
72
  /**
62
73
  * Fetch a page of rows for `table_name` from `GET /api/db/tables/{table_name}`.
63
- * `limit` is clamped to `[1, TABLE_LIMIT_MAX]`.
74
+ * `limit` is clamped to `[1, DB_TABLE_ROWS_LIMIT_MAX]` — the same bounds
75
+ * the route 400s on, single-sourced so the client guard can't drift into
76
+ * sending a request the server refuses.
64
77
  *
65
78
  * @mutates `this`
66
79
  */
@@ -72,7 +85,7 @@ export declare class TableState {
72
85
  * Optimistically drops it from `rows` and decrements `total` on success;
73
86
  * surfaces server errors on `delete_error`.
74
87
  *
75
- * @returns `true` when the row was removed; `false` on missing primary key or server error
88
+ * @returns `true` when the row was removed; `false` when the table isn't deletable or the server refused
76
89
  * @mutates `this`
77
90
  */
78
91
  delete_row(row: Record<string, unknown>): Promise<boolean>;
@@ -1 +1 @@
1
- {"version":3,"file":"table_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/table_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,mEAAmE;AACnE,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,qBAAa,UAAU;IACtB,QAAQ,CAAC,IAAI,0BAAyB;IAEtC,UAAU,EAAE,MAAM,CAAkB;IACpC,OAAO,EAAE,KAAK,CAAC,UAAU,CAAC,CAAkB;IAC5C,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAkB;IACtD,KAAK,SAAiB;IACtB,MAAM,SAAiB;IACvB,KAAK,SAAmB;IACxB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC9C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC3C,YAAY,EAAE,MAAM,GAAG,IAAI,CAAoB;IAG/C,QAAQ,CAAC,aAAa,SAAoD;IAC1E,QAAQ,CAAC,WAAW,SAAkE;IACtF,QAAQ,CAAC,QAAQ,UAA6B;IAC9C,QAAQ,CAAC,QAAQ,UAAmD;IAEpE;;;;;OAKG;IACG,KAAK,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBvE,OAAO,IAAI,IAAI;IAIf,OAAO,IAAI,IAAI;IAIf;;;;;;;OAOG;IACG,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;CAoChE"}
1
+ {"version":3,"file":"table_state.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/table_state.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAGnD,OAAO,EAGN,KAAK,UAAU,EACf,MAAM,sBAAsB,CAAC;AAE9B,qBAAa,UAAU;IACtB,QAAQ,CAAC,IAAI,0BAAyB;IAEtC,UAAU,EAAE,MAAM,CAAkB;IACpC,OAAO,EAAE,KAAK,CAAC,UAAU,CAAC,CAAkB;IAC5C,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAkB;IACtD,KAAK,SAAiB;IACtB,MAAM,SAAiB;IACvB,KAAK,SAA2C;IAChD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC9C;;;;;OAKG;IACH,SAAS,UAAqB;IAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAoB;IAC3C,YAAY,EAAE,MAAM,GAAG,IAAI,CAAoB;IAE/C;;;;OAIG;IACH,QAAQ,CAAC,UAAU,UAAyD;IAG5E,QAAQ,CAAC,aAAa,SAAoD;IAC1E,QAAQ,CAAC,WAAW,SAAkE;IACtF,QAAQ,CAAC,QAAQ,UAA6B;IAC9C,QAAQ,CAAC,QAAQ,UAAmD;IAEpE;;;;;;;OAOG;IACG,KAAK,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoBvE,OAAO,IAAI,IAAI;IAIf,OAAO,IAAI,IAAI;IAIf;;;;;;;OAOG;IACG,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;CAoChE"}
@@ -43,8 +43,7 @@ import { to_error_message } from '@fuzdev/fuz_util/error.ts';
43
43
  import { AsyncSlot } from './async_slot.svelte.js';
44
44
  import { parse_response_error, ui_fetch } from './ui_fetch.js';
45
45
  import { format_value } from './ui_format.js';
46
- /** Maximum number of rows that can be fetched in a single page. */
47
- export const TABLE_LIMIT_MAX = 1000;
46
+ import { DB_TABLE_ROWS_DEFAULT_LIMIT, DB_TABLE_ROWS_LIMIT_MAX } from '../http/db_routes.js';
48
47
  export class TableState {
49
48
  list = new AsyncSlot();
50
49
  table_name = $state.raw('');
@@ -52,10 +51,23 @@ export class TableState {
52
51
  rows = $state.raw([]);
53
52
  total = $state.raw(0);
54
53
  offset = $state.raw(0);
55
- limit = $state.raw(100);
54
+ limit = $state.raw(DB_TABLE_ROWS_DEFAULT_LIMIT);
56
55
  primary_key = $state.raw(null);
56
+ /**
57
+ * Whether the server will accept a row `DELETE` on this table. False for a
58
+ * table with no single-column primary key *and* for one the server excludes
59
+ * by policy (the audit trail, singleton bookkeeping rows), which `primary_key`
60
+ * alone can't distinguish — it reports the key shape, not the policy.
61
+ */
62
+ deletable = $state.raw(false);
57
63
  deleting = $state.raw(null);
58
64
  delete_error = $state.raw(null);
65
+ /**
66
+ * Whether to offer a delete affordance at all. Gate rendering on this rather
67
+ * than on `primary_key`, or a policy-excluded table shows a button whose only
68
+ * outcome is a `table_not_deletable` refusal.
69
+ */
70
+ can_delete = $derived(this.deletable && this.primary_key !== null);
59
71
  // Pagination computed values
60
72
  showing_start = $derived(this.total === 0 ? 0 : this.offset + 1);
61
73
  showing_end = $derived(Math.min(this.offset + this.rows.length, this.total));
@@ -63,14 +75,16 @@ export class TableState {
63
75
  has_next = $derived(this.offset + this.limit < this.total);
64
76
  /**
65
77
  * Fetch a page of rows for `table_name` from `GET /api/db/tables/{table_name}`.
66
- * `limit` is clamped to `[1, TABLE_LIMIT_MAX]`.
78
+ * `limit` is clamped to `[1, DB_TABLE_ROWS_LIMIT_MAX]` — the same bounds
79
+ * the route 400s on, single-sourced so the client guard can't drift into
80
+ * sending a request the server refuses.
67
81
  *
68
82
  * @mutates `this`
69
83
  */
70
84
  async fetch(table_name, offset = 0, limit = 100) {
71
85
  this.table_name = table_name;
72
86
  this.offset = offset;
73
- this.limit = Math.max(1, Math.min(TABLE_LIMIT_MAX, limit));
87
+ this.limit = Math.max(1, Math.min(DB_TABLE_ROWS_LIMIT_MAX, limit));
74
88
  await this.list.run(async () => {
75
89
  const response = await ui_fetch(`/api/db/tables/${table_name}?offset=${this.offset}&limit=${this.limit}`);
76
90
  if (!response.ok) {
@@ -81,6 +95,7 @@ export class TableState {
81
95
  this.rows = data.rows ?? [];
82
96
  this.total = data.total ?? 0;
83
97
  this.primary_key = data.primary_key ?? null;
98
+ this.deletable = data.deletable ?? false;
84
99
  });
85
100
  }
86
101
  go_prev() {
@@ -94,11 +109,11 @@ export class TableState {
94
109
  * Optimistically drops it from `rows` and decrements `total` on success;
95
110
  * surfaces server errors on `delete_error`.
96
111
  *
97
- * @returns `true` when the row was removed; `false` on missing primary key or server error
112
+ * @returns `true` when the row was removed; `false` when the table isn't deletable or the server refused
98
113
  * @mutates `this`
99
114
  */
100
115
  async delete_row(row) {
101
- if (!this.primary_key)
116
+ if (!this.can_delete || !this.primary_key)
102
117
  return false;
103
118
  const pk_value = row[this.primary_key];
104
119
  if (pk_value === null || pk_value === undefined)
@@ -1 +1 @@
1
- {"version":3,"file":"ui_format.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/ui_format.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAChC,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,EACjC,MAAK,MAAmB,KACtB,MA2BF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,KAAG,MAY1C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,EAAE,YAAY,MAAM,EAAE,kBAAe,KAAG,MAOlF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,MAAM,KAAG,MAAmC,CAAC;AAEjF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAS7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,KAAG,MAOzE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GACjC,YAAY,MAAM,EAClB,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KACtC,MAyDF,CAAC"}
1
+ {"version":3,"file":"ui_format.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/ui/ui_format.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAChC,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,EACjC,MAAK,MAAmB,KACtB,MA2BF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,IAAI,MAAM,KAAG,MAY1C,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,EAAE,YAAY,MAAM,EAAE,kBAAe,KAAG,MAOlF,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,MAAM,KAAG,MAAmC,CAAC;AAEjF;;GAEG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,MAS7C,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GAAI,WAAW,MAAM,GAAG,MAAM,GAAG,IAAI,KAAG,MAOzE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,GACjC,YAAY,MAAM,EAClB,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,KACtC,MAgEF,CAAC"}
@@ -178,6 +178,10 @@ export const format_audit_metadata = (event_type, metadata) => {
178
178
  return metadata.setting
179
179
  ? `${metadata.setting}: ${JSON.stringify(metadata.old_value)} → ${JSON.stringify(metadata.new_value)}`
180
180
  : '';
181
+ case 'db_admin_row_delete':
182
+ return metadata.table
183
+ ? `${metadata.table}.${metadata.pk_column}: ${truncate_middle(metadata.id, 12)}`
184
+ : '';
181
185
  default:
182
186
  return JSON.stringify(metadata);
183
187
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.108.0",
3
+ "version": "0.109.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",