@fuzdev/fuz_app 0.67.1 → 0.69.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/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +10 -3
- package/dist/auth/CLAUDE.md +99 -5
- package/dist/auth/account_queries.d.ts +87 -4
- package/dist/auth/account_queries.d.ts.map +1 -1
- package/dist/auth/account_queries.js +107 -17
- package/dist/auth/account_schema.d.ts +19 -0
- package/dist/auth/account_schema.d.ts.map +1 -1
- package/dist/auth/account_schema.js +8 -0
- package/dist/auth/admin_action_specs.d.ts +170 -3
- package/dist/auth/admin_action_specs.d.ts.map +1 -1
- package/dist/auth/admin_action_specs.js +148 -4
- package/dist/auth/admin_actions.d.ts +4 -14
- package/dist/auth/admin_actions.d.ts.map +1 -1
- package/dist/auth/admin_actions.js +246 -40
- package/dist/auth/audit_log_ddl.d.ts +10 -1
- package/dist/auth/audit_log_ddl.d.ts.map +1 -1
- package/dist/auth/audit_log_ddl.js +13 -4
- package/dist/auth/audit_log_schema.d.ts +34 -1
- package/dist/auth/audit_log_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_schema.js +73 -0
- package/dist/auth/auth_ddl.d.ts +2 -2
- package/dist/auth/auth_ddl.d.ts.map +1 -1
- package/dist/auth/auth_ddl.js +10 -2
- package/dist/auth/cell_action_specs.d.ts +1295 -0
- package/dist/auth/cell_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_action_specs.js +397 -0
- package/dist/auth/cell_actions.d.ts +63 -0
- package/dist/auth/cell_actions.d.ts.map +1 -0
- package/dist/auth/cell_actions.js +546 -0
- package/dist/auth/cell_audit_action_specs.d.ts +131 -0
- package/dist/auth/cell_audit_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_audit_action_specs.js +70 -0
- package/dist/auth/cell_audit_actions.d.ts +18 -0
- package/dist/auth/cell_audit_actions.d.ts.map +1 -0
- package/dist/auth/cell_audit_actions.js +59 -0
- package/dist/auth/cell_audit_events.d.ts +28 -0
- package/dist/auth/cell_audit_events.d.ts.map +1 -0
- package/dist/auth/cell_audit_events.js +42 -0
- package/dist/auth/cell_audit_metadata.d.ts +48 -0
- package/dist/auth/cell_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_audit_metadata.js +46 -0
- package/dist/auth/cell_authorize.d.ts +88 -0
- package/dist/auth/cell_authorize.d.ts.map +1 -0
- package/dist/auth/cell_authorize.js +172 -0
- package/dist/auth/cell_data_schema.d.ts +44 -0
- package/dist/auth/cell_data_schema.d.ts.map +1 -0
- package/dist/auth/cell_data_schema.js +42 -0
- package/dist/auth/cell_field_action_specs.d.ts +244 -0
- package/dist/auth/cell_field_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_field_action_specs.js +136 -0
- package/dist/auth/cell_field_actions.d.ts +34 -0
- package/dist/auth/cell_field_actions.d.ts.map +1 -0
- package/dist/auth/cell_field_actions.js +153 -0
- package/dist/auth/cell_field_audit_metadata.d.ts +30 -0
- package/dist/auth/cell_field_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_field_audit_metadata.js +28 -0
- package/dist/auth/cell_grant_action_specs.d.ts +333 -0
- package/dist/auth/cell_grant_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_grant_action_specs.js +148 -0
- package/dist/auth/cell_grant_actions.d.ts +50 -0
- package/dist/auth/cell_grant_actions.d.ts.map +1 -0
- package/dist/auth/cell_grant_actions.js +208 -0
- package/dist/auth/cell_grant_audit_metadata.d.ts +75 -0
- package/dist/auth/cell_grant_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_grant_audit_metadata.js +54 -0
- package/dist/auth/cell_item_action_specs.d.ts +331 -0
- package/dist/auth/cell_item_action_specs.d.ts.map +1 -0
- package/dist/auth/cell_item_action_specs.js +182 -0
- package/dist/auth/cell_item_actions.d.ts +37 -0
- package/dist/auth/cell_item_actions.d.ts.map +1 -0
- package/dist/auth/cell_item_actions.js +204 -0
- package/dist/auth/cell_item_audit_metadata.d.ts +35 -0
- package/dist/auth/cell_item_audit_metadata.d.ts.map +1 -0
- package/dist/auth/cell_item_audit_metadata.js +32 -0
- package/dist/auth/cell_relation_visibility.d.ts +32 -0
- package/dist/auth/cell_relation_visibility.d.ts.map +1 -0
- package/dist/auth/cell_relation_visibility.js +57 -0
- package/dist/auth/deps.d.ts +9 -0
- package/dist/auth/deps.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.d.ts +30 -0
- package/dist/auth/role_grant_queries.d.ts.map +1 -1
- package/dist/auth/role_grant_queries.js +54 -0
- package/dist/auth/signup_routes.d.ts +0 -3
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +9 -3
- package/dist/auth/standard_rpc_actions.d.ts +5 -5
- package/dist/auth/standard_rpc_actions.js +4 -4
- package/dist/db/CLAUDE.md +118 -0
- package/dist/db/cell_audit_queries.d.ts +26 -0
- package/dist/db/cell_audit_queries.d.ts.map +1 -0
- package/dist/db/cell_audit_queries.js +53 -0
- package/dist/db/cell_ddl.d.ts +151 -0
- package/dist/db/cell_ddl.d.ts.map +1 -0
- package/dist/db/cell_ddl.js +247 -0
- package/dist/db/cell_field_queries.d.ts +105 -0
- package/dist/db/cell_field_queries.d.ts.map +1 -0
- package/dist/db/cell_field_queries.js +113 -0
- package/dist/db/cell_grant_queries.d.ts +132 -0
- package/dist/db/cell_grant_queries.d.ts.map +1 -0
- package/dist/db/cell_grant_queries.js +145 -0
- package/dist/db/cell_history_ddl.d.ts +38 -0
- package/dist/db/cell_history_ddl.d.ts.map +1 -0
- package/dist/db/cell_history_ddl.js +61 -0
- package/dist/db/cell_item_queries.d.ts +107 -0
- package/dist/db/cell_item_queries.d.ts.map +1 -0
- package/dist/db/cell_item_queries.js +119 -0
- package/dist/db/cell_queries.d.ts +327 -0
- package/dist/db/cell_queries.d.ts.map +1 -0
- package/dist/db/cell_queries.js +431 -0
- package/dist/db/fact_ddl.d.ts +38 -0
- package/dist/db/fact_ddl.d.ts.map +1 -0
- package/dist/db/fact_ddl.js +71 -0
- package/dist/db/fact_queries.d.ts +140 -0
- package/dist/db/fact_queries.d.ts.map +1 -0
- package/dist/db/fact_queries.js +161 -0
- package/dist/db/fact_store.d.ts +112 -0
- package/dist/db/fact_store.d.ts.map +1 -0
- package/dist/db/fact_store.js +225 -0
- package/dist/server/app_server.d.ts +1 -7
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +1 -5
- package/dist/server/env.d.ts +2 -0
- package/dist/server/env.d.ts.map +1 -1
- package/dist/server/env.js +6 -0
- package/dist/server/fact_write.d.ts +32 -0
- package/dist/server/fact_write.d.ts.map +1 -0
- package/dist/server/fact_write.js +56 -0
- package/dist/server/file_fact_fetcher.d.ts +42 -0
- package/dist/server/file_fact_fetcher.d.ts.map +1 -0
- package/dist/server/file_fact_fetcher.js +60 -0
- package/dist/server/file_fact_url.d.ts +53 -0
- package/dist/server/file_fact_url.d.ts.map +1 -0
- package/dist/server/file_fact_url.js +52 -0
- package/dist/server/serve_fact_route.d.ts +78 -0
- package/dist/server/serve_fact_route.d.ts.map +1 -0
- package/dist/server/serve_fact_route.js +205 -0
- package/dist/testing/CLAUDE.md +142 -6
- package/dist/testing/app_server.d.ts +46 -0
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +67 -8
- package/dist/testing/audit_completeness.d.ts.map +1 -1
- package/dist/testing/audit_completeness.js +67 -1
- package/dist/testing/cross_backend/account_lifecycle.d.ts +10 -0
- package/dist/testing/cross_backend/account_lifecycle.d.ts.map +1 -0
- package/dist/testing/cross_backend/account_lifecycle.js +144 -0
- package/dist/testing/cross_backend/actor_lookup.d.ts +10 -0
- package/dist/testing/cross_backend/actor_lookup.d.ts.map +1 -0
- package/dist/testing/cross_backend/actor_lookup.js +83 -0
- package/dist/testing/cross_backend/actor_search.d.ts +6 -0
- package/dist/testing/cross_backend/actor_search.d.ts.map +1 -0
- package/dist/testing/cross_backend/actor_search.js +92 -0
- package/dist/testing/cross_backend/app_settings.d.ts +6 -0
- package/dist/testing/cross_backend/app_settings.d.ts.map +1 -0
- package/dist/testing/cross_backend/app_settings.js +95 -0
- package/dist/testing/cross_backend/backend_config.d.ts +1 -1
- package/dist/testing/cross_backend/capabilities.d.ts +29 -7
- package/dist/testing/cross_backend/capabilities.d.ts.map +1 -1
- package/dist/testing/cross_backend/capabilities.js +3 -1
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts +39 -0
- package/dist/testing/cross_backend/cell_cross_helpers.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_cross_helpers.js +45 -0
- package/dist/testing/cross_backend/cell_crud.d.ts +4 -0
- package/dist/testing/cross_backend/cell_crud.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_crud.js +168 -0
- package/dist/testing/cross_backend/cell_grant_role.d.ts +8 -0
- package/dist/testing/cross_backend/cell_grant_role.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_grant_role.js +102 -0
- package/dist/testing/cross_backend/cell_relations.d.ts +4 -0
- package/dist/testing/cross_backend/cell_relations.d.ts.map +1 -0
- package/dist/testing/cross_backend/cell_relations.js +229 -0
- package/dist/testing/cross_backend/conformance_case.d.ts +144 -0
- package/dist/testing/cross_backend/conformance_case.d.ts.map +1 -0
- package/dist/testing/cross_backend/conformance_case.js +132 -0
- package/dist/testing/cross_backend/conformance_table.d.ts +46 -0
- package/dist/testing/cross_backend/conformance_table.d.ts.map +1 -0
- package/dist/testing/cross_backend/conformance_table.js +199 -0
- package/dist/testing/cross_backend/default_backend_configs.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_backend_configs.js +6 -2
- package/dist/testing/cross_backend/default_spine_surface.d.ts +17 -9
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +20 -12
- package/dist/testing/cross_backend/origin.d.ts +10 -0
- package/dist/testing/cross_backend/origin.d.ts.map +1 -0
- package/dist/testing/cross_backend/origin.js +73 -0
- package/dist/testing/cross_backend/setup.d.ts +22 -40
- package/dist/testing/cross_backend/setup.d.ts.map +1 -1
- package/dist/testing/cross_backend/setup.js +39 -5
- package/dist/testing/cross_backend/testing_reset_actions.d.ts +90 -2
- package/dist/testing/cross_backend/testing_reset_actions.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_reset_actions.js +91 -3
- package/dist/testing/cross_backend/xfail.d.ts +15 -0
- package/dist/testing/cross_backend/xfail.d.ts.map +1 -0
- package/dist/testing/cross_backend/xfail.js +37 -0
- package/dist/testing/entities.d.ts.map +1 -1
- package/dist/testing/entities.js +4 -0
- package/dist/testing/integration.d.ts +2 -3
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +20 -85
- package/dist/testing/rate_limiting.d.ts +1 -1
- package/dist/testing/rpc_helpers.d.ts +3 -3
- package/dist/testing/sse_round_trip.d.ts +1 -1
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +0 -1
- package/dist/testing/ws_round_trip.d.ts.map +1 -1
- package/dist/testing/ws_round_trip.js +4 -0
- package/dist/ui/AdminAccounts.svelte +84 -35
- package/dist/ui/AdminAccounts.svelte.d.ts.map +1 -1
- package/dist/ui/AdminSessions.svelte +21 -23
- package/dist/ui/AdminSessions.svelte.d.ts.map +1 -1
- package/dist/ui/CLAUDE.md +17 -26
- package/dist/ui/OpenSignupToggle.svelte +2 -5
- package/dist/ui/OpenSignupToggle.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.d.ts +9 -10
- package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/account_sessions_state.svelte.js +7 -17
- package/dist/ui/admin_accounts_state.svelte.d.ts +41 -20
- package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_accounts_state.svelte.js +52 -22
- package/dist/ui/admin_invites_state.svelte.d.ts +8 -11
- package/dist/ui/admin_invites_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_invites_state.svelte.js +7 -16
- package/dist/ui/admin_rpc_adapters.d.ts +6 -2
- package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
- package/dist/ui/admin_rpc_adapters.js +5 -1
- package/dist/ui/admin_sessions_state.svelte.d.ts +6 -10
- package/dist/ui/admin_sessions_state.svelte.d.ts.map +1 -1
- package/dist/ui/admin_sessions_state.svelte.js +4 -14
- package/dist/ui/app_settings_state.svelte.d.ts +8 -12
- package/dist/ui/app_settings_state.svelte.d.ts.map +1 -1
- package/dist/ui/app_settings_state.svelte.js +6 -16
- package/dist/ui/audit_log_state.svelte.d.ts +9 -8
- package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
- package/dist/ui/audit_log_state.svelte.js +8 -20
- package/package.json +2 -2
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic cell RPC action handlers.
|
|
3
|
+
*
|
|
4
|
+
* Six `request_response` actions bound to the specs in
|
|
5
|
+
* `./cell_action_specs.ts`:
|
|
6
|
+
*
|
|
7
|
+
* - Mutations: `cell_create`, `cell_update`, `cell_delete`, `cell_clone`.
|
|
8
|
+
* - Reads: `cell_get`, `cell_list`.
|
|
9
|
+
*
|
|
10
|
+
* Authorization model:
|
|
11
|
+
*
|
|
12
|
+
* - `cell_create` is authenticated at the spec level. The handler stamps
|
|
13
|
+
* `created_by` from `auth.actor.id`. `path` writes are admin-only —
|
|
14
|
+
* non-admin callers supplying `path` get `ERROR_CELL_PATH_ADMIN_ONLY`.
|
|
15
|
+
* - `cell_get` is `optional` auth at the spec level. Per-row authorization
|
|
16
|
+
* via `can_view_cell(auth, cell)`. Misses + unauthorized reads both 404,
|
|
17
|
+
* so private-cell existence doesn't leak through the wire. Bundled
|
|
18
|
+
* relations are filtered to viewable targets (strict target-visibility).
|
|
19
|
+
* - `cell_update` / `cell_delete` are authenticated at the spec level
|
|
20
|
+
* with per-row `can_edit_cell` enforcement. `path` writes on update
|
|
21
|
+
* are admin-only; `visibility` writes require the manage tier
|
|
22
|
+
* (`can_manage_cell`).
|
|
23
|
+
* - `cell_list` is `optional` auth at the spec level. The SQL-side
|
|
24
|
+
* visibility predicate in `query_cell_list` admits null auth to
|
|
25
|
+
* public-only rows and authed callers to owned + public + grant-admitted
|
|
26
|
+
* rows; admin sees all. SQL-side because post-filtering in JS would
|
|
27
|
+
* silently truncate pages. The handler rejects the `created_by` filter
|
|
28
|
+
* for null auth (account-id enumeration guard).
|
|
29
|
+
*
|
|
30
|
+
* Mutations emit `cell_create` / `cell_update` / `cell_delete` audit
|
|
31
|
+
* events via `deps.audit.emit(...)`. The `AuditLogConfig` threaded through
|
|
32
|
+
* the consumer's `audit_factory` (see `create_app_backend`) must declare
|
|
33
|
+
* the cell event types (see `./cell_audit_metadata.ts`).
|
|
34
|
+
*
|
|
35
|
+
* App vocabulary (e.g., collection / entry kinds) lives in client-side
|
|
36
|
+
* helpers and per-app `validate_data` deps — this layer is generic-only
|
|
37
|
+
* by construction.
|
|
38
|
+
*
|
|
39
|
+
* @module
|
|
40
|
+
*/
|
|
41
|
+
import { z } from 'zod';
|
|
42
|
+
import { rpc_action, } from '../actions/action_rpc.js';
|
|
43
|
+
import { jsonrpc_errors } from '../http/jsonrpc_errors.js';
|
|
44
|
+
import { is_pg_unique_violation } from '../db/pg_error.js';
|
|
45
|
+
import { has_role } from './request_context.js';
|
|
46
|
+
import { ROLE_ADMIN } from './role_schema.js';
|
|
47
|
+
import { cell_create_action_spec, cell_get_action_spec, cell_update_action_spec, cell_delete_action_spec, cell_list_action_spec, cell_clone_action_spec, ERROR_CELL_NOT_FOUND, ERROR_CELL_PATH_ADMIN_ONLY, ERROR_CELL_PATH_TAKEN, ERROR_CELL_VISIBILITY_MANAGE_ONLY, ERROR_CELL_GET_REQUIRES_ID_OR_PATH, ERROR_CELL_CLONE_KIND_MISMATCH, ERROR_CELL_LIST_CREATED_BY_REQUIRES_AUTH, ERROR_CELL_LIST_SHARED_WITH_REQUIRES_AUTH, CELL_LIST_LIMIT_DEFAULT, CELL_RELATIONS_BUNDLE_LIMIT, } from './cell_action_specs.js';
|
|
48
|
+
import { query_cell_create, query_cell_get, query_cell_get_by_path, query_cell_update, query_cell_delete, query_cell_list, query_cell_load_many, } from '../db/cell_queries.js';
|
|
49
|
+
import { query_cell_grant_list_for_cell, query_cell_grants_for_caller_in_cells, } from '../db/cell_grant_queries.js';
|
|
50
|
+
import { query_cell_field_list_for_source, query_cell_field_set } from '../db/cell_field_queries.js';
|
|
51
|
+
import { query_cell_item_insert, query_cell_item_list_for_parent, } from '../db/cell_item_queries.js';
|
|
52
|
+
import { can_view_cell, can_edit_cell, can_manage_cell } from './cell_authorize.js';
|
|
53
|
+
import { filter_visible_target_ids } from './cell_relation_visibility.js';
|
|
54
|
+
import { to_grant_json } from './cell_grant_actions.js';
|
|
55
|
+
import { to_field_json } from './cell_field_actions.js';
|
|
56
|
+
import { to_item_json } from './cell_item_actions.js';
|
|
57
|
+
const to_iso_nullable = (value) => {
|
|
58
|
+
if (value === null)
|
|
59
|
+
return null;
|
|
60
|
+
return typeof value === 'string' ? value : value.toISOString();
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Translate the `idx_cell_path_unique` violation into a clean `conflict`
|
|
64
|
+
* (409) reason. `path` is the only unique constraint a cell write can hit
|
|
65
|
+
* (the id is a server-generated UUID), so a `23505` on a path-bearing
|
|
66
|
+
* write is unambiguously a path collision.
|
|
67
|
+
*/
|
|
68
|
+
const path_taken_error = () => jsonrpc_errors.conflict('cell.path is already taken', { reason: ERROR_CELL_PATH_TAKEN });
|
|
69
|
+
export const to_cell_json = (row) => ({
|
|
70
|
+
id: row.id,
|
|
71
|
+
path: row.path,
|
|
72
|
+
data: row.data,
|
|
73
|
+
visibility: row.visibility,
|
|
74
|
+
refs: row.refs,
|
|
75
|
+
created_by: row.created_by,
|
|
76
|
+
updated_by: row.updated_by,
|
|
77
|
+
created_at: typeof row.created_at === 'string' ? row.created_at : row.created_at.toISOString(),
|
|
78
|
+
updated_at: to_iso_nullable(row.updated_at),
|
|
79
|
+
deleted_at: to_iso_nullable(row.deleted_at),
|
|
80
|
+
grant_count: row.grant_count,
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Emit a cell-mutation audit event with the standard `{cell_id, kind?,
|
|
84
|
+
* path?}` envelope. Relation-graph mutations are tracked independently
|
|
85
|
+
* via per-row `cell_item_*` / `cell_field_*` events.
|
|
86
|
+
*/
|
|
87
|
+
const emit_cell_audit = (ctx, event_type, row, deps, auth) => {
|
|
88
|
+
deps.audit.emit(ctx, {
|
|
89
|
+
event_type,
|
|
90
|
+
actor_id: auth.actor.id,
|
|
91
|
+
account_id: auth.account.id,
|
|
92
|
+
ip: ctx.client_ip,
|
|
93
|
+
metadata: {
|
|
94
|
+
cell_id: row.id,
|
|
95
|
+
kind: row.data.kind,
|
|
96
|
+
path: row.path,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
/** Create the six generic cell RPC actions. */
|
|
101
|
+
export const create_cell_actions = (deps) => {
|
|
102
|
+
const { validate_data } = deps;
|
|
103
|
+
/**
|
|
104
|
+
* Run the optional `validate_data` deps callback and convert any thrown
|
|
105
|
+
* `ZodError` into the standard `invalid_params` JSON-RPC error so per-
|
|
106
|
+
* kind validation failures surface to clients with code -32602 (not
|
|
107
|
+
* -32603 / internal). The dispatcher only auto-converts ZodError for
|
|
108
|
+
* wire-level input schemas; sub-API validation runs inside the handler.
|
|
109
|
+
*/
|
|
110
|
+
const validate_data_or_throw = (data) => {
|
|
111
|
+
if (validate_data === undefined)
|
|
112
|
+
return data;
|
|
113
|
+
try {
|
|
114
|
+
return validate_data(data);
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof z.ZodError) {
|
|
118
|
+
throw jsonrpc_errors.invalid_params('cell.data shape validation failed', {
|
|
119
|
+
issues: err.issues,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const create_handler = async (input, ctx) => {
|
|
126
|
+
const auth = ctx.auth;
|
|
127
|
+
// Path writes are admin-only. Reject before the insert so the audit
|
|
128
|
+
// + DB are clean.
|
|
129
|
+
if (input.path !== undefined && input.path !== null && !has_role(auth, ROLE_ADMIN)) {
|
|
130
|
+
throw jsonrpc_errors.forbidden('cell.path is admin-only', {
|
|
131
|
+
reason: ERROR_CELL_PATH_ADMIN_ONLY,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
// Per-kind shape validation (sub-API). Unknown kinds pass through;
|
|
135
|
+
// known kinds with malformed payloads surface as invalid_params.
|
|
136
|
+
const validated_data = validate_data_or_throw(input.data);
|
|
137
|
+
let row;
|
|
138
|
+
try {
|
|
139
|
+
row = await query_cell_create(ctx, {
|
|
140
|
+
// Boundary cast: `CellData` is structurally JSON-compatible at
|
|
141
|
+
// runtime (loose object of JSON-safe values), but its inferred
|
|
142
|
+
// type carries `T | undefined` from `.optional()` which doesn't
|
|
143
|
+
// extend `Json`. The DB column is JSONB; the cast trusts the
|
|
144
|
+
// schema-validated runtime shape.
|
|
145
|
+
data: validated_data,
|
|
146
|
+
visibility: input.visibility,
|
|
147
|
+
path: input.path ?? null,
|
|
148
|
+
created_by: auth.actor.id,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (input.path != null && is_pg_unique_violation(err))
|
|
153
|
+
throw path_taken_error();
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
emit_cell_audit(ctx, 'cell_create', row, deps, auth);
|
|
157
|
+
return { cell: to_cell_json(row) };
|
|
158
|
+
};
|
|
159
|
+
const get_handler = async (input, ctx) => {
|
|
160
|
+
// Defense in depth: spec already refines for "id or path". Surface
|
|
161
|
+
// the same error code from the handler so adversarial callers that
|
|
162
|
+
// bypass the wire schema get the same error shape.
|
|
163
|
+
if (input.id === undefined && input.path === undefined) {
|
|
164
|
+
throw jsonrpc_errors.invalid_params('cell_get requires id or path', {
|
|
165
|
+
reason: ERROR_CELL_GET_REQUIRES_ID_OR_PATH,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const auth = ctx.auth;
|
|
169
|
+
const row = input.id !== undefined
|
|
170
|
+
? await query_cell_get(ctx, input.id)
|
|
171
|
+
: await query_cell_get_by_path(ctx, input.path);
|
|
172
|
+
if (!row) {
|
|
173
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
174
|
+
}
|
|
175
|
+
// Run the three post-cell fetches in parallel — they share only
|
|
176
|
+
// `row.id` and have no inter-dependency. Bundle fetches one over the
|
|
177
|
+
// cap so we can detect truncation without a separate count query.
|
|
178
|
+
// Skip the grant fetch for unauthenticated callers — no grant can
|
|
179
|
+
// admit a null auth, so the predicate either short-circuits via
|
|
180
|
+
// `cell_is_public` or returns false either way.
|
|
181
|
+
const [grants, fields, items] = await Promise.all([
|
|
182
|
+
auth ? query_cell_grant_list_for_cell(ctx, row.id) : Promise.resolve(null),
|
|
183
|
+
query_cell_field_list_for_source(ctx, row.id, {
|
|
184
|
+
limit: CELL_RELATIONS_BUNDLE_LIMIT + 1,
|
|
185
|
+
}),
|
|
186
|
+
query_cell_item_list_for_parent(ctx, row.id, {
|
|
187
|
+
limit: CELL_RELATIONS_BUNDLE_LIMIT + 1,
|
|
188
|
+
}),
|
|
189
|
+
]);
|
|
190
|
+
// 404 covers both "no such cell" and "exists but caller can't view"
|
|
191
|
+
// — same response code so private-cell existence doesn't leak.
|
|
192
|
+
if (!can_view_cell(auth, row, grants)) {
|
|
193
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
194
|
+
}
|
|
195
|
+
const can_edit = can_edit_cell(auth, row, grants);
|
|
196
|
+
// `can_grant` gates the share UI — managing grants is a manage-tier
|
|
197
|
+
// affordance (admin / owner), so it tracks `can_manage_cell` rather
|
|
198
|
+
// than the broader `can_edit` (editor-grant holders edit, but the
|
|
199
|
+
// share list is the manager's to curate).
|
|
200
|
+
const can_grant = can_manage_cell(auth, row);
|
|
201
|
+
const fields_truncated = fields.length > CELL_RELATIONS_BUNDLE_LIMIT;
|
|
202
|
+
const fields_bundled = fields_truncated ? fields.slice(0, CELL_RELATIONS_BUNDLE_LIMIT) : fields;
|
|
203
|
+
const items_truncated = items.length > CELL_RELATIONS_BUNDLE_LIMIT;
|
|
204
|
+
const items_bundled = items_truncated ? items.slice(0, CELL_RELATIONS_BUNDLE_LIMIT) : items;
|
|
205
|
+
// Strict target-visibility (D8): drop bundled relations whose target
|
|
206
|
+
// the caller can't view, so a viewer of this cell can't enumerate
|
|
207
|
+
// private linked cells by id. One batched filter over both relation
|
|
208
|
+
// id-sets. `*_truncated` still reflects the raw relation size.
|
|
209
|
+
const visible_targets = await filter_visible_target_ids(ctx, auth, [
|
|
210
|
+
...fields_bundled.map((f) => f.target_id),
|
|
211
|
+
...items_bundled.map((i) => i.child_id),
|
|
212
|
+
]);
|
|
213
|
+
const fields_visible = fields_bundled.filter((f) => visible_targets.has(f.target_id));
|
|
214
|
+
const items_visible = items_bundled.filter((i) => visible_targets.has(i.child_id));
|
|
215
|
+
return {
|
|
216
|
+
cell: to_cell_json(row),
|
|
217
|
+
fields: fields_visible.map(to_field_json),
|
|
218
|
+
fields_truncated,
|
|
219
|
+
items: items_visible.map(to_item_json),
|
|
220
|
+
items_truncated,
|
|
221
|
+
can_edit,
|
|
222
|
+
can_grant,
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
const update_handler = async (input, ctx) => {
|
|
226
|
+
const auth = ctx.auth;
|
|
227
|
+
const path_provided = Object.hasOwn(input, 'path');
|
|
228
|
+
// `path` writes are admin-only. Check before fetching so non-admins
|
|
229
|
+
// can't probe for cell existence by varying `path` shape.
|
|
230
|
+
if (path_provided && !has_role(auth, ROLE_ADMIN)) {
|
|
231
|
+
throw jsonrpc_errors.forbidden('cell.path is admin-only', {
|
|
232
|
+
reason: ERROR_CELL_PATH_ADMIN_ONLY,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const existing = await query_cell_get(ctx, input.cell_id);
|
|
236
|
+
if (!existing) {
|
|
237
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
238
|
+
}
|
|
239
|
+
const grants = await query_cell_grant_list_for_cell(ctx, existing.id);
|
|
240
|
+
if (!can_edit_cell(auth, existing, grants)) {
|
|
241
|
+
// IDOR mask: 404, not 403 — same shape as cell_get.
|
|
242
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
243
|
+
}
|
|
244
|
+
// Visibility writes are manage-tier only (admin / owner). An
|
|
245
|
+
// editor-grant holder may edit `data` but cannot flip the cell's
|
|
246
|
+
// visibility — that would let a delegated editor expose a private
|
|
247
|
+
// cell or hide a public one. Gated on an actual change so a client
|
|
248
|
+
// round-tripping the unchanged value isn't rejected.
|
|
249
|
+
if (input.visibility !== undefined &&
|
|
250
|
+
input.visibility !== existing.visibility &&
|
|
251
|
+
!can_manage_cell(auth, existing)) {
|
|
252
|
+
throw jsonrpc_errors.forbidden('cell.visibility is manage-tier only', {
|
|
253
|
+
reason: ERROR_CELL_VISIBILITY_MANAGE_ONLY,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// Per-kind shape validation when `data` is supplied. Patch-only —
|
|
257
|
+
// we don't validate the existing row's data on update (validation
|
|
258
|
+
// is for incoming patches). `data` writes fully replace, so the
|
|
259
|
+
// patch IS the post-update state.
|
|
260
|
+
const validated_data = input.data !== undefined ? validate_data_or_throw(input.data) : undefined;
|
|
261
|
+
let updated;
|
|
262
|
+
try {
|
|
263
|
+
updated = await query_cell_update(ctx, input.cell_id, {
|
|
264
|
+
// Boundary cast (see `create_handler`).
|
|
265
|
+
data: validated_data,
|
|
266
|
+
visibility: input.visibility,
|
|
267
|
+
path: path_provided ? (input.path ?? null) : undefined,
|
|
268
|
+
updated_by: auth.actor.id,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
if (path_provided && input.path != null && is_pg_unique_violation(err)) {
|
|
273
|
+
throw path_taken_error();
|
|
274
|
+
}
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
if (!updated) {
|
|
278
|
+
// Raced with a deleter between the visibility check and the UPDATE.
|
|
279
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
280
|
+
}
|
|
281
|
+
emit_cell_audit(ctx, 'cell_update', updated, deps, auth);
|
|
282
|
+
return { cell: to_cell_json(updated) };
|
|
283
|
+
};
|
|
284
|
+
const delete_handler = async (input, ctx) => {
|
|
285
|
+
const auth = ctx.auth;
|
|
286
|
+
// Fetch first so we can audit `kind` + `path` after the soft-delete
|
|
287
|
+
// flips `deleted_at`.
|
|
288
|
+
const existing = await query_cell_get(ctx, input.cell_id);
|
|
289
|
+
if (!existing) {
|
|
290
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
291
|
+
}
|
|
292
|
+
const grants = await query_cell_grant_list_for_cell(ctx, existing.id);
|
|
293
|
+
if (!can_edit_cell(auth, existing, grants)) {
|
|
294
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
295
|
+
}
|
|
296
|
+
const deleted = await query_cell_delete(ctx, input.cell_id, { deleted_by: auth.actor.id });
|
|
297
|
+
if (!deleted) {
|
|
298
|
+
// Raced with another deleter.
|
|
299
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
300
|
+
}
|
|
301
|
+
emit_cell_audit(ctx, 'cell_delete', existing, deps, auth);
|
|
302
|
+
return { ok: true, deleted: true };
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* Insert one cloned cell row owned by the caller.
|
|
306
|
+
*
|
|
307
|
+
* `data` is `{...source.data, ...patch}` — patch wins last for
|
|
308
|
+
* predictable merge semantics. `path` is always nulled (admin-only
|
|
309
|
+
* paths can't auto-clone — no admin escalation through clone).
|
|
310
|
+
* Relations (`cell_field`, `cell_item`) are NOT copied here; the
|
|
311
|
+
* caller copies fields shallowly and walks items per the clone
|
|
312
|
+
* semantics in `clone_handler`. Provenance lives only in the
|
|
313
|
+
* `cell_clone` audit row's `source_id`; `data` carries no
|
|
314
|
+
* server-stamped provenance fields.
|
|
315
|
+
*/
|
|
316
|
+
const clone_one_cell_row = async (ctx, source, auth, options) => {
|
|
317
|
+
// Both `source.data` and `options.patch_data` are CellData (loose
|
|
318
|
+
// objects). Patch-last shallow merge composes cleanly.
|
|
319
|
+
const merged_data = options.patch_data !== undefined ? { ...source.data, ...options.patch_data } : source.data;
|
|
320
|
+
// Block cross-kind patches before per-kind shape validation: a
|
|
321
|
+
// kind-A → kind-B patch can pass `validate_data` coincidentally
|
|
322
|
+
// (loose shapes accept overlapping fields) and produce an
|
|
323
|
+
// incoherent result. Reject when the merged kind diverges from the
|
|
324
|
+
// source's kind. Both undefined or equal kinds pass through;
|
|
325
|
+
// one-sided undefined is permissive (caller patching unrelated
|
|
326
|
+
// fields on a typeless source).
|
|
327
|
+
if (source.data.kind !== undefined &&
|
|
328
|
+
merged_data.kind !== undefined &&
|
|
329
|
+
source.data.kind !== merged_data.kind) {
|
|
330
|
+
throw jsonrpc_errors.invalid_params('cell_clone cannot change kind via with_data_patch', {
|
|
331
|
+
reason: ERROR_CELL_CLONE_KIND_MISMATCH,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// Per-kind shape validation runs on the merged result (sub-API).
|
|
335
|
+
// Source rows are validated on their original create; the patch
|
|
336
|
+
// could violate the kind shape (e.g., remove a required field).
|
|
337
|
+
const validated_data = validate_data_or_throw(merged_data);
|
|
338
|
+
return query_cell_create(ctx, {
|
|
339
|
+
// Boundary cast (see `create_handler`).
|
|
340
|
+
data: validated_data,
|
|
341
|
+
visibility: source.visibility,
|
|
342
|
+
path: null, // admin-only paths cannot auto-clone
|
|
343
|
+
created_by: auth.actor.id,
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
const clone_handler = async (input, ctx) => {
|
|
347
|
+
const auth = ctx.auth;
|
|
348
|
+
const source = await query_cell_get(ctx, input.source_id);
|
|
349
|
+
if (!source) {
|
|
350
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
351
|
+
}
|
|
352
|
+
const source_grants = await query_cell_grant_list_for_cell(ctx, source.id);
|
|
353
|
+
// 404 covers both miss and unauthorized read — same shape as cell_get
|
|
354
|
+
// so the existence of private cells doesn't leak through clones.
|
|
355
|
+
if (!can_view_cell(auth, source, source_grants)) {
|
|
356
|
+
throw jsonrpc_errors.not_found('cell', { reason: ERROR_CELL_NOT_FOUND });
|
|
357
|
+
}
|
|
358
|
+
const deep = input.deep === true;
|
|
359
|
+
// Pre-fetch source relations OUTSIDE the transaction so authz checks
|
|
360
|
+
// (per-child `can_view_cell`) on `query_cell_get`-fetched grants
|
|
361
|
+
// don't bloat the transaction. The fetch sees the world as of
|
|
362
|
+
// pre-transaction; the inserts inside the transaction commit
|
|
363
|
+
// atomically together.
|
|
364
|
+
const source_fields = await query_cell_field_list_for_source(ctx, source.id);
|
|
365
|
+
const source_items = deep
|
|
366
|
+
? await query_cell_item_list_for_parent(ctx, source.id)
|
|
367
|
+
: [];
|
|
368
|
+
// In deep mode, pre-resolve which children are viewable so we can
|
|
369
|
+
// skip un-viewable ones silently (strict per-target view filter, D8).
|
|
370
|
+
// Batched (not per-child): one bulk row load + one batched visibility
|
|
371
|
+
// filter over the whole child id-set — same shape the forward reads
|
|
372
|
+
// and `filter_visible_target_ids` use, instead of an N+1 walk. Done
|
|
373
|
+
// outside the transaction for the same reason as the relation fetch.
|
|
374
|
+
const cloneable_items = [];
|
|
375
|
+
const cloneable_children = new Map();
|
|
376
|
+
if (deep) {
|
|
377
|
+
const child_ids = source_items.map((i) => i.child_id);
|
|
378
|
+
const [child_rows, visible_children] = await Promise.all([
|
|
379
|
+
query_cell_load_many(ctx, child_ids),
|
|
380
|
+
filter_visible_target_ids(ctx, auth, child_ids),
|
|
381
|
+
]);
|
|
382
|
+
const child_by_id = new Map(child_rows.map((r) => [r.id, r]));
|
|
383
|
+
for (const item of source_items) {
|
|
384
|
+
const child = child_by_id.get(item.child_id);
|
|
385
|
+
// Skip missing (soft-deleted/vanished) and non-viewable children
|
|
386
|
+
// silently — no count is surfaced, so the source's hidden-child
|
|
387
|
+
// count never leaks to the cloner (D8).
|
|
388
|
+
if (!child || !visible_children.has(item.child_id))
|
|
389
|
+
continue;
|
|
390
|
+
cloneable_items.push(item);
|
|
391
|
+
cloneable_children.set(item.child_id, child);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// `cell_clone_action_spec.side_effects = true`, so the RPC
|
|
395
|
+
// dispatcher already wraps the handler in a transaction —
|
|
396
|
+
// `ctx.db` is transaction-scoped. Every write below participates
|
|
397
|
+
// in that single transaction and rolls back together on any
|
|
398
|
+
// failure. No nested `ctx.db.transaction(...)` here.
|
|
399
|
+
const cloned_root = await clone_one_cell_row(ctx, source, auth, {
|
|
400
|
+
patch_data: input.with_data_patch,
|
|
401
|
+
});
|
|
402
|
+
// Copy outgoing fields shallowly: the clone points at the same
|
|
403
|
+
// targets the source did (fields are JSON references, not
|
|
404
|
+
// contents). Cloning `foo` should not deep-clone `foo.author`.
|
|
405
|
+
//
|
|
406
|
+
// Strict target-visibility (D8): only copy field edges whose target
|
|
407
|
+
// the caller may view. `cell_field_set` gates the target on
|
|
408
|
+
// `can_view_cell`, so without this filter clone would be a side door
|
|
409
|
+
// to owning edges that point at private cells the cloner can't see.
|
|
410
|
+
// Non-viewable targets are dropped silently.
|
|
411
|
+
const field_targets_visible = await filter_visible_target_ids(ctx, auth, source_fields.map((f) => f.target_id));
|
|
412
|
+
for (const f of source_fields) {
|
|
413
|
+
if (!field_targets_visible.has(f.target_id))
|
|
414
|
+
continue;
|
|
415
|
+
// Route through the query layer (not raw SQL) for parity with the
|
|
416
|
+
// item-copy path below. The clone target is fresh, so the UPSERT's
|
|
417
|
+
// `ON CONFLICT` is a no-op — behaviorally a plain insert.
|
|
418
|
+
await query_cell_field_set(ctx, {
|
|
419
|
+
source_id: cloned_root.id,
|
|
420
|
+
name: f.name,
|
|
421
|
+
target_id: f.target_id,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (deep) {
|
|
425
|
+
// Deep mode: clone each viewable direct child, attach via new
|
|
426
|
+
// cell_item rows reusing the source position. Reusing the
|
|
427
|
+
// position keeps lex order stable and avoids the
|
|
428
|
+
// fractional_index machinery for an internal walk that can't
|
|
429
|
+
// collide (the clone's `(parent_id, position)` slot is fresh —
|
|
430
|
+
// no concurrent writer).
|
|
431
|
+
for (const item of cloneable_items) {
|
|
432
|
+
const child = cloneable_children.get(item.child_id);
|
|
433
|
+
const cloned_child = await clone_one_cell_row(ctx, child, auth, {});
|
|
434
|
+
await query_cell_item_insert(ctx, {
|
|
435
|
+
parent_id: cloned_root.id,
|
|
436
|
+
position: item.position,
|
|
437
|
+
child_id: cloned_child.id,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// Shallow: copy `cell_item` rows referencing the source's
|
|
443
|
+
// children, sharing both `child_id` and `position`. Preserves
|
|
444
|
+
// the "shallow copies the clone's outgoing edges, sharing
|
|
445
|
+
// targets with the source" invariant.
|
|
446
|
+
//
|
|
447
|
+
// Strict target-visibility (D8): only copy item edges whose child
|
|
448
|
+
// the caller may view — the same invariant the deep walk and
|
|
449
|
+
// `cell_item_insert` enforce. Non-viewable children are skipped
|
|
450
|
+
// silently (no count surfaced — see the deep-walk note on the
|
|
451
|
+
// hidden-child-count leak).
|
|
452
|
+
const shallow_items = await query_cell_item_list_for_parent(ctx, source.id);
|
|
453
|
+
const shallow_children_visible = await filter_visible_target_ids(ctx, auth, shallow_items.map((i) => i.child_id));
|
|
454
|
+
for (const item of shallow_items) {
|
|
455
|
+
if (!shallow_children_visible.has(item.child_id))
|
|
456
|
+
continue;
|
|
457
|
+
await query_cell_item_insert(ctx, {
|
|
458
|
+
parent_id: cloned_root.id,
|
|
459
|
+
position: item.position,
|
|
460
|
+
child_id: item.child_id,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Audit envelope is richer than the standard cell-mutation envelope
|
|
465
|
+
// — emit directly rather than threading it through `emit_cell_audit`.
|
|
466
|
+
// `kind` is read from `source.data` (not the cloned row) so the
|
|
467
|
+
// audit trail attributes the clone to the source's shape.
|
|
468
|
+
const source_kind = source.data.kind;
|
|
469
|
+
const cloned_child_count = deep ? cloneable_items.length : 0;
|
|
470
|
+
deps.audit.emit(ctx, {
|
|
471
|
+
event_type: 'cell_clone',
|
|
472
|
+
actor_id: auth.actor.id,
|
|
473
|
+
account_id: auth.account.id,
|
|
474
|
+
ip: ctx.client_ip,
|
|
475
|
+
metadata: {
|
|
476
|
+
source_id: source.id,
|
|
477
|
+
new_id: cloned_root.id,
|
|
478
|
+
deep,
|
|
479
|
+
item_count: cloned_child_count,
|
|
480
|
+
...(source_kind !== undefined ? { kind: source_kind } : {}),
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
return { cell: to_cell_json(cloned_root) };
|
|
484
|
+
};
|
|
485
|
+
const list_handler = async (input, ctx) => {
|
|
486
|
+
const auth = ctx.auth;
|
|
487
|
+
// Null auth + `created_by` is a soft account-id enumeration probe
|
|
488
|
+
// ("does account X have any public cells?") — require auth to use it.
|
|
489
|
+
if (auth === null && input.created_by !== undefined) {
|
|
490
|
+
throw jsonrpc_errors.invalid_params('cell_list created_by requires authentication', {
|
|
491
|
+
reason: ERROR_CELL_LIST_CREATED_BY_REQUIRES_AUTH,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
// `shared_with: 'me'` resolves to the caller's actor + role_grants;
|
|
495
|
+
// no auth means no caller, no admit path.
|
|
496
|
+
if (auth === null && input.shared_with !== undefined) {
|
|
497
|
+
throw jsonrpc_errors.invalid_params('cell_list shared_with requires authentication', {
|
|
498
|
+
reason: ERROR_CELL_LIST_SHARED_WITH_REQUIRES_AUTH,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
// Project the active role_grant set into parallel arrays for the
|
|
502
|
+
// `cell_grant` role-shaped EXISTS. Middleware (`request_context`)
|
|
503
|
+
// has already filtered to active-only role_grants; we trust that and
|
|
504
|
+
// pass NULL `scope_id`s through (global-scope role_grants).
|
|
505
|
+
const role_grant_roles = auth ? auth.role_grants.map((p) => p.role) : [];
|
|
506
|
+
const role_grant_scope_ids = auth ? auth.role_grants.map((p) => p.scope_id) : [];
|
|
507
|
+
const caller_actor_id = auth?.actor?.id ?? null;
|
|
508
|
+
const rows = await query_cell_list(ctx, {
|
|
509
|
+
ids: input.ids,
|
|
510
|
+
data_kind: input.data_kind,
|
|
511
|
+
visibility: input.visibility,
|
|
512
|
+
ref: input.ref,
|
|
513
|
+
created_by: input.created_by,
|
|
514
|
+
path_prefix: input.path_prefix,
|
|
515
|
+
viewer_actor_id: caller_actor_id,
|
|
516
|
+
viewer_is_admin: auth ? has_role(auth, ROLE_ADMIN) : false,
|
|
517
|
+
caller_actor_id,
|
|
518
|
+
caller_role_grant_roles: role_grant_roles,
|
|
519
|
+
caller_role_grant_scope_ids: role_grant_scope_ids,
|
|
520
|
+
shared_with_caller_only: input.shared_with === 'me',
|
|
521
|
+
order_by: input.order_by,
|
|
522
|
+
order_direction: input.order_direction,
|
|
523
|
+
// Apply the default cap when caller omits `limit`. Without this
|
|
524
|
+
// the SQL `LIMIT NULL` returns every matching row.
|
|
525
|
+
limit: input.limit ?? CELL_LIST_LIMIT_DEFAULT,
|
|
526
|
+
offset: input.offset,
|
|
527
|
+
});
|
|
528
|
+
let cell_grants;
|
|
529
|
+
if (input.shared_with === 'me' && rows.length > 0 && caller_actor_id !== null) {
|
|
530
|
+
const grant_rows = await query_cell_grants_for_caller_in_cells(ctx, rows.map((r) => r.id), caller_actor_id, role_grant_roles, role_grant_scope_ids);
|
|
531
|
+
cell_grants = {};
|
|
532
|
+
for (const g of grant_rows) {
|
|
533
|
+
(cell_grants[g.cell_id] ??= []).push(to_grant_json(g));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { cells: rows.map(to_cell_json), cell_grants };
|
|
537
|
+
};
|
|
538
|
+
return [
|
|
539
|
+
rpc_action(cell_create_action_spec, create_handler),
|
|
540
|
+
rpc_action(cell_get_action_spec, get_handler),
|
|
541
|
+
rpc_action(cell_update_action_spec, update_handler),
|
|
542
|
+
rpc_action(cell_delete_action_spec, delete_handler),
|
|
543
|
+
rpc_action(cell_list_action_spec, list_handler),
|
|
544
|
+
rpc_action(cell_clone_action_spec, clone_handler),
|
|
545
|
+
];
|
|
546
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cell_audit_list` RPC — per-cell audit timeline.
|
|
3
|
+
*
|
|
4
|
+
* Returns audit-log rows whose metadata names this cell on any of the
|
|
5
|
+
* `(cell_id, source_id, parent_id, child_id, target_id, new_id)` keys
|
|
6
|
+
* used by the cell-domain event types. The handler 404-masks for
|
|
7
|
+
* callers who are not in the cell's manage tier (`can_manage_cell` =
|
|
8
|
+
* admin / owner) — the timeline reveals who-touched-the-cell, so it is
|
|
9
|
+
* gated above `can_view_cell`.
|
|
10
|
+
*
|
|
11
|
+
* Read-only; no audit side effect. Returns the most-recent
|
|
12
|
+
* `CELL_AUDIT_LIST_DEFAULT_LIMIT` events; pagination is intentionally
|
|
13
|
+
* not on the wire yet — the only consumer renders a single page. Add
|
|
14
|
+
* `{before, limit}` input + `{next_before}` output together when a
|
|
15
|
+
* paginating consumer surfaces.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
/**
|
|
21
|
+
* Wire shape for a single cell-audit row. Narrower than
|
|
22
|
+
* `AuditLogEventJson` — `account_id` and `target_account_id` are
|
|
23
|
+
* deliberately omitted so this verb does NOT surface the actor↔account
|
|
24
|
+
* join. `target_actor_id` and `metadata` are dropped too: `target_actor_id`
|
|
25
|
+
* is NULL for every cell-domain event (the grant recipient lives inside
|
|
26
|
+
* `metadata.principal` on grant rows, not on the audit-log top-level
|
|
27
|
+
* field); `metadata` is unread by the timeline UI.
|
|
28
|
+
*
|
|
29
|
+
* `ip` is also omitted: it is PII about the actors who touched the cell,
|
|
30
|
+
* and even at the manage tier this per-cell timeline has no need for it
|
|
31
|
+
* (admins reach the full `audit_log` surface, which carries `ip`, through
|
|
32
|
+
* the admin audit verbs). Keeping it off this wire avoids leaking
|
|
33
|
+
* collaborators' IPs to a cell's owner.
|
|
34
|
+
*
|
|
35
|
+
* All omitted fields can be re-added under a richer admin-only
|
|
36
|
+
* event-detail view later — keep the wire surface honest about what
|
|
37
|
+
* consumers use.
|
|
38
|
+
*/
|
|
39
|
+
export declare const CellAuditEventJson: z.ZodObject<{
|
|
40
|
+
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
41
|
+
seq: z.ZodNumber;
|
|
42
|
+
event_type: z.ZodString;
|
|
43
|
+
outcome: z.ZodEnum<{
|
|
44
|
+
success: "success";
|
|
45
|
+
failure: "failure";
|
|
46
|
+
}>;
|
|
47
|
+
actor_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
48
|
+
created_at: z.ZodString;
|
|
49
|
+
}, z.core.$strict>;
|
|
50
|
+
export type CellAuditEventJson = z.infer<typeof CellAuditEventJson>;
|
|
51
|
+
/** Page size for `cell_audit_list`. Single page at MVP; no cursor wire. */
|
|
52
|
+
export declare const CELL_AUDIT_LIST_DEFAULT_LIMIT = 50;
|
|
53
|
+
export declare const CellAuditListInput: z.ZodObject<{
|
|
54
|
+
cell_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
55
|
+
acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
56
|
+
}, z.core.$strict>;
|
|
57
|
+
export type CellAuditListInput = z.infer<typeof CellAuditListInput>;
|
|
58
|
+
export declare const CellAuditListOutput: z.ZodObject<{
|
|
59
|
+
events: z.ZodArray<z.ZodObject<{
|
|
60
|
+
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
61
|
+
seq: z.ZodNumber;
|
|
62
|
+
event_type: z.ZodString;
|
|
63
|
+
outcome: z.ZodEnum<{
|
|
64
|
+
success: "success";
|
|
65
|
+
failure: "failure";
|
|
66
|
+
}>;
|
|
67
|
+
actor_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
68
|
+
created_at: z.ZodString;
|
|
69
|
+
}, z.core.$strict>>;
|
|
70
|
+
}, z.core.$strict>;
|
|
71
|
+
export type CellAuditListOutput = z.infer<typeof CellAuditListOutput>;
|
|
72
|
+
export declare const cell_audit_list_action_spec: {
|
|
73
|
+
method: string;
|
|
74
|
+
kind: "request_response";
|
|
75
|
+
initiator: "frontend";
|
|
76
|
+
auth: {
|
|
77
|
+
account: "required";
|
|
78
|
+
actor: "required";
|
|
79
|
+
};
|
|
80
|
+
side_effects: false;
|
|
81
|
+
input: z.ZodObject<{
|
|
82
|
+
cell_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
83
|
+
acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
84
|
+
}, z.core.$strict>;
|
|
85
|
+
output: z.ZodObject<{
|
|
86
|
+
events: z.ZodArray<z.ZodObject<{
|
|
87
|
+
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
88
|
+
seq: z.ZodNumber;
|
|
89
|
+
event_type: z.ZodString;
|
|
90
|
+
outcome: z.ZodEnum<{
|
|
91
|
+
success: "success";
|
|
92
|
+
failure: "failure";
|
|
93
|
+
}>;
|
|
94
|
+
actor_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
95
|
+
created_at: z.ZodString;
|
|
96
|
+
}, z.core.$strict>>;
|
|
97
|
+
}, z.core.$strict>;
|
|
98
|
+
async: true;
|
|
99
|
+
description: string;
|
|
100
|
+
};
|
|
101
|
+
/** Registry export to compose into `all_cell_action_specs`. */
|
|
102
|
+
export declare const all_cell_audit_action_specs: readonly [{
|
|
103
|
+
method: string;
|
|
104
|
+
kind: "request_response";
|
|
105
|
+
initiator: "frontend";
|
|
106
|
+
auth: {
|
|
107
|
+
account: "required";
|
|
108
|
+
actor: "required";
|
|
109
|
+
};
|
|
110
|
+
side_effects: false;
|
|
111
|
+
input: z.ZodObject<{
|
|
112
|
+
cell_id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
113
|
+
acting: z.ZodOptional<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
114
|
+
}, z.core.$strict>;
|
|
115
|
+
output: z.ZodObject<{
|
|
116
|
+
events: z.ZodArray<z.ZodObject<{
|
|
117
|
+
id: z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">;
|
|
118
|
+
seq: z.ZodNumber;
|
|
119
|
+
event_type: z.ZodString;
|
|
120
|
+
outcome: z.ZodEnum<{
|
|
121
|
+
success: "success";
|
|
122
|
+
failure: "failure";
|
|
123
|
+
}>;
|
|
124
|
+
actor_id: z.ZodNullable<z.core.$ZodBranded<z.ZodUUID, "Uuid", "out">>;
|
|
125
|
+
created_at: z.ZodString;
|
|
126
|
+
}, z.core.$strict>>;
|
|
127
|
+
}, z.core.$strict>;
|
|
128
|
+
async: true;
|
|
129
|
+
description: string;
|
|
130
|
+
}];
|
|
131
|
+
//# sourceMappingURL=cell_audit_action_specs.d.ts.map
|