@anchrd/intel-api 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -263,6 +263,21 @@ grants them.
263
263
 
264
264
  A missing capability answers **`403`**, and the screen says a permission is missing.
265
265
 
266
+ ⚠️ **`mcp.connect` is the one that gates a whole endpoint rather than an action.** Intel's own MCP
267
+ endpoint (`POST /mcp`, the one an MCP client or a portal connects to) checks it **on every request**
268
+ and answers `403` without it — before any tool runs and before anything is read. Two things follow
269
+ for an operator:
270
+
271
+ - **Somebody who should use Intel through an MCP client needs `mcp.connect` in Intel**, beside their
272
+ `nodes.*` capabilities. Without it the client authenticates successfully and every call is refused.
273
+ - **Revoking it takes effect on the next call**, with no new sign-in and nothing to press. If a
274
+ Cloudflare Access policy in front of the endpoint also compares the `mcp` claim, that layer is the
275
+ slower of the two — the claim is written once per sign-in and a running session carries it for up
276
+ to 24 hours, while this check reads the live grant rows every time.
277
+
278
+ The web UI is unaffected: it speaks the HTTP surface, which is gated by `nodes.*`, `flows.*` and
279
+ `tools.*` as before.
280
+
266
281
  ### Layer two: the grant on the node
267
282
 
268
283
  What somebody may reach *in this tree*, granted in Intel on a node. Four verbs — `read`, `write`,
@@ -117,6 +117,9 @@ export default {
117
117
  semantic,
118
118
  externalFlowCallers: async (who, folderId) => await flowRepository.externalCallers(who, folderId),
119
119
  flowNodeReferences: async (who, folderId) => await flowRepository.nodeReferences(who, folderId),
120
+ // ⚠️ The real one, like its two neighbours: the refusal is only worth anything if it reads
121
+ // real published graphs and the real visibility predicate (#457).
122
+ flowsUsingNode: async (who, nodeId) => await flowRepository.flowsUsingNode({ ...who, canRun: true }, nodeId),
120
123
  // ⚠️ `canExecute: true` is not a permission being granted here — the servers list touches
121
124
  // nothing and executes nothing. It exists because `ToolActor` carries the flag for the one
122
125
  // method that needs it, and a `false` would read as if listing were gated on execution.
@@ -275,6 +275,120 @@ export function createFlowRepository(deps) {
275
275
  hidden: rows.filter((row) => row.visible !== 1).length,
276
276
  };
277
277
  },
