@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.
- package/README.md +4 -0
- package/dist/actions/CLAUDE.md +1 -1
- package/dist/auth/CLAUDE.md +1 -1
- package/dist/auth/audit_emitter.d.ts +9 -5
- package/dist/auth/audit_emitter.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.d.ts +7 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +16 -1
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +11 -1
- package/dist/auth/migrations.d.ts +7 -0
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +25 -8
- package/dist/db/CLAUDE.md +9 -5
- package/dist/db/cell_audit_queries.d.ts +4 -2
- package/dist/db/cell_audit_queries.d.ts.map +1 -1
- package/dist/db/cell_audit_queries.js +4 -2
- package/dist/db/pg_error.d.ts +12 -0
- package/dist/db/pg_error.d.ts.map +1 -1
- package/dist/db/pg_error.js +15 -1
- package/dist/http/CLAUDE.md +78 -18
- package/dist/http/db_routes.d.ts +96 -3
- package/dist/http/db_routes.d.ts.map +1 -1
- package/dist/http/db_routes.js +263 -46
- package/dist/http/error_schemas.d.ts +12 -1
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +12 -1
- package/dist/http/pending_effects.d.ts +5 -5
- package/dist/http/pending_effects.d.ts.map +1 -1
- package/dist/http/pending_effects.js +1 -1
- package/dist/testing/CLAUDE.md +21 -0
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +5 -1
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -1
- package/dist/testing/cross_backend/cell_relations.js +53 -1
- package/dist/testing/cross_backend/query_shape.d.ts +10 -0
- package/dist/testing/cross_backend/query_shape.d.ts.map +1 -0
- package/dist/testing/cross_backend/query_shape.js +128 -0
- package/dist/ui/CLAUDE.md +9 -4
- package/dist/ui/table_state.svelte.d.ts +18 -5
- package/dist/ui/table_state.svelte.d.ts.map +1 -1
- package/dist/ui/table_state.svelte.js +22 -7
- package/dist/ui/ui_format.d.ts.map +1 -1
- package/dist/ui/ui_format.js +4 -0
- 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
|
|
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
|
*/
|
package/dist/testing/CLAUDE.md
CHANGED
|
@@ -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,
|
|
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;
|
|
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
|
-
`
|
|
248
|
-
`
|
|
249
|
-
`
|
|
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 (
|
|
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
|
|
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,
|
|
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`
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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`
|
|
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,
|
|
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"}
|
package/dist/ui/ui_format.js
CHANGED
|
@@ -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
|
}
|