@anchrd/intel-api 0.20.0 → 0.22.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/adapters/cloudflare/cloudflare.js +51 -3
- package/dist/adapters/db/db.js +150 -14
- package/dist/flows/flows.d.ts +6 -2
- package/dist/flows/flows.js +52 -18
- package/dist/flows/flows.types.d.ts +2 -2
- package/dist/http/http.js +14 -2
- package/dist/nodes/nodes.js +50 -30
- package/dist/nodes/nodes.types.d.ts +28 -0
- package/dist/tools/tools.js +0 -6
- package/dist/tools/tools.types.d.ts +0 -1
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/package.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createGateClient } from "@anchrd/gate-sdk";
|
|
2
|
+
import { serverOf } from "@anchrd/intel-contract/tool";
|
|
2
3
|
import { ulid } from "ulid";
|
|
3
4
|
import { createBrowserAuth } from "../../auth/auth.js";
|
|
4
5
|
import { createBundle } from "../../bundle/bundle.js";
|
|
@@ -156,13 +157,60 @@ export default {
|
|
|
156
157
|
// requirements list and every tree link of every run read this one answer, so none of
|
|
157
158
|
// them can be kinder than the others.
|
|
158
159
|
visibleNodes: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
|
|
159
|
-
|
|
160
|
+
/**
|
|
161
|
+
* ⚠️ The fingerprint of a whole SURFACE, not of one tool (#489). A step names a server and
|
|
162
|
+
* optionally the functions of it it may use, so what publishing freezes is that set — every
|
|
163
|
+
* covered function's own fingerprint, sorted so the order the portal happens to answer in
|
|
164
|
+
* cannot change the result, and hashed into one value.
|
|
165
|
+
*
|
|
166
|
+
* `null` in three cases, and all three have to refuse a publish: the catalog is unreachable,
|
|
167
|
+
* the server offers this user nothing, or a function the step explicitly allows is gone. The
|
|
168
|
+
* last one matters most — a narrowed step whose function disappeared is not a step that may
|
|
169
|
+
* quietly fall back to the rest of the server.
|
|
170
|
+
*/
|
|
171
|
+
toolSurfaceFingerprint: async (actor, server, allow) => {
|
|
160
172
|
const catalog = await tools
|
|
161
173
|
.catalog({ id: actor.id, email: actor.email, canExecute: true })
|
|
162
174
|
.catch(() => null);
|
|
163
|
-
|
|
175
|
+
if (!catalog)
|
|
176
|
+
return null;
|
|
177
|
+
const reachable = new Map(catalog.items.map((item) => [item.name, item.fingerprint]));
|
|
178
|
+
const covered = allow === null
|
|
179
|
+
? [...reachable.keys()].filter((name) => serverOf(name, [server]) !== null)
|
|
180
|
+
: allow;
|
|
181
|
+
if (covered.length === 0)
|
|
182
|
+
return null;
|
|
183
|
+
const parts = [];
|
|
184
|
+
for (const name of [...new Set(covered)].sort()) {
|
|
185
|
+
const fingerprint = reachable.get(name);
|
|
186
|
+
if (!fingerprint)
|
|
187
|
+
return null;
|
|
188
|
+
parts.push(`${name}:${fingerprint}`);
|
|
189
|
+
}
|
|
190
|
+
// ⚠️ The handle and the narrowing mode are part of what is hashed, not just the covered
|
|
191
|
+
// functions. Without them two different surfaces could collide: a server whose only
|
|
192
|
+
// function is allowed explicitly would hash the same as the same server left open, and the
|
|
193
|
+
// difference between "this one function" and "whatever this server offers" is exactly what
|
|
194
|
+
// publishing is meant to freeze.
|
|
195
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode([server, allow === null ? "*" : "allow", ...parts].join("\n")));
|
|
196
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
197
|
+
},
|
|
198
|
+
// Which of the named servers this user does NOT reach. A server is reached when the live
|
|
199
|
+
// catalog carries at least one function of it — the same live answer the tool surface uses,
|
|
200
|
+
// never a mirrored table.
|
|
201
|
+
// ⚠️ The catalog error is NOT caught here, and that is the whole point. Answering an
|
|
202
|
+
// unreadable portal with "all of them are missing" turns an outage into a sentence about
|
|
203
|
+
// PERMISSIONS — the reader goes asking for access they already have, while the portal is
|
|
204
|
+
// simply down. `tools.catalog` already tells the two apart (`tool_catalog_unreadable`,
|
|
205
|
+
// `portal_not_connected`), and letting that answer through is the same rule the tool surface
|
|
206
|
+
// keeps one screen up: a sentence more specific than the evidence sends the reader looking in
|
|
207
|
+
// the wrong place.
|
|
208
|
+
unavailableServers: async (actor, servers) => {
|
|
209
|
+
if (servers.length === 0)
|
|
210
|
+
return [];
|
|
211
|
+
const catalog = await tools.catalog({ id: actor.id, email: actor.email, canExecute: true });
|
|
212
|
+
return servers.filter((server) => !catalog.items.some((item) => serverOf(item.name, [server]) !== null));
|
|
164
213
|
},
|
|
165
|
-
unavailableTools: async (actor, toolNames) => await tools.unavailable({ id: actor.id, email: actor.email, canExecute: true }, toolNames),
|
|
166
214
|
});
|
|
167
215
|
return await createIntel({
|
|
168
216
|
baseUrl: env.INTEL_URL,
|
package/dist/adapters/db/db.js
CHANGED
|
@@ -649,16 +649,78 @@ export function createNodeRepository(deps) {
|
|
|
649
649
|
},
|
|
650
650
|
async countInboundLinks(nodeId) {
|
|
651
651
|
const row = await deps.db
|
|
652
|
-
.prepare(
|
|
652
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
653
|
+
SELECT id FROM nodes WHERE id = ?
|
|
654
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
655
|
+
) SELECT COUNT(*) AS n FROM node_links
|
|
656
|
+
WHERE target_node_id IN (SELECT id FROM tree)
|
|
657
|
+
AND source_node_id NOT IN (SELECT id FROM tree)`)
|
|
653
658
|
.bind(nodeId)
|
|
654
659
|
.first();
|
|
655
660
|
return row?.n ?? 0;
|
|
656
661
|
},
|
|
662
|
+
async inspectPurgeTree(nodeId) {
|
|
663
|
+
const tree = `WITH RECURSIVE tree(id) AS (
|
|
664
|
+
SELECT id FROM nodes WHERE id = ?
|
|
665
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
666
|
+
)`;
|
|
667
|
+
const nodes = await deps.db
|
|
668
|
+
.prepare(`${tree} SELECT n.id, n.kind FROM nodes n JOIN tree ON tree.id = n.id`)
|
|
669
|
+
.bind(nodeId)
|
|
670
|
+
.all();
|
|
671
|
+
const nodeIds = (nodes.results ?? []).map((row) => row.id);
|
|
672
|
+
const flows = await deps.db
|
|
673
|
+
.prepare(`${tree} SELECT f.id FROM flows f WHERE f.parent_id IN (SELECT id FROM tree)`)
|
|
674
|
+
.bind(nodeId)
|
|
675
|
+
.all();
|
|
676
|
+
const keys = await deps.db
|
|
677
|
+
.prepare(`${tree} SELECT v.content_key FROM node_versions v WHERE v.node_id IN (SELECT id FROM tree)`)
|
|
678
|
+
.bind(nodeId)
|
|
679
|
+
.all();
|
|
680
|
+
const vectors = await deps.db
|
|
681
|
+
.prepare(`${tree} SELECT v.node_id, v.chunk_key FROM node_vectors v WHERE v.node_id IN (SELECT id FROM tree)`)
|
|
682
|
+
.bind(nodeId)
|
|
683
|
+
.all();
|
|
684
|
+
const grouped = new Map();
|
|
685
|
+
for (const row of vectors.results ?? []) {
|
|
686
|
+
const current = grouped.get(row.node_id) ?? [];
|
|
687
|
+
current.push(row.chunk_key);
|
|
688
|
+
grouped.set(row.node_id, current);
|
|
689
|
+
}
|
|
690
|
+
const counts = { flow: flows.results?.length ?? 0 };
|
|
691
|
+
for (const row of nodes.results ?? [])
|
|
692
|
+
counts[row.kind] = (counts[row.kind] ?? 0) + 1;
|
|
693
|
+
return {
|
|
694
|
+
nodeIds,
|
|
695
|
+
flowIds: (flows.results ?? []).map((row) => row.id),
|
|
696
|
+
contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))],
|
|
697
|
+
vectorKeys: nodeIds.map((id) => ({ nodeId: id, keys: grouped.get(id) ?? [] })),
|
|
698
|
+
counts,
|
|
699
|
+
};
|
|
700
|
+
},
|
|
701
|
+
async findPurgeReplay(actorId, idempotencyKey) {
|
|
702
|
+
const row = await deps.db
|
|
703
|
+
.prepare(`SELECT title, content_keys_json FROM node_purge_receipts
|
|
704
|
+
WHERE actor_id = ? AND idempotency_key = ?`)
|
|
705
|
+
.bind(actorId, idempotencyKey)
|
|
706
|
+
.first();
|
|
707
|
+
return row ? { title: row.title, contentKeys: JSON.parse(row.content_keys_json) } : null;
|
|
708
|
+
},
|
|
709
|
+
async completePurgeReplay(actorId, idempotencyKey, completedAt) {
|
|
710
|
+
await deps.db
|
|
711
|
+
.prepare(`UPDATE node_purge_receipts SET completed_at = ?
|
|
712
|
+
WHERE actor_id = ? AND idempotency_key = ?`)
|
|
713
|
+
.bind(completedAt, actorId, idempotencyKey)
|
|
714
|
+
.run();
|
|
715
|
+
},
|
|
657
716
|
async purgeNode(input) {
|
|
658
717
|
// The keys BEFORE anything falls: after the batch there is no version row left to read them
|
|
659
718
|
// from, and an object nobody can name is an object nobody can delete.
|
|
660
719
|
const keys = await deps.db
|
|
661
|
-
.prepare(
|
|
720
|
+
.prepare(`WITH RECURSIVE tree(id) AS (
|
|
721
|
+
SELECT id FROM nodes WHERE id = ?
|
|
722
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
723
|
+
) SELECT content_key FROM node_versions WHERE node_id IN (SELECT id FROM tree)`)
|
|
662
724
|
.bind(input.nodeId)
|
|
663
725
|
.all();
|
|
664
726
|
// ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
|
|
@@ -669,6 +731,13 @@ export function createNodeRepository(deps) {
|
|
|
669
731
|
// reader was told nothing had happened while the content was already gone. The audit row
|
|
670
732
|
// carried the same condition, so it was not even booked.
|
|
671
733
|
const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
|
|
734
|
+
const tree = `WITH RECURSIVE tree(id) AS (
|
|
735
|
+
SELECT id FROM nodes WHERE id = ?
|
|
736
|
+
UNION ALL SELECT child.id FROM nodes child JOIN tree parent ON child.parent_id = parent.id
|
|
737
|
+
)`;
|
|
738
|
+
const treeFlows = `${tree}, tree_flows(id) AS (
|
|
739
|
+
SELECT id FROM flows WHERE parent_id IN (SELECT id FROM tree)
|
|
740
|
+
)`;
|
|
672
741
|
const deleted = await deps.db.batch([
|
|
673
742
|
// ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
|
|
674
743
|
// `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
|
|
@@ -680,40 +749,71 @@ export function createNodeRepository(deps) {
|
|
|
680
749
|
json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
|
|
681
750
|
FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
682
751
|
.bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
|
|
752
|
+
deps.db
|
|
753
|
+
.prepare(`INSERT INTO node_purge_receipts (
|
|
754
|
+
actor_id, idempotency_key, node_id, title, content_keys_json, completed_at
|
|
755
|
+
) SELECT ?, ?, n.id, n.title, ?, NULL FROM nodes n
|
|
756
|
+
WHERE n.id = ? AND n.archived_at IS NOT NULL`)
|
|
757
|
+
.bind(input.actorId, input.idempotencyKey, JSON.stringify([...new Set((keys.results ?? []).map((row) => row.content_key))]), input.nodeId),
|
|
683
758
|
// The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
|
|
684
759
|
// but a leak: the title stands in the result and the content is gone.
|
|
685
760
|
deps.db
|
|
686
|
-
.prepare(
|
|
761
|
+
.prepare(`${tree} DELETE FROM node_fts WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
687
762
|
.bind(input.nodeId, input.nodeId),
|
|
688
763
|
deps.db
|
|
689
|
-
.prepare(
|
|
764
|
+
.prepare(`${tree} DELETE FROM node_vectors WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
690
765
|
.bind(input.nodeId, input.nodeId),
|
|
691
766
|
deps.db
|
|
692
|
-
.prepare(
|
|
693
|
-
WHERE version_id IN (SELECT id FROM node_versions WHERE node_id
|
|
767
|
+
.prepare(`${tree} DELETE FROM node_index_state
|
|
768
|
+
WHERE version_id IN (SELECT id FROM node_versions WHERE node_id IN (SELECT id FROM tree)) AND ${alive}`)
|
|
694
769
|
.bind(input.nodeId, input.nodeId),
|
|
695
770
|
// Both directions. Outgoing links belong to this node; incoming ones would otherwise point
|
|
696
771
|
// at a row that is gone, and a foreign key would refuse the delete anyway.
|
|
697
772
|
deps.db
|
|
698
|
-
.prepare(
|
|
699
|
-
WHERE (source_node_id
|
|
700
|
-
.bind(input.nodeId, input.nodeId
|
|
773
|
+
.prepare(`${tree} DELETE FROM node_links
|
|
774
|
+
WHERE (source_node_id IN (SELECT id FROM tree) OR target_node_id IN (SELECT id FROM tree)) AND ${alive}`)
|
|
775
|
+
.bind(input.nodeId, input.nodeId),
|
|
701
776
|
// A permission on a node that does not exist is a dangling permission.
|
|
702
777
|
deps.db
|
|
703
|
-
.prepare(
|
|
778
|
+
.prepare(`${tree} DELETE FROM node_grants WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
779
|
+
.bind(input.nodeId, input.nodeId),
|
|
780
|
+
deps.db
|
|
781
|
+
.prepare(`${treeFlows} UPDATE flows SET published_version_id = NULL
|
|
782
|
+
WHERE id NOT IN (SELECT id FROM tree_flows) AND EXISTS (
|
|
783
|
+
SELECT 1 FROM flow_versions fv, json_tree(fv.graph_json) graph
|
|
784
|
+
WHERE fv.id IN (flows.current_version_id, flows.published_version_id)
|
|
785
|
+
AND ((graph.key = 'resourceId' AND graph.value IN (SELECT id FROM tree))
|
|
786
|
+
OR (graph.key = 'flowId' AND graph.value IN (SELECT id FROM tree_flows)))
|
|
787
|
+
) AND ${alive}`)
|
|
788
|
+
.bind(input.nodeId, input.nodeId),
|
|
789
|
+
deps.db
|
|
790
|
+
.prepare(`${treeFlows} DELETE FROM flow_run_steps
|
|
791
|
+
WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows)) AND ${alive}`)
|
|
792
|
+
.bind(input.nodeId, input.nodeId),
|
|
793
|
+
deps.db
|
|
794
|
+
.prepare(`${treeFlows} DELETE FROM flow_runs WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
795
|
+
.bind(input.nodeId, input.nodeId),
|
|
796
|
+
deps.db
|
|
797
|
+
.prepare(`${treeFlows} DELETE FROM flow_versions WHERE flow_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
798
|
+
.bind(input.nodeId, input.nodeId),
|
|
799
|
+
deps.db
|
|
800
|
+
.prepare(`${treeFlows} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
801
|
+
.bind(input.nodeId, input.nodeId),
|
|
802
|
+
deps.db
|
|
803
|
+
.prepare(`${treeFlows} DELETE FROM flows WHERE id IN (SELECT id FROM tree_flows) AND ${alive}`)
|
|
704
804
|
.bind(input.nodeId, input.nodeId),
|
|
705
805
|
deps.db
|
|
706
|
-
.prepare(
|
|
806
|
+
.prepare(`${tree} DELETE FROM node_versions WHERE node_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
707
807
|
.bind(input.nodeId, input.nodeId),
|
|
708
808
|
// ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
|
|
709
809
|
// one of them would be answered with a resource id that no longer resolves.
|
|
710
810
|
deps.db
|
|
711
|
-
.prepare(
|
|
811
|
+
.prepare(`${tree} DELETE FROM idempotency_keys WHERE resource_id IN (SELECT id FROM tree) AND ${alive}`)
|
|
712
812
|
.bind(input.nodeId, input.nodeId),
|
|
713
813
|
// LAST — and its count is what the caller reads as the verdict.
|
|
714
814
|
deps.db
|
|
715
|
-
.prepare(
|
|
716
|
-
.bind(input.nodeId),
|
|
815
|
+
.prepare(`${tree} DELETE FROM nodes WHERE id IN (SELECT id FROM tree) AND ${alive}`)
|
|
816
|
+
.bind(input.nodeId, input.nodeId),
|
|
717
817
|
]);
|
|
718
818
|
const removed = deleted.at(-1)?.meta?.changes ?? 0;
|
|
719
819
|
if (removed === 0)
|
|
@@ -913,6 +1013,42 @@ export function createNodeRepository(deps) {
|
|
|
913
1013
|
.all();
|
|
914
1014
|
return (result.results ?? []).map(mapGrant);
|
|
915
1015
|
},
|
|
1016
|
+
async listEffectiveAccess(resourceId) {
|
|
1017
|
+
// Owners and grants come from ONE statement, hence one D1 snapshot. Two parallel SELECTs are
|
|
1018
|
+
// not atomic: a grant mutation between their snapshots can produce a principal list that
|
|
1019
|
+
// never existed as a whole.
|
|
1020
|
+
const row = await deps.db
|
|
1021
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
|
|
1022
|
+
SELECT id, parent_id, owner_id FROM nodes WHERE id = ?
|
|
1023
|
+
UNION
|
|
1024
|
+
SELECT parent.id, parent.parent_id, parent.owner_id
|
|
1025
|
+
FROM nodes parent
|
|
1026
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
1027
|
+
)
|
|
1028
|
+
SELECT
|
|
1029
|
+
(SELECT json_group_array(owner_id)
|
|
1030
|
+
FROM (SELECT DISTINCT owner_id FROM ancestors ORDER BY owner_id)) AS owner_ids_json,
|
|
1031
|
+
(SELECT json_group_array(json_object(
|
|
1032
|
+
'id', id, 'node_id', node_id, 'principal_type', principal_type,
|
|
1033
|
+
'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
|
|
1034
|
+
'created_by', created_by, 'created_at', created_at
|
|
1035
|
+
))
|
|
1036
|
+
FROM (
|
|
1037
|
+
SELECT grant_row.id, grant_row.node_id, grant_row.principal_type,
|
|
1038
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
1039
|
+
grant_row.created_by, grant_row.created_at
|
|
1040
|
+
FROM node_grants grant_row
|
|
1041
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
1042
|
+
WHERE grant_row.expires_at IS NULL OR grant_row.expires_at > ?
|
|
1043
|
+
ORDER BY grant_row.principal_type, grant_row.principal_id, grant_row.verb
|
|
1044
|
+
)) AS grants_json`)
|
|
1045
|
+
.bind(resourceId, deps.now().toISOString())
|
|
1046
|
+
.first();
|
|
1047
|
+
return {
|
|
1048
|
+
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
1049
|
+
items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
|
|
1050
|
+
};
|
|
1051
|
+
},
|
|
916
1052
|
// ⚠️ `UNION`, never `UNION ALL`: a ring in `parent_id` must end the recursion rather than the
|
|
917
1053
|
// database, the same reason every other walk of this tree gives.
|
|
918
1054
|
async organizationExecuteReaches(nodeId, exceptGrantId) {
|
package/dist/flows/flows.d.ts
CHANGED
|
@@ -23,14 +23,18 @@ export declare function toolNodes(graph: FlowGraph): ToolStepNode[];
|
|
|
23
23
|
export declare function resourceIdOf(node: TreeLinkNode): string;
|
|
24
24
|
export declare function calleeIds(graph: FlowGraph): string[];
|
|
25
25
|
/**
|
|
26
|
-
* The documents a graph names and the
|
|
26
|
+
* The documents a graph names and the tool SERVERS it reaches for, flattened and without repetition.
|
|
27
27
|
* The requirements list, the publish-time check and the run's first tool check read this one answer,
|
|
28
28
|
* so they cannot disagree about what a flow touches. A caller that needs to know *which step* names
|
|
29
29
|
* a document reads the node lists above instead — the relation graph draws exactly that edge.
|
|
30
|
+
*
|
|
31
|
+
* ⚠️ Servers, not functions, since #489. A step no longer names one function, so "what does this
|
|
32
|
+
* flow need" cannot be answered with a function list any more — and answering it with the functions
|
|
33
|
+
* a step MIGHT call would be a guess about a choice that is made while the flow runs.
|
|
30
34
|
*/
|
|
31
35
|
export declare function graphReferences(graph: FlowGraph): {
|
|
32
36
|
nodes: string[];
|
|
33
|
-
|
|
37
|
+
servers: string[];
|
|
34
38
|
};
|
|
35
39
|
export declare function compileFlow(graph: FlowGraph): CompiledFlow;
|
|
36
40
|
export declare function createFlows(deps: FlowDeps): FlowService;
|
package/dist/flows/flows.js
CHANGED
|
@@ -46,24 +46,28 @@ export function calleeIds(graph) {
|
|
|
46
46
|
return ids;
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* The documents a graph names and the
|
|
49
|
+
* The documents a graph names and the tool SERVERS it reaches for, flattened and without repetition.
|
|
50
50
|
* The requirements list, the publish-time check and the run's first tool check read this one answer,
|
|
51
51
|
* so they cannot disagree about what a flow touches. A caller that needs to know *which step* names
|
|
52
52
|
* a document reads the node lists above instead — the relation graph draws exactly that edge.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ Servers, not functions, since #489. A step no longer names one function, so "what does this
|
|
55
|
+
* flow need" cannot be answered with a function list any more — and answering it with the functions
|
|
56
|
+
* a step MIGHT call would be a guess about a choice that is made while the flow runs.
|
|
53
57
|
*/
|
|
54
58
|
export function graphReferences(graph) {
|
|
55
59
|
const nodes = [];
|
|
56
|
-
const
|
|
60
|
+
const servers = [];
|
|
57
61
|
for (const node of treeLinkNodes(graph)) {
|
|
58
62
|
if (!nodes.includes(node.configuration.resourceId)) {
|
|
59
63
|
nodes.push(node.configuration.resourceId);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
for (const node of toolNodes(graph)) {
|
|
63
|
-
if (!
|
|
64
|
-
|
|
67
|
+
if (!servers.includes(node.configuration.server))
|
|
68
|
+
servers.push(node.configuration.server);
|
|
65
69
|
}
|
|
66
|
-
return { nodes,
|
|
70
|
+
return { nodes, servers };
|
|
67
71
|
}
|
|
68
72
|
// ⚠️ The reason, in the words the person can act on, and not one word more. How many documents a
|
|
69
73
|
// step cannot reach is something they may know; which ones they are is the very thing the ACL is
|
|
@@ -76,10 +80,14 @@ function treeLinkStepDetail(label, missing) {
|
|
|
76
80
|
// ⚠️ For tools this is the only honest moment there is. The catalog is a live tools/list with the
|
|
77
81
|
// requesting user's own token (ADR-0003), so nobody can be told in advance what someone else would
|
|
78
82
|
// see — but the person in front of the failure can be told exactly where to go.
|
|
83
|
+
// ⚠️ Servers, not tools, since #489 — and the sentence says so. A step names a server, so a person
|
|
84
|
+
// told "you do not have access to this tool: notion" would go looking for a function by that name
|
|
85
|
+
// and find none. What they can act on is the server: it is what the portal grants and what they can
|
|
86
|
+
// ask to be granted.
|
|
79
87
|
function toolStepDetail(missing) {
|
|
80
88
|
return missing.length === 1
|
|
81
|
-
? `You do not
|
|
82
|
-
: `You do not
|
|
89
|
+
? `You do not reach this tool server in the portal: ${missing.join(", ")}`
|
|
90
|
+
: `You do not reach these tool servers in the portal: ${missing.join(", ")}`;
|
|
83
91
|
}
|
|
84
92
|
export function compileFlow(graph) {
|
|
85
93
|
const nodes = new Map();
|
|
@@ -439,11 +447,11 @@ export function createFlows(deps) {
|
|
|
439
447
|
// The one consequence to know about: `start` throws the FIRST entry, so it now names the first
|
|
440
448
|
// missing tool rather than all of them — the same as it has always done for several
|
|
441
449
|
// unpublished sub-flows below. `validate` is what lists every reason at once, and it does.
|
|
442
|
-
for (const
|
|
450
|
+
for (const server of await deps.unavailableServers(actor, graphReferences(version.graph).servers)) {
|
|
443
451
|
problems.push({
|
|
444
452
|
status: 403,
|
|
445
453
|
code: "flow_tools_unavailable",
|
|
446
|
-
detail: toolStepDetail([
|
|
454
|
+
detail: toolStepDetail([server]),
|
|
447
455
|
});
|
|
448
456
|
}
|
|
449
457
|
try {
|
|
@@ -597,12 +605,12 @@ export function createFlows(deps) {
|
|
|
597
605
|
async function requireToolsAuthorized(actor, node, graph) {
|
|
598
606
|
if (!node)
|
|
599
607
|
return;
|
|
600
|
-
const
|
|
608
|
+
const servers = attachedNodes(node, graph)
|
|
601
609
|
.filter((candidate) => candidate.kind === "tool")
|
|
602
|
-
.map((candidate) => candidate.configuration.
|
|
603
|
-
if (!
|
|
610
|
+
.map((candidate) => candidate.configuration.server);
|
|
611
|
+
if (!servers.length)
|
|
604
612
|
return;
|
|
605
|
-
const missing = await deps.
|
|
613
|
+
const missing = await deps.unavailableServers(actor, [...new Set(servers)]);
|
|
606
614
|
if (missing.length) {
|
|
607
615
|
throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
|
|
608
616
|
}
|
|
@@ -773,6 +781,19 @@ export function createFlows(deps) {
|
|
|
773
781
|
let changed = false;
|
|
774
782
|
const nodes = [];
|
|
775
783
|
for (const node of graph.nodes) {
|
|
784
|
+
// A tool step is frozen the same way a sub-flow call is: what it may reach is pinned at the
|
|
785
|
+
// moment of publishing (#489). The check above has already refused an unreachable server and
|
|
786
|
+
// a surface that moved since a previous publish, so this only writes down what it confirmed.
|
|
787
|
+
if (node.kind === "tool") {
|
|
788
|
+
const fingerprint = await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow);
|
|
789
|
+
if (fingerprint !== null && fingerprint !== node.configuration.fingerprint) {
|
|
790
|
+
nodes.push({ ...node, configuration: { ...node.configuration, fingerprint } });
|
|
791
|
+
changed = true;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
nodes.push(node);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
776
797
|
if (node.kind !== "subflow") {
|
|
777
798
|
nodes.push(node);
|
|
778
799
|
continue;
|
|
@@ -1085,7 +1106,7 @@ export function createFlows(deps) {
|
|
|
1085
1106
|
versionId: null,
|
|
1086
1107
|
nodes: [],
|
|
1087
1108
|
hiddenNodes: 0,
|
|
1088
|
-
|
|
1109
|
+
servers: [],
|
|
1089
1110
|
};
|
|
1090
1111
|
}
|
|
1091
1112
|
const version = await requireVersion(versionId, flow.id);
|
|
@@ -1101,7 +1122,7 @@ export function createFlows(deps) {
|
|
|
1101
1122
|
versionId: version.id,
|
|
1102
1123
|
nodes: reachable,
|
|
1103
1124
|
hiddenNodes: referenced.nodes.length - reachable.length,
|
|
1104
|
-
|
|
1125
|
+
servers: referenced.servers,
|
|
1105
1126
|
};
|
|
1106
1127
|
},
|
|
1107
1128
|
async create(actor, input) {
|
|
@@ -1311,11 +1332,24 @@ export function createFlows(deps) {
|
|
|
1311
1332
|
}
|
|
1312
1333
|
for (const node of version.graph.nodes) {
|
|
1313
1334
|
if (node.kind === "tool") {
|
|
1314
|
-
|
|
1335
|
+
// ⚠️ The fingerprint covers the whole surface this step may use — the server and, when
|
|
1336
|
+
// the step narrows it, exactly those functions with their schemas (#489). Freezing one
|
|
1337
|
+
// function was enough while a step named one; a step that may use any function of a
|
|
1338
|
+
// server would otherwise silently inherit functions the provider added AFTER publishing.
|
|
1339
|
+
const fingerprint = await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow);
|
|
1315
1340
|
if (!fingerprint) {
|
|
1316
|
-
throw new IntelError(409, "flow_tool_unavailable", `Tool is unavailable: ${node.label}`);
|
|
1341
|
+
throw new IntelError(409, "flow_tool_unavailable", `Tool server is unavailable: ${node.label}`);
|
|
1317
1342
|
}
|
|
1318
|
-
|
|
1343
|
+
// ⚠️ A step that has never been published carries no fingerprint, and that is not a
|
|
1344
|
+
// mismatch — it is the state every draft starts in. Publishing is what FREEZES the
|
|
1345
|
+
// surface, exactly as it turns a `latest` sub-flow call into a pinned one, and `freeze`
|
|
1346
|
+
// below writes the value in.
|
|
1347
|
+
//
|
|
1348
|
+
// Demanding it up front would mean the editor had to produce it, and nothing hands it
|
|
1349
|
+
// one: the value is derived from the asking user's own live catalog. Requiring it would
|
|
1350
|
+
// make every tool step unpublishable rather than safe.
|
|
1351
|
+
if (node.configuration.fingerprint !== null &&
|
|
1352
|
+
node.configuration.fingerprint !== fingerprint) {
|
|
1319
1353
|
throw new IntelError(409, "flow_tool_schema_changed", `Review the current schema before publishing: ${node.label}`);
|
|
1320
1354
|
}
|
|
1321
1355
|
}
|
|
@@ -209,8 +209,8 @@ export interface FlowDeps {
|
|
|
209
209
|
* kinder than the door it describes — which is what #17 and #19 were sent back for.
|
|
210
210
|
*/
|
|
211
211
|
visibleNodes(actor: FlowActor, nodeId: string): Promise<Node | null>;
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
toolSurfaceFingerprint(actor: FlowActor, server: string, allow: string[] | null): Promise<string | null>;
|
|
213
|
+
unavailableServers(actor: FlowActor, servers: string[]): Promise<string[]>;
|
|
214
214
|
}
|
|
215
215
|
export type FolderAccess = "ok" | "missing" | "not-a-folder" | "forbidden";
|
|
216
216
|
export interface FlowService {
|
package/dist/http/http.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
3
|
-
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
3
|
+
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
4
4
|
import { RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
|
|
5
5
|
import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
|
|
6
6
|
import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
|
|
@@ -366,13 +366,25 @@ export function createHttp(deps) {
|
|
|
366
366
|
// two steps and the first one is reversible.
|
|
367
367
|
app.delete("/nodes/:nodeId", async (context) => {
|
|
368
368
|
const auth = requireCapability(context, "nodes", "write");
|
|
369
|
-
const input = PurgeNodeInput.parse(
|
|
369
|
+
const input = PurgeNodeInput.parse(await context.req.json().catch(() => null));
|
|
370
|
+
if (input.nodeId !== context.req.param("nodeId")) {
|
|
371
|
+
throw new IntelError(400, "node_id_mismatch", "Path and body node IDs differ");
|
|
372
|
+
}
|
|
370
373
|
return context.json(await deps.nodes.purge(asActor(auth), input));
|
|
371
374
|
});
|
|
375
|
+
app.get("/nodes/:nodeId/purge-preview", async (context) => {
|
|
376
|
+
const auth = requireCapability(context, "nodes", "write");
|
|
377
|
+
const input = PurgeNodePreviewInput.parse({ nodeId: context.req.param("nodeId") });
|
|
378
|
+
return context.json(await deps.nodes.purgePreview(asActor(auth), input));
|
|
379
|
+
});
|
|
372
380
|
app.get("/nodes/:nodeId/grants", async (context) => {
|
|
373
381
|
const auth = requireCapability(context, "nodes", "share");
|
|
374
382
|
return context.json(await deps.nodes.listGrants(asActor(auth), context.req.param("nodeId")));
|
|
375
383
|
});
|
|
384
|
+
app.get("/nodes/:nodeId/effective-access", async (context) => {
|
|
385
|
+
const auth = requireCapability(context, "nodes", "share");
|
|
386
|
+
return context.json(await deps.nodes.listEffectiveAccess(asActor(auth), context.req.param("nodeId")));
|
|
387
|
+
});
|
|
376
388
|
app.post("/nodes/:nodeId/grants", async (context) => {
|
|
377
389
|
const auth = requireCapability(context, "nodes", "share");
|
|
378
390
|
const input = ShareInput.parse(await context.req.json().catch(() => null));
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -381,6 +381,20 @@ export function createNodes(deps) {
|
|
|
381
381
|
async get(actor, nodeId) {
|
|
382
382
|
return await getDocument(await requireVisible(actor, nodeId));
|
|
383
383
|
},
|
|
384
|
+
async purgePreview(actor, input) {
|
|
385
|
+
const node = await requireVisible(actor, input.nodeId);
|
|
386
|
+
if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
|
|
387
|
+
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
388
|
+
}
|
|
389
|
+
if (!node.archivedAt) {
|
|
390
|
+
throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
|
|
391
|
+
}
|
|
392
|
+
const tree = await deps.repository.inspectPurgeTree(node.id);
|
|
393
|
+
return {
|
|
394
|
+
inboundLinks: await deps.repository.countInboundLinks(node.id),
|
|
395
|
+
totalItems: tree.nodeIds.length + tree.flowIds.length,
|
|
396
|
+
};
|
|
397
|
+
},
|
|
384
398
|
/**
|
|
385
399
|
* The content of one pinned version (#147). Search citations pin the version they quoted, and
|
|
386
400
|
* without this read a citation could name text no surface can show any more.
|
|
@@ -855,6 +869,14 @@ export function createNodes(deps) {
|
|
|
855
869
|
* chosen to the safer side.
|
|
856
870
|
*/
|
|
857
871
|
async purge(actor, input) {
|
|
872
|
+
const idempotencyKey = `${input.nodeId}:${input.idempotencyKey}`;
|
|
873
|
+
const replay = await deps.repository.findPurgeReplay(actor.id, idempotencyKey);
|
|
874
|
+
if (replay) {
|
|
875
|
+
for (const key of replay.contentKeys)
|
|
876
|
+
await deps.content.delete(key);
|
|
877
|
+
await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
|
|
878
|
+
return { purged: true, title: replay.title };
|
|
879
|
+
}
|
|
858
880
|
const node = await requireVisible(actor, input.nodeId);
|
|
859
881
|
if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
|
|
860
882
|
throw new IntelError(403, "node_forbidden", "This node cannot be edited");
|
|
@@ -866,28 +888,14 @@ export function createNodes(deps) {
|
|
|
866
888
|
if (!node.archivedAt) {
|
|
867
889
|
throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
|
|
868
890
|
}
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
|
|
872
|
-
// an answer with a reason beats an effect that reaches further than the question.
|
|
873
|
-
if (await deps.repository.hasAnyChild(node.id)) {
|
|
874
|
-
throw new IntelError(409, "folder_not_empty", "This folder still holds entries. Empty it before deleting it for good.");
|
|
875
|
-
}
|
|
876
|
-
// ⚠️ The other class of dependant, and the reason it refuses where a prose link does not: a
|
|
877
|
-
// flow would break at RUN TIME, in front of somebody who did not order this deletion.
|
|
878
|
-
//
|
|
879
|
-
// ⚠️ `flowsUsingNode`, NOT `externalFlowCallers` — the first version asked the second and
|
|
880
|
-
// therefore never refused anything (found in review). `externalFlowCallers` answers "who calls
|
|
881
|
-
// a FLOW filed inside this folder" (`kind: "subflow"`); a flow reads a NODE through a
|
|
882
|
-
// tree-link step (`configuration.resourceId`). Two different graph shapes, and only the second
|
|
883
|
-
// one is what a deleted document breaks.
|
|
884
|
-
const users = await deps.flowsUsingNode(actor, node.id);
|
|
885
|
-
if (users.visible.length || users.hidden) {
|
|
886
|
-
throw new IntelError(409, "node_in_use_by_flow", callersDetail(users));
|
|
887
|
-
}
|
|
891
|
+
// #492 replaces the refusal with owned cleanup. Flows inside the tree disappear with it;
|
|
892
|
+
// published flows outside it are depublished atomically before their dependency vanishes.
|
|
893
|
+
const tree = await deps.repository.inspectPurgeTree(node.id);
|
|
888
894
|
// Not a refusal — a fact for the record. A prose reference is a mention, not an operation;
|
|
889
|
-
// refusing over one would make deleting a lottery, decided by whoever once linked to it.
|
|
890
|
-
//
|
|
895
|
+
// refusing over one would make deleting a lottery, decided by whoever once linked to it.
|
|
896
|
+
//
|
|
897
|
+
// The same count is exposed by `purgePreview`, so the surface can name the consequence before
|
|
898
|
+
// confirmation. It is counted again here because the audit records the state at deletion time.
|
|
891
899
|
const inboundLinks = await deps.repository.countInboundLinks(node.id);
|
|
892
900
|
// ⚠️ The vectors go FIRST, and the order is the correction of a wrong claim (found in review).
|
|
893
901
|
// The comment here used to say Vectorize needed no call because archiving had already emptied
|
|
@@ -901,11 +909,14 @@ export function createNodes(deps) {
|
|
|
901
909
|
// the rule behind both is the same: DO THE IRRECOVERABLE THING LAST. A failed batch after the
|
|
902
910
|
// vectors are gone leaves an ARCHIVED node whose index can be rebuilt (`reindex`); a failed
|
|
903
911
|
// removal after the batch leaves orphans nothing can name.
|
|
904
|
-
const vectorKeys =
|
|
912
|
+
const vectorKeys = tree.vectorKeys;
|
|
905
913
|
// The whole-node chunk carries the empty key and is never in the table — the pass writes it
|
|
906
914
|
// and records the rest, so a purge that only sent the recorded ones would leave it behind.
|
|
907
|
-
if (deps.semantic)
|
|
908
|
-
|
|
915
|
+
if (deps.semantic) {
|
|
916
|
+
for (const vector of vectorKeys) {
|
|
917
|
+
await deps.semantic.remove(vector.nodeId, ["", ...vector.keys]);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
909
920
|
const purged = await deps.repository.purgeNode({
|
|
910
921
|
nodeId: node.id,
|
|
911
922
|
actorId: actor.id,
|
|
@@ -913,7 +924,8 @@ export function createNodes(deps) {
|
|
|
913
924
|
occurredAt: deps.now().toISOString(),
|
|
914
925
|
// ⚠️ The title travels INTO the record from the row itself (the repository reads it there),
|
|
915
926
|
// and this is what travels beside it. After the delete nothing can look either up.
|
|
916
|
-
metadata: { inboundLinks },
|
|
927
|
+
metadata: { inboundLinks, counts: tree.counts },
|
|
928
|
+
idempotencyKey,
|
|
917
929
|
});
|
|
918
930
|
// The row was restored or already gone between the check and the statement. Nothing was
|
|
919
931
|
// deleted, and saying so is better than reporting a success that did not happen.
|
|
@@ -921,13 +933,13 @@ export function createNodes(deps) {
|
|
|
921
933
|
throw new IntelError(409, "update_conflict", "This node was changed by another editor");
|
|
922
934
|
}
|
|
923
935
|
// ⚠️ AFTER the database agreed, never before: it is the truth about what exists, and an object
|
|
924
|
-
// deleted ahead of a batch that then fails would leave a node whose content is gone.
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
// an archived node reaches this line. What is emptied above is Intel's own bookkeeping.
|
|
936
|
+
// deleted ahead of a batch that then fails would leave a node whose content is gone. This is
|
|
937
|
+
// the same rule the vectors follow in the other direction — do the irrecoverable thing last —
|
|
938
|
+
// and the directions differ because only one of the two can be rebuilt: an index can, an
|
|
939
|
+
// object cannot.
|
|
929
940
|
for (const key of purged.contentKeys)
|
|
930
941
|
await deps.content.delete(key);
|
|
942
|
+
await deps.repository.completePurgeReplay(actor.id, idempotencyKey, deps.now().toISOString());
|
|
931
943
|
return { purged: true, title: node.title };
|
|
932
944
|
},
|
|
933
945
|
async listGrants(actor, resourceId) {
|
|
@@ -941,6 +953,14 @@ export function createNodes(deps) {
|
|
|
941
953
|
items: await deps.repository.listGrants(resourceId),
|
|
942
954
|
};
|
|
943
955
|
},
|
|
956
|
+
async listEffectiveAccess(actor, resourceId) {
|
|
957
|
+
const node = await requireVisible(actor, resourceId);
|
|
958
|
+
if (!(await deps.repository.can(actor, resourceId, "share"))) {
|
|
959
|
+
throw new IntelError(403, "node_forbidden", "Sharing of this node cannot be managed");
|
|
960
|
+
}
|
|
961
|
+
const effective = await deps.repository.listEffectiveAccess(resourceId);
|
|
962
|
+
return { resourceId: node.id, ...effective };
|
|
963
|
+
},
|
|
944
964
|
async listLinks(actor, nodeId) {
|
|
945
965
|
await requireVisible(actor, nodeId);
|
|
946
966
|
return { items: await deps.repository.listLinksVisible(actor, nodeId) };
|
|
@@ -87,16 +87,36 @@ export interface NodeRepository {
|
|
|
87
87
|
hasAnyChild(nodeId: string): Promise<boolean>;
|
|
88
88
|
listVectorKeys(nodeId: string): Promise<string[]>;
|
|
89
89
|
countInboundLinks(nodeId: string): Promise<number>;
|
|
90
|
+
inspectPurgeTree(nodeId: string): Promise<{
|
|
91
|
+
nodeIds: string[];
|
|
92
|
+
flowIds: string[];
|
|
93
|
+
contentKeys: string[];
|
|
94
|
+
vectorKeys: Array<{
|
|
95
|
+
nodeId: string;
|
|
96
|
+
keys: string[];
|
|
97
|
+
}>;
|
|
98
|
+
counts: Record<string, number>;
|
|
99
|
+
}>;
|
|
100
|
+
findPurgeReplay(actorId: string, idempotencyKey: string): Promise<{
|
|
101
|
+
title: string;
|
|
102
|
+
contentKeys: string[];
|
|
103
|
+
} | null>;
|
|
104
|
+
completePurgeReplay(actorId: string, idempotencyKey: string, completedAt: string): Promise<void>;
|
|
90
105
|
purgeNode(input: {
|
|
91
106
|
nodeId: string;
|
|
92
107
|
actorId: string;
|
|
93
108
|
auditId: string;
|
|
94
109
|
occurredAt: string;
|
|
95
110
|
metadata: Record<string, unknown>;
|
|
111
|
+
idempotencyKey: string;
|
|
96
112
|
}): Promise<"missing" | {
|
|
97
113
|
contentKeys: string[];
|
|
98
114
|
}>;
|
|
99
115
|
listGrants(resourceId: string): Promise<ResourceGrant[]>;
|
|
116
|
+
listEffectiveAccess(resourceId: string): Promise<{
|
|
117
|
+
ownerIds: string[];
|
|
118
|
+
items: ResourceGrant[];
|
|
119
|
+
}>;
|
|
100
120
|
organizationExecuteReaches(nodeId: string, exceptGrantId: string): Promise<boolean>;
|
|
101
121
|
listLinksVisible(actor: Actor, nodeId: string): Promise<NodeLink[]>;
|
|
102
122
|
resolveVisibleTitles(actor: Actor, nodeIds: string[]): Promise<Array<{
|
|
@@ -287,11 +307,19 @@ export interface NodeService {
|
|
|
287
307
|
archive(actor: Actor, input: ArchiveNodeInput): Promise<Node>;
|
|
288
308
|
purge(actor: Actor, input: {
|
|
289
309
|
nodeId: string;
|
|
310
|
+
idempotencyKey: string;
|
|
290
311
|
}): Promise<{
|
|
291
312
|
purged: true;
|
|
292
313
|
title: string;
|
|
293
314
|
}>;
|
|
315
|
+
purgePreview(actor: Actor, input: {
|
|
316
|
+
nodeId: string;
|
|
317
|
+
}): Promise<{
|
|
318
|
+
inboundLinks: number;
|
|
319
|
+
totalItems: number;
|
|
320
|
+
}>;
|
|
294
321
|
listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
|
|
322
|
+
listEffectiveAccess(actor: Actor, resourceId: string): Promise<import("@anchrd/intel-contract/share").ResourceAccessList>;
|
|
295
323
|
listLinks(actor: Actor, nodeId: string): Promise<{
|
|
296
324
|
items: NodeLink[];
|
|
297
325
|
}>;
|
package/dist/tools/tools.js
CHANGED
|
@@ -203,11 +203,5 @@ export function createTools(deps) {
|
|
|
203
203
|
}
|
|
204
204
|
return await call(actor, input);
|
|
205
205
|
},
|
|
206
|
-
async unavailable(actor, names) {
|
|
207
|
-
if (names.length === 0)
|
|
208
|
-
return [];
|
|
209
|
-
const available = new Set((await capabilities(actor)).map((capability) => capability.name));
|
|
210
|
-
return names.filter((name) => !available.has(name));
|
|
211
|
-
},
|
|
212
206
|
};
|
|
213
207
|
}
|
|
@@ -53,5 +53,4 @@ export interface ToolService {
|
|
|
53
53
|
servers(actor: ToolActor): Promise<ToolServerCatalog>;
|
|
54
54
|
execute(actor: ToolActor, input: ExecuteToolInput): Promise<ToolTestResult>;
|
|
55
55
|
test(actor: ToolActor, input: TestToolInput): Promise<ToolTestResult>;
|
|
56
|
-
unavailable(actor: ToolActor, names: string[]): Promise<string[]>;
|
|
57
56
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- #492: D1 commits before irreversible R2 deletion. This receipt keeps every object key reachable
|
|
2
|
+
-- when an R2 call fails, and also makes a repeated purge key return the original result.
|
|
3
|
+
CREATE TABLE node_purge_receipts (
|
|
4
|
+
actor_id TEXT NOT NULL,
|
|
5
|
+
idempotency_key TEXT NOT NULL,
|
|
6
|
+
node_id TEXT NOT NULL,
|
|
7
|
+
title TEXT NOT NULL,
|
|
8
|
+
content_keys_json TEXT NOT NULL,
|
|
9
|
+
completed_at TEXT,
|
|
10
|
+
PRIMARY KEY (actor_id, idempotency_key)
|
|
11
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.15.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.18.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|