278
+ // Who calls THIS flow — the sibling of `externalCallers`, which asks the same question about a
279
+ // whole folder (#457). Same shape: what the actor may see by name, the rest as a number, because
280
+ // a refusal must not become a way of learning that a flow one cannot see exists.
281
+ async flowCallers(actor, flowId) {
282
+ const result = await deps.db
283
+ .prepare(`${subtreeCte}
284
+ SELECT DISTINCT ${qualifiedFlowColumns("flow")},
285
+ CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
286
+ FROM flows flow
287
+ JOIN flow_versions version ON version.id = flow.published_version_id
288
+ JOIN json_each(version.graph_json, '$.nodes') node
289
+ WHERE json_extract(node.value, '$.kind') = 'subflow'
290
+ AND json_extract(node.value, '$.configuration.flowId') = ?
291
+ AND flow.archived_at IS NULL
292
+ AND flow.id <> ?
293
+ ORDER BY lower(flow.title), flow.id`)
294
+ .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...flowInSubtreeBindings(actor), flowId, flowId)
295
+ .all();
296
+ const rows = result.results ?? [];
297
+ return {
298
+ visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
299
+ hidden: rows.filter((row) => row.visible !== 1).length,
300
+ };
301
+ },
302
+ // Published flows whose graph reads THIS node as a step (#457) — the mirror of `nodeReferences`,
303
+ // which asks the same graph the other way round ("which documents do the flows in this folder
304
+ // read"). Same visible/hidden shape as every other refusal: a title only where the actor may see
305
+ // it, the rest counted, so a refusal never becomes a directory of the tree (ADR-0004 §3).
306
+ async flowsUsingNode(actor, nodeId) {
307
+ const result = await deps.db
308
+ .prepare(`${subtreeCte}
309
+ SELECT DISTINCT ${qualifiedFlowColumns("flow")},
310
+ CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
311
+ FROM flows flow
312
+ JOIN flow_versions version ON version.id = flow.published_version_id
313
+ JOIN json_each(version.graph_json, '$.nodes') node
314
+ WHERE json_extract(node.value, '$.kind') IN (${treeLinkKindList})
315
+ AND json_extract(node.value, '$.configuration.resourceId') = ?
316
+ AND flow.archived_at IS NULL
317
+ ORDER BY lower(flow.title), flow.id`)
318
+ .bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...flowInSubtreeBindings(actor), nodeId)
319
+ .all();
320
+ const rows = result.results ?? [];
321
+ return {
322
+ visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
323
+ hidden: rows.filter((row) => row.visible !== 1).length,
324
+ };
325
+ },
326
+ // ⚠️ BOTH directions, and the second one was missing (found in review). A run of this flow can be
327
+ // the PARENT of somebody else's run — deleting it takes the record of what started theirs. It can
328
+ // just as well be the CHILD of somebody else's run, and deleting that leaves the caller standing
329
+ // on a sub-flow node whose callee is gone: it can never be completed, and migration 0010's stall
330
+ // rule then sweeps the caller to `failed`. A history with a hole is not a history, in either
331
+ // direction.
332
+ async hasEntangledRuns(flowId) {
333
+ const row = await deps.db
334
+ .prepare(`SELECT 1 AS found
335
+ FROM flow_runs mine
336
+ JOIN flow_runs other
337
+ ON other.parent_run_id = mine.id OR other.id = mine.parent_run_id
338
+ WHERE mine.flow_id = ? AND other.flow_id <> ?
339
+ LIMIT 1`)
340
+ .bind(flowId, flowId)
341
+ .first();
342
+ return row !== null;
343
+ },
344
+ // ⚠️ Archiving deliberately leaves runs in flight alone — restoring the flow is what makes them
345
+ // readable again (`flows.ts`). A purge takes that way back with it, so a run that has not
346
+ // finished refuses the deletion rather than being deleted mid-execution.
347
+ async hasUnfinishedRuns(flowId) {
348
+ const row = await deps.db
349
+ .prepare(`SELECT 1 AS found FROM flow_runs
350
+ WHERE flow_id = ? AND status IN ('queued', 'running', 'waiting') LIMIT 1`)
351
+ .bind(flowId)
352
+ .first();
353
+ return row !== null;
354
+ },
355
+ async purgeFlow(input) {
356
+ const aliveFlow = "EXISTS (SELECT 1 FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL)";
357
+ const deleted = await deps.db.batch([
358
+ // ⚠️ FIRST, reading the title out of the row that falls at the end of this batch. Afterwards
359
+ // nothing can look a name up, and an entry carrying only an id names something nobody can
360
+ // find again (`anchrd/gate#146`).
361
+ deps.db
362
+ .prepare(`INSERT INTO audit_events (
363
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
364
+ ) SELECT ?, ?, 'flows.purge', 'flow', f.id, json_object('title', f.title), ?
365
+ FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL`)
366
+ .bind(input.auditId, input.actorId, input.occurredAt, input.flowId),
367
+ // The runs are this flow's history: the steps hang off the runs, the runs off the flow.
368
+ // ⚠️ The guard belongs on EVERY statement, not only the last: a batch whose final statement
369
+ // matches nothing still COMMITS, so a restore in between would empty a living flow's history
370
+ // and answer `409` as if nothing had happened.
371
+ deps.db
372
+ .prepare(`DELETE FROM flow_run_steps
373
+ WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id = ?) AND ${aliveFlow}`)
374
+ .bind(input.flowId, input.flowId),
375
+ deps.db
376
+ .prepare(`DELETE FROM flow_runs WHERE flow_id = ? AND ${aliveFlow}`)
377
+ .bind(input.flowId, input.flowId),
378
+ deps.db
379
+ .prepare(`DELETE FROM flow_versions WHERE flow_id = ? AND ${aliveFlow}`)
380
+ .bind(input.flowId, input.flowId),
381
+ deps.db
382
+ .prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${aliveFlow}`)
383
+ .bind(input.flowId, input.flowId),
384
+ // ⚠️ LAST, and still guarded — a restore between the service's check and this statement
385
+ // would otherwise cost a living flow.
386
+ deps.db
387
+ .prepare("DELETE FROM flows WHERE id = ? AND archived_at IS NOT NULL")
388
+ .bind(input.flowId),
389
+ ]);
390
+ return (deleted.at(-1)?.meta?.changes ?? 0) === 0 ? "missing" : { purged: true };
391
+ },
278
392
  // The documents the flows in this folder's subtree read, so whoever shares the folder
279
393
  // can be told what the grant does not cover (ADR-0004 §4).
280
394
  //
@@ -620,6 +620,106 @@ export function createNodeRepository(deps) {
620
620
  .first();
621
621
  return row ? mapNode(row) : "conflict";
622
622
  },
623
+ async hasAnyChild(nodeId) {
624
+ // ⚠️ Without a visibility predicate and without an archived filter, both on purpose: this
625
+ // answers whether the row can be deleted at all, not what somebody may look at (#457).
626
+ //
627
+ // ⚠️ BOTH tables, and that is not defensive breadth: knowledge and flows share ONE tree
628
+ // (ADR-0004), `flows.parent_id` references `nodes(id)` since migration 0002, and a folder whose
629
+ // only content is a flow answered "empty" while it was not. Purging it left the flow with a
630
+ // parent that no longer exists — invisible in the tree, unreachable, unmovable.
631
+ const row = await deps.db
632
+ .prepare(`SELECT 1 AS found FROM nodes WHERE parent_id = ?
633
+ UNION ALL
634
+ SELECT 1 AS found FROM flows WHERE parent_id = ?
635
+ LIMIT 1`)
636
+ .bind(nodeId, nodeId)
637
+ .first();
638
+ return row !== null;
639
+ },
640
+ // The names this node's vectors carry in the external index. They are read BEFORE the row falls,
641
+ // because `node_vectors` is the only thing that knows them — and once it is gone, an orphan in
642
+ // Vectorize can never be named again (#457).
643
+ async listVectorKeys(nodeId) {
644
+ const rows = await deps.db
645
+ .prepare("SELECT chunk_key FROM node_vectors WHERE node_id = ?")
646
+ .bind(nodeId)
647
+ .all();
648
+ return (rows.results ?? []).map((row) => row.chunk_key);
649
+ },
650
+ async countInboundLinks(nodeId) {
651
+ const row = await deps.db
652
+ .prepare("SELECT COUNT(*) AS n FROM node_links WHERE target_node_id = ?")
653
+ .bind(nodeId)
654
+ .first();
655
+ return row?.n ?? 0;
656
+ },
657
+ async purgeNode(input) {
658
+ // The keys BEFORE anything falls: after the batch there is no version row left to read them
659
+ // from, and an object nobody can name is an object nobody can delete.
660
+ const keys = await deps.db
661
+ .prepare("SELECT content_key FROM node_versions WHERE node_id = ?")
662
+ .bind(input.nodeId)
663
+ .all();
664
+ // ⚠️ The guard belongs on EVERY statement, not only on the last one (found in review). A
665
+ // `D1.batch` is one implicit transaction, but a final statement that changes ZERO rows is not
666
+ // an error — the transaction commits. With the condition only at the end, a restore between
667
+ // the service's check and this batch deleted every version, grant, link and index row of a
668
+ // node that was alive again, left its row standing, and answered `409 update_conflict`: the
669
+ // reader was told nothing had happened while the content was already gone. The audit row
670
+ // carried the same condition, so it was not even booked.
671
+ const alive = "EXISTS (SELECT 1 FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL)";
672
+ const deleted = await deps.db.batch([
673
+ // ⚠️ FIRST, and reading the title out of the row that falls at the end of this batch. Its
674
+ // `WHERE EXISTS` is what makes it honest: no row, no entry — a purge that hit nothing does
675
+ // not book one.
676
+ deps.db
677
+ .prepare(`INSERT INTO audit_events (
678
+ id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
679
+ ) SELECT ?, ?, 'node.purge', 'node', n.id,
680
+ json_patch(?, json_object('title', n.title, 'kind', n.kind)), ?
681
+ FROM nodes n WHERE n.id = ? AND n.archived_at IS NOT NULL`)
682
+ .bind(input.auditId, input.actorId, JSON.stringify(input.metadata), input.occurredAt, input.nodeId),
683
+ // The derived indexes go with their source. ⚠️ A hit on a deleted document is not a blemish
684
+ // but a leak: the title stands in the result and the content is gone.
685
+ deps.db
686
+ .prepare(`DELETE FROM node_fts WHERE node_id = ? AND ${alive}`)
687
+ .bind(input.nodeId, input.nodeId),
688
+ deps.db
689
+ .prepare(`DELETE FROM node_vectors WHERE node_id = ? AND ${alive}`)
690
+ .bind(input.nodeId, input.nodeId),
691
+ deps.db
692
+ .prepare(`DELETE FROM node_index_state
693
+ WHERE version_id IN (SELECT id FROM node_versions WHERE node_id = ?) AND ${alive}`)
694
+ .bind(input.nodeId, input.nodeId),
695
+ // Both directions. Outgoing links belong to this node; incoming ones would otherwise point
696
+ // at a row that is gone, and a foreign key would refuse the delete anyway.
697
+ deps.db
698
+ .prepare(`DELETE FROM node_links
699
+ WHERE (source_node_id = ? OR target_node_id = ?) AND ${alive}`)
700
+ .bind(input.nodeId, input.nodeId, input.nodeId),
701
+ // A permission on a node that does not exist is a dangling permission.
702
+ deps.db
703
+ .prepare(`DELETE FROM node_grants WHERE node_id = ? AND ${alive}`)
704
+ .bind(input.nodeId, input.nodeId),
705
+ deps.db
706
+ .prepare(`DELETE FROM node_versions WHERE node_id = ? AND ${alive}`)
707
+ .bind(input.nodeId, input.nodeId),
708
+ // ⚠️ The replay keys of every earlier write to this node. Left behind, a later write reusing
709
+ // one of them would be answered with a resource id that no longer resolves.
710
+ deps.db
711
+ .prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${alive}`)
712
+ .bind(input.nodeId, input.nodeId),
713
+ // LAST — and its count is what the caller reads as the verdict.
714
+ deps.db
715
+ .prepare("DELETE FROM nodes WHERE id = ? AND archived_at IS NOT NULL")
716
+ .bind(input.nodeId),
717
+ ]);
718
+ const removed = deleted.at(-1)?.meta?.changes ?? 0;
719
+ if (removed === 0)
720
+ return "missing";
721
+ return { contentKeys: [...new Set((keys.results ?? []).map((row) => row.content_key))] };
722
+ },
623
723
  async appendVersion(input) {
624
724
  const version = input.version;
625
725
  await deps.db.batch([
@@ -270,6 +270,16 @@ function decodeCursor(cursor) {
270
270
  throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
271
271
  return { createdAt, id };
272
272
  }
273
+ // ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
274
+ // refusal follows (`callersDetail` in nodes.ts): a refusal must not become a way of learning that a
275
+ // flow one cannot see exists (ADR-0004 §3).
276
+ function callersDetail(callers) {
277
+ const named = callers.visible.map((title) => `“${title}”`).join(", ");
278
+ const rest = callers.hidden === 0
279
+ ? ""
280
+ : `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
281
+ return `Published flows still call this one: ${named}${rest}. Change or unpublish them first.`;
282
+ }
273
283
  export function createFlows(deps) {
274
284
  async function requireFlow(actor, flowId) {
275
285
  const flow = await deps.repository.getVisible(actor, flowId);
@@ -1146,6 +1156,48 @@ export function createFlows(deps) {
1146
1156
  }
1147
1157
  return updated;
1148
1158
  },
1159
+ /**
1160
+ * ⚠️ The flow half of #457 — after this a flow is really gone, with its versions and the record
1161
+ * of every run it ever had. Read the decisions on that issue before touching it.
1162
+ */
1163
+ async purge(actor, input) {
1164
+ const current = await deps.repository.getVisible(actor, input.flowId);
1165
+ if (!current)
1166
+ throw new IntelError(404, "flow_not_found", "Flow was not found");
1167
+ if (!(await deps.repository.can(actor, input.flowId, "write"))) {
1168
+ throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
1169
+ }
1170
+ // ⚠️ THE structural guard, same as the node's: a living flow has no path into nothing.
1171
+ if (!current.archivedAt) {
1172
+ throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
1173
+ }
1174
+ // A published caller would break at RUN TIME, in front of somebody who did not order this.
1175
+ const callers = await deps.repository.flowCallers(actor, current.id);
1176
+ if (callers.visible.length || callers.hidden) {
1177
+ throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers));
1178
+ }
1179
+ // ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
1180
+ // belonging to other flows. Deleting them would take the record of who started those runs
1181
+ // with it — a history with a hole is not a history, so this refuses instead.
1182
+ if (await deps.repository.hasEntangledRuns(current.id)) {
1183
+ throw new IntelError(409, "flow_runs_entangled", "Runs of this flow are tied to runs of other flows. Deleting them would leave those without the record of what started them, or without the run they are waiting for.");
1184
+ }
1185
+ // ⚠️ Archiving deliberately leaves a run in flight alone, because restoring the flow makes it
1186
+ // readable again. A purge takes that way back with it — so an unfinished run refuses.
1187
+ if (await deps.repository.hasUnfinishedRuns(current.id)) {
1188
+ throw new IntelError(409, "flow_runs_unfinished", "This flow still has runs that have not finished. Wait for them or cancel them first.");
1189
+ }
1190
+ const purged = await deps.repository.purgeFlow({
1191
+ flowId: current.id,
1192
+ actorId: actor.id,
1193
+ auditId: deps.id(),
1194
+ occurredAt: deps.now().toISOString(),
1195
+ });
1196
+ if (purged === "missing") {
1197
+ throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
1198
+ }
1199
+ return { purged: true, title: current.title };
1200
+ },
1149
1201
  /**
1150
1202
  * Archive a flow, or take it back out again.
1151
1203
  *
@@ -88,6 +88,24 @@ export interface FlowRepository {
88
88
  idempotencyKey: string;
89
89
  auditId: string;
90
90
  }): Promise<"conflict" | Flow>;
91
+ flowsUsingNode(actor: FlowActor, nodeId: string): Promise<{
92
+ visible: string[];
93
+ hidden: number;
94
+ }>;
95
+ flowCallers(actor: FlowActor, flowId: string): Promise<{
96
+ visible: string[];
97
+ hidden: number;
98
+ }>;
99
+ hasEntangledRuns(flowId: string): Promise<boolean>;
100
+ hasUnfinishedRuns(flowId: string): Promise<boolean>;
101
+ purgeFlow(input: {
102
+ flowId: string;
103
+ actorId: string;
104
+ auditId: string;
105
+ occurredAt: string;
106
+ }): Promise<"missing" | {
107
+ purged: true;
108
+ }>;
91
109
  getVersion(versionId: string): Promise<FlowVersion | null>;
92
110
  listVersions(flowId: string): Promise<Omit<FlowVersionSummary, "published">[]>;
93
111
  getVersions(versionIds: string[]): Promise<FlowVersion[]>;
@@ -213,6 +231,12 @@ export interface FlowService {
213
231
  create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
214
232
  update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
215
233
  archive(actor: FlowActor, input: ArchiveFlowInput): Promise<Flow>;
234
+ purge(actor: FlowActor, input: {
235
+ flowId: string;
236
+ }): Promise<{
237
+ purged: true;
238
+ title: string;
239
+ }>;
216
240
  save(actor: FlowActor, input: SaveFlowVersionInput): Promise<FlowDocument>;
217
241
  previewPublish(actor: FlowActor, input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
218
242
  publish(actor: FlowActor, input: PublishFlowInput): Promise<Flow>;
package/dist/http/http.js CHANGED
@@ -1,6 +1,6 @@
1
- import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
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, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
3
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, 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";
@@ -360,6 +360,15 @@ export function createHttp(deps) {
360
360
  // Application behind an agent (#182). Every other kind never reaches it.
361
361
  return context.json(await deps.nodes.archive(asActor(auth), input));
362
362
  });
363
+ // ⚠️ DELETE, and it means it (#457). Every other "delete" in this surface is `archived_at`; this
364
+ // one leaves nothing behind. The same capability as archiving — `nodes.write` — because the
365
+ // structural guard is elsewhere: only an archived node can be purged, so the destructive way has
366
+ // two steps and the first one is reversible.
367
+ app.delete("/nodes/:nodeId", async (context) => {
368
+ const auth = requireCapability(context, "nodes", "write");
369
+ const input = PurgeNodeInput.parse({ nodeId: context.req.param("nodeId") });
370
+ return context.json(await deps.nodes.purge(asActor(auth), input));
371
+ });
363
372
  app.get("/nodes/:nodeId/grants", async (context) => {
364
373
  const auth = requireCapability(context, "nodes", "share");
365
374
  return context.json(await deps.nodes.listGrants(asActor(auth), context.req.param("nodeId")));
@@ -475,6 +484,12 @@ export function createHttp(deps) {
475
484
  }
476
485
  return context.json(await deps.flows.archive(asFlowActor(auth), input));
477
486
  });
487
+ // ⚠️ DELETE, and it means it (#457) — the flow's versions and the record of every run it had.
488
+ app.delete("/flows/:flowId", async (context) => {
489
+ const auth = requireCapability(context, "flows", "write");
490
+ const input = PurgeFlowInput.parse({ flowId: context.req.param("flowId") });
491
+ return context.json(await deps.flows.purge(asFlowActor(auth), input));
492
+ });
478
493
  app.post("/flows/:flowId/versions", async (context) => {
479
494
  const auth = requireCapability(context, "flows", "write");
480
495
  const input = SaveFlowVersionInput.parse(await context.req.json().catch(() => null));
@@ -116,6 +116,32 @@ export function createIntel(deps) {
116
116
  context.header("WWW-Authenticate", `Bearer resource_metadata="${resourceMetadataUrl}"`);
117
117
  return context.json({ jsonrpc: "2.0", id: null, error: { code: -32003, message: "authentication required" } }, 401);
118
118
  }
119
+ // ⚠️ Intel's OWN portal door, asked per request — and it is deliberately the service-trimmed
120
+ // `can`, the exact spelling the guard on `/auth/connect` above must never use (anchrd/gate#293).
121
+ // The two questions are different and both are needed:
122
+ //
123
+ // /auth/connect "is anything in the SHARED portal for this person" mcpPortalRefused()
124
+ // /mcp "may they reach THIS server" can("mcp", "connect")
125
+ //
126
+ // Until now only Cloudflare Access asked the second one, comparing the `mcp` claim of the Access
127
+ // application against the service name. That claim is collected when the person signs in and a
128
+ // running Access session carries it for up to 24 hours, so a revoked grant kept working for a
129
+ // day while `rules` — the same rows, read live on every call — already said no. This line moves
130
+ // the decision to where the answer is: a revocation lands on the NEXT call, with no new sign-in,
131
+ // no session revocation and nobody pressing anything. It also lets the Access policy go coarse
132
+ // ("is a Gate user") without opening the server to everyone.
133
+ //
134
+ // ⚠️ NOT `permits(...)`: `intel.admin` is the bootstrap escape for Intel's own resource ACLs
135
+ // (ADR-0004 §6), and there is no bootstrap here — this grant is set in Gate, not in Intel, so no
136
+ // admin has to be able to hand it to themselves through this door. Widening it would let people
137
+ // in whom the Access policy turns away today, which is the opposite of moving a check.
138
+ //
139
+ // ⚠️ Fails CLOSED, and that is the whole reason a `403` and not a `401`: the caller IS
140
+ // authenticated and merely may not, so a `WWW-Authenticate` challenge would send an MCP client
141
+ // round the OAuth loop it just completed — the certain dead end of #434 in another costume.
142
+ if (!authorization.can("mcp", "connect")) {
143
+ return context.json({ jsonrpc: "2.0", id: null, error: { code: -32003, message: "permission required" } }, 403);
144
+ }
119
145
  return await handleMcp(context.req.raw, {
120
146
  authorization,
121
147
  // ⚠️ The very token this call was authorized with, so a tool that has to speak to Gate does
package/dist/mcp/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
2
- import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
2
+ import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
3
3
  import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
4
- import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
4
+ import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
5
5
  import { ListGrantsInput, RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
6
6
  import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
7
7
  import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
@@ -464,6 +464,26 @@ export async function handleMcp(request, deps) {
464
464
  openWorldHint: false,
465
465
  },
466
466
  }, async (input) => text(await deps.nodes.archive(actor, input)));
467
+ // ⚠️ It exists on this surface for the reason #436 states: a way only the interface has is a way
468
+ // an agent goes around, by looking for the row in the database. What it may do is bounded by the
469
+ // same rules the interface obeys — only an archived node, only an empty folder, never one a flow
470
+ // still uses.
471
+ server.registerTool("node_purge", {
472
+ title: "Delete node for good",
473
+ description: "Delete one ARCHIVED node and everything belonging to it — every version, its content, its grants and its entries in the search index. This cannot be undone. A living node is refused: archive it first. A folder is refused while it still holds anything, and so is a node a published flow uses as a step; documents that merely LINK to it are not, and their links break.",
474
+ inputSchema: PurgeNodeInput,
475
+ annotations: {
476
+ title: "Delete node for good",
477
+ readOnlyHint: false,
478
+ destructiveHint: true,
479
+ // ⚠️ NOT idempotent, and that is the honest answer rather than a missing flag: calling it
480
+ // twice deletes once and then answers 404. A client that repeated it on a timeout would
481
+ // otherwise read the second answer as "already done" when it may mean "somebody else's
482
+ // node with that id is gone".
483
+ idempotentHint: false,
484
+ openWorldHint: false,
485
+ },
486
+ }, async (input) => text(await deps.nodes.purge(actor, input)));
467
487
  }
468
488
  if (permits(deps.authorization, "nodes", "share")) {
469
489
  server.registerTool("node_grant_list", {
@@ -689,6 +709,21 @@ export async function handleMcp(request, deps) {
689
709
  openWorldHint: false,
690
710
  },
691
711
  }, async (input) => text(await deps.flows.archive(flowActor, input)));
712
+ // ⚠️ On this surface because a way only the UI has is a way an agent works around (#436). Its
713
+ // limits are the same as the UI's.
714
+ server.registerTool("flow_purge", {
715
+ title: "Delete flow for good",
716
+ description: "Delete one ARCHIVED flow together with every version and the record of every run it had. This cannot be undone. A living flow is refused: archive it first. So is a flow another published flow still calls, and one whose runs started runs of other flows.",
717
+ inputSchema: PurgeFlowInput,
718
+ annotations: {
719
+ title: "Delete flow for good",
720
+ readOnlyHint: false,
721
+ destructiveHint: true,
722
+ // Not idempotent, and that is the honest answer: twice deletes once and then answers 404.
723
+ idempotentHint: false,
724
+ openWorldHint: false,
725
+ },
726
+ }, async (input) => text(await deps.flows.purge(flowActor, input)));
692
727
  server.registerTool("flow_version_create", {
693
728
  title: "Create flow version",
694
729
  description: "Append a validated immutable flow graph version with optimistic concurrency.",
@@ -849,6 +849,87 @@ export function createNodes(deps) {
849
849
  await deps.indexing.enqueue(updated.currentVersionId);
850
850
  return updated;
851
851
  },
852
+ /**
853
+ * ⚠️ The one call in Intel after which something is really gone (#457). Read the decisions on
854
+ * that issue before changing anything here: each refusal below is one of them, and each was
855
+ * chosen to the safer side.
856
+ */
857
+ async purge(actor, input) {
858
+ const node = await requireVisible(actor, input.nodeId);
859
+ if (!(await deps.repository.can(actor, input.nodeId, "write"))) {
860
+ throw new IntelError(403, "node_forbidden", "This node cannot be edited");
861
+ }
862
+ // ⚠️ THE structural guard: only what is already archived may go. A stronger PERMISSION would
863
+ // not have made this safer, only rarer — whoever may archive can already make the content
864
+ // invisible. What makes it safe is that the destructive path has two steps and the first one
865
+ // is reversible.
866
+ if (!node.archivedAt) {
867
+ throw new IntelError(409, "node_not_archived", "Only an archived node can be deleted for good. Archive it first.");
868
+ }
869
+ // A folder is refused while anything is still inside — archived rows included. A cascade
870
+ // would turn ONE confirmed click into an unknown number of deletions, and the confirmation
871
+ // could then no longer name what disappears. `folder_execute_in_use` (#448) is the pattern:
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
+ }
888
+ // 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. The
890
+ // surface names the number BEFORE the reader says yes.
891
+ const inboundLinks = await deps.repository.countInboundLinks(node.id);
892
+ // ⚠️ The vectors go FIRST, and the order is the correction of a wrong claim (found in review).
893
+ // The comment here used to say Vectorize needed no call because archiving had already emptied
894
+ // it. Archiving only ENQUEUES the purge, and that pass starts from `archivedNodeId(versionId)`
895
+ // — a join over `nodes`. Once this batch has run, that answers `null` and removes nothing,
896
+ // while `node_vectors`, the only record of what the vectors are CALLED, is gone with it. The
897
+ // orphans would then be unnameable forever, and each one keeps a place in the 100-candidate
898
+ // list Vectorize caps per installation.
899
+ //
900
+ // ⚠️ Before the batch rather than after, which inverts the order the content keys follow — and
901
+ // the rule behind both is the same: DO THE IRRECOVERABLE THING LAST. A failed batch after the
902
+ // vectors are gone leaves an ARCHIVED node whose index can be rebuilt (`reindex`); a failed
903
+ // removal after the batch leaves orphans nothing can name.
904
+ const vectorKeys = await deps.repository.listVectorKeys(node.id);
905
+ // The whole-node chunk carries the empty key and is never in the table — the pass writes it
906
+ // and records the rest, so a purge that only sent the recorded ones would leave it behind.
907
+ if (deps.semantic)
908
+ await deps.semantic.remove(node.id, ["", ...vectorKeys]);
909
+ const purged = await deps.repository.purgeNode({
910
+ nodeId: node.id,
911
+ actorId: actor.id,
912
+ auditId: deps.id(),
913
+ occurredAt: deps.now().toISOString(),
914
+ // ⚠️ The title travels INTO the record from the row itself (the repository reads it there),
915
+ // and this is what travels beside it. After the delete nothing can look either up.
916
+ metadata: { inboundLinks },
917
+ });
918
+ // The row was restored or already gone between the check and the statement. Nothing was
919
+ // deleted, and saying so is better than reporting a success that did not happen.
920
+ if (purged === "missing") {
921
+ throw new IntelError(409, "update_conflict", "This node was changed by another editor");
922
+ }
923
+ // ⚠️ 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
+ // ⚠️ Vectorize needs no call here, and that follows from the guard above rather than from
927
+ // luck: archiving already takes a node's vectors out of the index (see `archive`), and only
928
+ // an archived node reaches this line. What is emptied above is Intel's own bookkeeping.
929
+ for (const key of purged.contentKeys)
930
+ await deps.content.delete(key);
931
+ return { purged: true, title: node.title };
932
+ },
852
933
  async listGrants(actor, resourceId) {
853
934
  const node = await requireVisible(actor, resourceId);
854
935
  if (!(await deps.repository.can(actor, resourceId, "share"))) {
@@ -84,6 +84,18 @@ export interface NodeRepository {
84
84
  idempotencyKey: string;
85
85
  auditId: string;
86
86
  }): Promise<"conflict" | Node>;
87
+ hasAnyChild(nodeId: string): Promise<boolean>;
88
+ listVectorKeys(nodeId: string): Promise<string[]>;
89
+ countInboundLinks(nodeId: string): Promise<number>;
90
+ purgeNode(input: {
91
+ nodeId: string;
92
+ actorId: string;
93
+ auditId: string;
94
+ occurredAt: string;
95
+ metadata: Record<string, unknown>;
96
+ }): Promise<"missing" | {
97
+ contentKeys: string[];
98
+ }>;
87
99
  listGrants(resourceId: string): Promise<ResourceGrant[]>;
88
100
  organizationExecuteReaches(nodeId: string, exceptGrantId: string): Promise<boolean>;
89
101
  listLinksVisible(actor: Actor, nodeId: string): Promise<NodeLink[]>;
@@ -189,6 +201,19 @@ export interface NodesDeps {
189
201
  hidden: number;
190
202
  }>;
191
203
  flowNodeReferences(actor: Actor, folderId: string): Promise<string[]>;
204
+ /**
205
+ * Published flows that use THIS node as a step (#457).
206
+ *
207
+ * ⚠️ Not the same question as `externalFlowCallers`, and the difference cost a refusal that never
208
+ * fired: that one asks who calls a FLOW filed inside a folder (`kind: "subflow"`,
209
+ * `configuration.flowId`). A flow reads a NODE through a tree-link step (`configuration
210
+ * .resourceId`) — a different graph shape entirely, and the one a deletion breaks at run time, in
211
+ * front of somebody who did not order it.
212
+ */
213
+ flowsUsingNode(actor: Actor, nodeId: string): Promise<{
214
+ visible: string[];
215
+ hidden: number;
216
+ }>;
192
217
  /**
193
218
  * The handles of the MCP servers this actor reaches through the portal right now (D30).
194
219
  *
@@ -260,6 +285,12 @@ export interface NodeService {
260
285
  * is off, which stops working loudly and is repaired by repeating the call.
261
286
  */
262
287
  archive(actor: Actor, input: ArchiveNodeInput): Promise<Node>;
288
+ purge(actor: Actor, input: {
289
+ nodeId: string;
290
+ }): Promise<{
291
+ purged: true;
292
+ title: string;
293
+ }>;
263
294
  listGrants(actor: Actor, resourceId: string): Promise<ResourceGrantList>;
264
295
  listLinks(actor: Actor, nodeId: string): Promise<{
265
296
  items: NodeLink[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.18.0",
3
+ "version": "0.20.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.15.0",
46
+ "@anchrd/intel-contract": "^0.16.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",