@anchrd/intel-api 0.23.0 → 0.25.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/cli/cli.js CHANGED
@@ -1,38 +1,81 @@
1
1
  import { createBuild } from "../build/build.js";
2
2
  import { createPrepare } from "../prepare/prepare.js";
3
3
  const interfaces = [
4
- { handle: "intel", functions: ["use", "admin"] },
4
+ // ⚠️ No `use`. Nothing asked it (#475): every door in Intel asks a concrete capability —
5
+ // `nodes:read`, `flows:run`, `mcp:connect` — and a coarse "may use Intel at all" beside them is a
6
+ // permission an operator sets and nothing consults. Worse than useless: somebody granted only
7
+ // `intel:use` gets the empty screen the README warns about and reads it as a defect.
8
+ // `admin` stays and is the opposite case — it is the one way into a tree that carries no grant
9
+ // yet (ADR-0004 §6), read in ten places.
10
+ { handle: "intel", functions: ["admin"] },
5
11
  // ⚠️ `nodes`, not `knowledge` — and the rename cost something, which is why it needed its own
6
12
  // ticket (#152) five months after #125 renamed everything else. This handle is only half Intel's:
7
- // `bootstrap` DECLARES it in Gate and deliberately changes no grant, so to Gate a renamed handle
8
- // is a NEW one. Every grant on the old name keeps pointing at the old name, and everybody loses
9
- // access at once, silently, until an operator hands the permissions out again.
13
+ // `bootstrap` DECLARES it in Gate and touches no grant on an interface it does not name, so to
14
+ // Gate a renamed handle is a NEW one. Every grant on the old name keeps pointing at the old name,
15
+ // and everybody loses access at once, silently, until an operator hands the permissions out again.
10
16
  //
11
17
  // It was done in the one window where that is free: the installation on anchrd.sh was being
12
18
  // rebuilt anyway (#385, anchrd/core#93) and there is no other. Doing it later would mean doing it
13
19
  // to somebody.
14
20
  //
15
- // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares, it never revokes.
16
- // It is a harmless leftover on installations that have it, and nothing in Intel asks about it any
17
- // more; removing it is an act in Gate, by hand, when somebody is sure nothing else uses it.
21
+ // ⚠️ The old `knowledge` interface stays declared in Gate — bootstrap declares only the handles in
22
+ // this list and never revokes a whole INTERFACE. It is a harmless leftover on installations that
23
+ // have it, and nothing in Intel asks about it any more; removing it is an act in Gate, by hand,
24
+ // when somebody is sure nothing else uses it.
18
25
  { handle: "nodes", functions: ["read", "create", "write", "share"] },
19
26
  // ⚠️ No `approve`. The approval node is gone (#73), and this list is what Intel declares to Gate:
20
- // a function nobody asks about is a permission an operator has to decide on for no reason. An
21
- // installation that already granted it keeps a harmless leftover — bootstrap declares, it does
22
- // not revoke.
27
+ // a function nobody asks about is a permission an operator has to decide on for no reason.
28
+ //
29
+ // ⚠️ Dropping a FUNCTION from a still-declared interface is not the harmless leftover the two
30
+ // blocks around this one describe (#474). `gate.set` is `PUT /api/v1/service/interfaces/:handle`,
31
+ // an upsert that REPLACES the function list, and in the same batch Gate runs `obsoleteGrants` —
32
+ // `DELETE FROM grants WHERE interface_id = ? AND function NOT IN (…)`. Gate holds that down with
33
+ // a test of its own (`service-interfaces.int.ts`, "scoped set/remove per App-Key und entfernt
34
+ // Grants gestrichener Funktionen"), and it is right to: catalog and grants are one change against
35
+ // Gate's own reasoning above `obsoleteGrants`. So an installation that granted `approve` does
36
+ // NOT keep it — the next bootstrap deletes every role assignment on it. That is why bootstrap
37
+ // reads the current list before it writes and names what it takes away.
23
38
  {
24
39
  handle: "flows",
25
40
  functions: ["read", "create", "write", "publish", "run", "share"],
26
41
  },
27
- { handle: "tools", functions: ["read", "test", "execute", "admin"] },
28
- // ⚠️ `agents:run` is deliberately NOT here any more (#390). `bootstrap` declares, it never
29
- // revokes, so an installation that has the interface keeps it as a harmless leftover but a new
30
- // one is not asked to decide about a permission nothing reads.
42
+ // ⚠️ No `admin`. Nothing asked it either (#475), and here the reason is architectural rather than
43
+ // an oversight: the Cloudflare MCP Portal owns which servers exist and who may reach each one, and
44
+ // Intel stores no tool permissions at all the catalog is a live `tools/list` with the asking
45
+ // person's token. There is no administration of tools inside Intel for the function to guard, so
46
+ // it cannot grow a reader later without that boundary moving first.
47
+ { handle: "tools", functions: ["read", "test", "execute"] },
48
+ // ⚠️ `agents:run` is deliberately NOT here any more (#390). The whole `agents` handle left the
49
+ // list, and bootstrap writes only the handles it names, so an installation that has that
50
+ // interface keeps it and its grants as a harmless leftover — but a new one is not asked to decide
51
+ // about a permission nothing reads. ⚠️ This holds because a whole INTERFACE went; a single
52
+ // function taken out of a handle that is still listed here is deleted along with its grants, see
53
+ // the block above (#474).
31
54
  // `mcp:connect` means the same thing at every MCP service of this installation. A separate name
32
55
  // for the same thing forces the operator to check, service by service, which permission carries
33
56
  // portal access.
34
57
  { handle: "mcp", functions: ["connect"] },
35
58
  ];
59
+ // What Intel used to declare and no longer does, keyed by handle for a whole interface and by
60
+ // `handle:function` for a single function.
61
+ //
62
+ // ⚠️ It exists because "surplus: approve" is not actionable on its own (#466). An operator who reads
63
+ // only the name has to decide whether somebody meant it, and the cheapest way out of that decision
64
+ // is to leave it standing — which is how `approve` outlived #73 by months. The reason is the half
65
+ // that turns the note into a decision instead of a puzzle, so a new retirement belongs in here in
66
+ // the same pull request that takes its entry out of `interfaces` above.
67
+ const retired = {
68
+ knowledge: "the whole interface became `nodes` (#152, after #125).",
69
+ agents: "the whole interface went when Agents left the surface (#390, #388).",
70
+ "flows:approve": "the approval node is gone (#73).",
71
+ "intel:use": "no door ever asked it; every one of them asks a concrete capability (#475).",
72
+ "tools:admin": "the portal owns tool administration, so Intel never had anything to guard (#475).",
73
+ };
74
+ // An unlisted surplus gets a sentence too. "no reason recorded" is an answer an operator can act on;
75
+ // an empty dash reads as an omission and sends them looking for the missing half.
76
+ function reason(key) {
77
+ return retired[key] ?? "no reason is recorded here, so read the history before removing it.";
78
+ }
36
79
  const usage = `Usage: intel <prepare|bootstrap|build|doctor|reindex>
37
80
  intel prepare Copy versioned D1 migrations to .intel/migrations
38
81
  intel bootstrap Idempotently declare Intel interfaces in Gate
@@ -67,13 +110,41 @@ export function createCli(deps) {
67
110
  return fail("GATE_URL and GATE_SERVICE_KEY must be set.");
68
111
  }
69
112
  const gate = deps.createGateInterfaces(gateUrl, serviceKey);
70
- for (const entry of interfaces)
113
+ // ⚠️ Read before writing, and refuse if the read fails. The current list is the only place the
114
+ // functions Gate is about to lose are named; `set` replaces it, and afterwards nothing can say
115
+ // which grants went with them (#474). A run that writes anyway takes permissions away and has
116
+ // nothing left to report — the old "No grants were changed." in exactly the shape that made it
117
+ // wrong. Bootstrap is idempotent, so refusing costs a second run.
118
+ let current;
119
+ try {
120
+ current = await within(gate.list());
121
+ }
122
+ catch {
123
+ return fail("Gate interfaces could not be read, so bootstrap cannot say which grants it would delete.");
124
+ }
125
+ let removed = 0;
126
+ for (const entry of interfaces) {
127
+ const declared = entry.functions;
128
+ const dropped = (current.find((row) => row.handle === entry.handle)?.functions ?? []).filter((fn) => !declared.includes(fn));
71
129
  await gate.set(entry.handle, [...entry.functions]);
72
- deps.log(`Declared ${interfaces.length} Intel interfaces in Gate. No grants were changed.`);
130
+ // Logged per interface, right after its own write returned, so a run that fails halfway still
131
+ // named every deletion it actually caused.
132
+ if (dropped.length) {
133
+ removed += dropped.length;
134
+ const names = dropped.map((fn) => `${entry.handle}:${fn}`).join(", ");
135
+ deps.log(`Removed ${names} from Gate; every grant on ${dropped.length === 1 ? "it" : "them"} was deleted with it.`);
136
+ }
137
+ }
138
+ deps.log(`Declared ${interfaces.length} Intel interfaces in Gate.`);
139
+ if (!removed)
140
+ deps.log("No declared function was dropped, so Gate deleted no grant.");
73
141
  return 0;
74
142
  }
75
143
  async function doctor() {
76
144
  const failures = [];
145
+ // A surplus hinders nothing, so it is never a `FAIL`: a doctor one knows red is a doctor nobody
146
+ // reads, and then the next real failure goes with it (#466).
147
+ const notes = [];
77
148
  const gateUrl = envValue("GATE_URL");
78
149
  const intelUrl = envValue("INTEL_URL");
79
150
  const serviceKey = envValue("GATE_SERVICE_KEY") ?? envValue("GATE_KEY");
@@ -106,17 +177,44 @@ export function createCli(deps) {
106
177
  if (gateUrl && serviceKey) {
107
178
  try {
108
179
  const current = await within(deps.createGateInterfaces(gateUrl, serviceKey).list());
180
+ let surplusFunction = false;
109
181
  for (const expected of interfaces) {
110
182
  const actual = current.find((entry) => entry.handle === expected.handle);
111
183
  const missing = expected.functions.filter((fn) => !actual?.functions.includes(fn));
112
184
  if (missing.length)
113
185
  failures.push(`Gate interface ${expected.handle} misses ${missing}`);
186
+ const declared = expected.functions;
187
+ for (const fn of actual?.functions ?? []) {
188
+ if (declared.includes(fn))
189
+ continue;
190
+ surplusFunction = true;
191
+ notes.push(`Gate carries ${expected.handle}:${fn}, which Intel does not declare — ${reason(`${expected.handle}:${fn}`)}`);
192
+ }
193
+ }
194
+ // ⚠️ The other direction, and it is the half a loop over `interfaces` can never reach: a
195
+ // handle Intel stopped declaring appears in no expectation, so it is never looked up. The
196
+ // comparison runs both ways or it does not run (#466).
197
+ const declaredHandles = interfaces.map((entry) => entry.handle);
198
+ const surplusHandles = current.filter((row) => !declaredHandles.includes(row.handle));
199
+ for (const row of surplusHandles) {
200
+ notes.push(`Gate carries the interface ${row.handle} (${row.functions.join(", ")}), which Intel does not declare — ${reason(row.handle)}`);
201
+ }
202
+ // What to do about it, once per kind — because the two kinds need opposite acts. A surplus
203
+ // FUNCTION on a still-declared handle disappears by itself on the next `bootstrap`, grants
204
+ // and all (#474); a surplus HANDLE is never written and therefore never goes away on its own.
205
+ if (surplusFunction) {
206
+ notes.push("The next `intel bootstrap` removes a surplus function and every grant on it (#474). Take the grant away in Gate first if anybody should keep it.");
207
+ }
208
+ if (surplusHandles.length) {
209
+ notes.push("`bootstrap` never touches an interface Intel does not declare, so a surplus one stays until somebody removes it in Gate by hand — which takes its grants with it.");
114
210
  }
115
211
  }
116
212
  catch {
117
213
  failures.push("Gate interface check failed");
118
214
  }
119
215
  }
216
+ for (const note of notes)
217
+ deps.log(`NOTE ${note}`);
120
218
  if (failures.length) {
121
219
  for (const failure of failures)
122
220
  deps.log(`FAIL ${failure}`);
@@ -1,4 +1,5 @@
1
1
  import { flowNodeLayer } from "@anchrd/intel-contract/flow";
2
+ import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
2
3
  import { IntelError } from "../shared/intel-error/intel-error.js";
3
4
  import { plainTitle } from "../shared/plain-title/plain-title.js";
4
5
  function invalid(detail) {
@@ -7,6 +8,27 @@ function invalid(detail) {
7
8
  // A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
8
9
  // walk and the run trail, both of which follow data that other writers can change.
9
10
  const MaxCallDepth = 20;
11
+ // All four, and unlike a node this needs no lookup: every verb means something on a flow. `read`
12
+ // opens it, `write` edits it, `execute` runs it, `share` passes access on (#530).
13
+ const flowVerbs = ["read", "write", "execute", "share"];
14
+ /**
15
+ * The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own.
16
+ *
17
+ * ⚠️ Never `isAdmin` and never `canRun`. Whether Gate hands this person `intel/admin` or `flows/run`
18
+ * is Gate's to know, so the warning built on this can be pessimistic and never permissive — it may
19
+ * say "they will not be able to reach this" about somebody who turns out to be able to. The other
20
+ * direction would be a warning that stays silent about a real gap.
21
+ *
22
+ * A grant to an email address is judged as that address, which is what the grant will be attached
23
+ * to; a second grant the same person holds under their user id is not folded in.
24
+ */
25
+ function asPrincipalActor(principal) {
26
+ if (principal.type === "user")
27
+ return { id: principal.id, email: "", canRun: false };
28
+ if (principal.type === "email")
29
+ return { id: "", email: principal.email, canRun: false };
30
+ return { id: "", email: "", canRun: false };
31
+ }
10
32
  // The four link kinds that name something in the shared tree (D25). `tool` is a link too, but it
11
33
  // names a portal tool rather than a resource, and every rule about reachability applies to these
12
34
  // four and not to it.
@@ -91,9 +113,22 @@ function toolStepDetail(missing) {
91
113
  }
92
114
  export function compileFlow(graph) {
93
115
  const nodes = new Map();
116
+ // ⚠️ A refusal names a node the way the canvas does — by its label — because the id appears
117
+ // nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author looking
118
+ // for a string the editor never draws, and the way to the cause led through the contract schema
119
+ // instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP as
120
+ // readily as from the canvas, and there the id is what the caller holds.
121
+ //
122
+ // ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
123
+ // it too — that one names the node the map already holds, which is the FIRST of the two. An id
124
+ // nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
125
+ const named = (id) => {
126
+ const node = nodes.get(id);
127
+ return node ? `"${node.label}" (${id})` : id;
128
+ };
94
129
  for (const node of graph.nodes) {
95
130
  if (nodes.has(node.id))
96
- invalid(`Duplicate node ID: ${node.id}`);
131
+ invalid(`Duplicate node ID: ${named(node.id)}`);
97
132
  nodes.set(node.id, node);
98
133
  }
99
134
  // ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
@@ -116,7 +151,7 @@ export function compileFlow(graph) {
116
151
  // One holder per attached node: two steps claiming the same material would make "what does
117
152
  // this step work with" answerable two ways, and the second answer would never be shown.
118
153
  if (attachedTo.has(edge.target))
119
- invalid(`Node ${edge.target} is already attached to ${attachedTo.get(edge.target)}`);
154
+ invalid(`Node ${named(edge.target)} is already attached to ${named(attachedTo.get(edge.target) ?? "")}`);
120
155
  attachedTo.set(edge.target, edge.source);
121
156
  attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
122
157
  continue;
@@ -147,44 +182,44 @@ export function compileFlow(graph) {
147
182
  // Before this, a link could stand in the chain — which is how a start with an attachment once
148
183
  // began its run at the attachment (#37).
149
184
  if (layer === "link" && holderId === undefined) {
150
- invalid(`Node ${node.id} is a link and must be attached to a step`);
185
+ invalid(`Node ${named(node.id)} is a link and must be attached to a step`);
151
186
  }
152
187
  if (layer !== "link" && holderId !== undefined) {
153
- invalid(`Node ${node.id} is not a link and cannot be attached to another node`);
188
+ invalid(`Node ${named(node.id)} is not a link and cannot be attached to another node`);
154
189
  }
155
190
  if (holderId !== undefined) {
156
191
  if (parents !== 0 || children.length !== 0) {
157
- invalid(`Node ${node.id} is attached as context and cannot also be a step`);
192
+ invalid(`Node ${named(node.id)} is attached as context and cannot also be a step`);
158
193
  }
159
194
  const holder = nodes.get(holderId);
160
195
  // Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
161
196
  // flow it names brings its own links, and lending it one from here would be steering another
162
197
  // flow from outside.
163
198
  if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
164
- invalid(`Node ${node.id} can only be attached to an instruction or a condition`);
199
+ invalid(`Node ${named(node.id)} can only be attached to an instruction or a condition`);
165
200
  }
166
201
  continue;
167
202
  }
168
203
  if (node.kind === "trigger" && parents !== 0)
169
204
  invalid("The trigger cannot have an incoming edge");
170
205
  if (node.kind !== "trigger" && parents !== 1) {
171
- invalid(`Node ${node.id} requires exactly one incoming edge`);
206
+ invalid(`Node ${named(node.id)} requires exactly one incoming edge`);
172
207
  }
173
208
  if (node.kind === "output" && children.length !== 0)
174
- invalid(`Output ${node.id} must be terminal`);
209
+ invalid(`Output ${named(node.id)} must be terminal`);
175
210
  if (node.kind === "condition") {
176
211
  if (children.length < 2)
177
- invalid(`Node ${node.id} requires at least two branches`);
212
+ invalid(`Node ${named(node.id)} requires at least two branches`);
178
213
  const handles = new Set(children.map((edge) => edge.sourceHandle));
179
214
  if (handles.has(null) || handles.size !== children.length) {
180
- invalid(`Node ${node.id} requires unique branch handles`);
215
+ invalid(`Node ${named(node.id)} requires unique branch handles`);
181
216
  }
182
217
  }
183
218
  else if (node.kind !== "output") {
184
219
  if (children.length !== 1)
185
- invalid(`Node ${node.id} requires exactly one outgoing edge`);
220
+ invalid(`Node ${named(node.id)} requires exactly one outgoing edge`);
186
221
  if (children[0]?.sourceHandle !== null)
187
- invalid(`Node ${node.id} cannot define a branch handle`);
222
+ invalid(`Node ${named(node.id)} cannot define a branch handle`);
188
223
  }
189
224
  }
190
225
  const visited = new Set();
@@ -369,6 +404,67 @@ export function createFlows(deps) {
369
404
  throw new IntelError(403, "flow_run_forbidden", "Flow run permission is required");
370
405
  }
371
406
  }
407
+ // Managing a flow's sharing is `share` on the flow — held directly, passed down by a folder above
408
+ // it, or held by owning it. Asked before any grant row is read or written, so a refusal leaves no
409
+ // trace and tells the caller nothing about what grants exist.
410
+ async function requireShareable(actor, flowId) {
411
+ const flow = await requireFlow(actor, flowId);
412
+ if (!(await deps.repository.can(actor, flow.id, "share"))) {
413
+ throw new IntelError(403, "flow_forbidden", "Sharing of this flow cannot be managed");
414
+ }
415
+ return flow;
416
+ }
417
+ /**
418
+ * What this grant does NOT reach, described to whoever just made it (ADR-0004 §4).
419
+ *
420
+ * Two halves, and the second is the one a folder grant never needed. A grant on a folder covered
421
+ * every flow beneath it, so the sub-flows were covered with it; a grant on ONE flow stops at that
422
+ * flow, and the flows it calls are then a separate grant somebody has to make.
423
+ *
424
+ * ⚠️ It is a warning and not a refusal, for the reason the node side gives: blocking would force
425
+ * everyone whose flow reads a central policy document to duplicate it. What makes the warning
426
+ * enough — and what makes this whole feature possible at all — is that `validate` answers the same
427
+ * question later, for the person about to run, at the moment they run (#530).
428
+ *
429
+ * ⚠️ Titles only where the SHARER may see them. Whoever holds `share` on one flow must not learn
430
+ * the titles of documents or flows they cannot reach themselves, so the rest is a number. A
431
+ * warning must not become a way of reading the tree (ADR-0004 §3, #17's review).
432
+ */
433
+ async function withShareWarnings(actor, flow, grant, principal) {
434
+ const grantee = asPrincipalActor(principal);
435
+ // What the published version does is what a grantee will run. A flow with nothing published
436
+ // has nothing to warn about yet, and the draft is not what `execute` would reach.
437
+ const versionId = flow.publishedVersionId;
438
+ const version = versionId ? await deps.repository.getVersion(versionId) : null;
439
+ if (!version) {
440
+ return { grant, unreadable: empty(), unrunnable: empty() };
441
+ }
442
+ const unreadable = empty();
443
+ // Deduplicated: a document two steps both name is one thing the grantee cannot read, not two.
444
+ for (const nodeId of [...new Set(graphReferences(version.graph).nodes)]) {
445
+ if (await deps.visibleNodes(grantee, nodeId))
446
+ continue;
447
+ const node = await deps.visibleNodes(actor, nodeId);
448
+ if (node)
449
+ unreadable.titles.push(node.title);
450
+ else
451
+ unreadable.hidden += 1;
452
+ }
453
+ const unrunnable = empty();
454
+ for (const calleeId of calleeIds(version.graph)) {
455
+ if (await deps.repository.can(grantee, calleeId, "execute"))
456
+ continue;
457
+ const callee = await deps.repository.getVisible(actor, calleeId);
458
+ if (callee)
459
+ unrunnable.titles.push(callee.title);
460
+ else
461
+ unrunnable.hidden += 1;
462
+ }
463
+ return { grant, unreadable, unrunnable };
464
+ }
465
+ function empty() {
466
+ return { titles: [], hidden: 0 };
467
+ }
372
468
  async function requireEdit(actor, flowId) {
373
469
  const flow = await requireFlow(actor, flowId);
374
470
  if (!(await deps.repository.can(actor, flowId, "write"))) {
@@ -1105,6 +1201,7 @@ export function createFlows(deps) {
1105
1201
  flowId: flow.id,
1106
1202
  versionId: null,
1107
1203
  nodes: [],
1204
+ invalidNodes: [],
1108
1205
  hiddenNodes: 0,
1109
1206
  servers: [],
1110
1207
  };
@@ -1112,19 +1209,101 @@ export function createFlows(deps) {
1112
1209
  const version = await requireVersion(versionId, flow.id);
1113
1210
  const referenced = graphReferences(version.graph);
1114
1211
  // ⚠️ The same lookup a tree link passes through, not a second rule written for a list.
1115
- // What it hands back is named; the difference between what was asked for and what came back
1116
- // is a number, because a title is exactly what someone without access may not learn (#17,
1117
- // #19). Tool names are not filtered: they come from a graph this actor may already read, and
1118
- // whether the portal offers them is a question only their own token can answer.
1212
+ // What it hands back is named; a title is exactly what someone without access may not learn
1213
+ // (#17, #19). Tool names are not filtered: they come from a graph this actor may already
1214
+ // read, and whether the portal offers them is a question only their own token can answer.
1119
1215
  const reachable = await reachableNodes(actor, referenced.nodes);
1216
+ const named = new Set(reachable.map((reference) => reference.id));
1217
+ const unreachable = referenced.nodes.filter((resourceId) => !named.has(resourceId));
1218
+ // ⚠️ What is left over used to be ONE number, and the panel read it out as "you cannot see
1219
+ // it" — a sentence about a permission, in front of a link that names nothing at all (#509).
1220
+ // The two suggest opposite actions and only one of them helps: with a permission one asks for
1221
+ // access, with a dead reference the step has to be replaced before the flow can run.
1222
+ //
1223
+ // ⚠️ The second question is asked ONLY about what the visibility door already refused, and it
1224
+ // is asked without an actor because its answer does not depend on one. That is the narrowest
1225
+ // shape this distinction can have — the alternative, a lookup that is kinder than the door it
1226
+ // describes, is what #17 and #19 were sent back for. It is still a disclosure and it is a
1227
+ // deliberate one (#492): for an id the caller wrote into this graph themselves, they learn
1228
+ // whether the tree still holds a row for it.
1229
+ const alive = new Set(await deps.repository.existingNodes(unreachable));
1230
+ const invalidNodes = unreachable.filter((resourceId) => !alive.has(resourceId));
1120
1231
  return {
1121
1232
  flowId: flow.id,
1122
1233
  versionId: version.id,
1123
1234
  nodes: reachable,
1124
- hiddenNodes: referenced.nodes.length - reachable.length,
1235
+ invalidNodes,
1236
+ hiddenNodes: unreachable.length - invalidNodes.length,
1125
1237
  servers: referenced.servers,
1126
1238
  };
1127
1239
  },
1240
+ async listGrants(actor, flowId) {
1241
+ const flow = await requireShareable(actor, flowId);
1242
+ return {
1243
+ resourceId: flow.id,
1244
+ applicableVerbs: flowVerbs,
1245
+ items: await deps.repository.listFlowGrants(flow.id),
1246
+ };
1247
+ },
1248
+ async listEffectiveAccess(actor, flowId) {
1249
+ const flow = await requireShareable(actor, flowId);
1250
+ const effective = await deps.repository.listEffectiveFlowAccess(flow.id);
1251
+ return { resourceId: flow.id, ...effective };
1252
+ },
1253
+ async share(actor, input) {
1254
+ const flow = await requireShareable(actor, input.flowId);
1255
+ const replayedId = await deps.repository.findIdempotent(actor.id, "flows.share", input.idempotencyKey);
1256
+ const principal = input.principal.type === "email"
1257
+ ? { type: "email", email: input.principal.email.toLowerCase() }
1258
+ : input.principal;
1259
+ if (replayedId) {
1260
+ const replayed = (await deps.repository.listFlowGrants(flow.id)).find((grant) => grant.id === replayedId);
1261
+ // A replay describes the same access the first attempt did, so the warning is asked again
1262
+ // rather than remembered: what the grantee can reach may have changed since.
1263
+ if (replayed)
1264
+ return await withShareWarnings(actor, flow, replayed, replayed.principal);
1265
+ }
1266
+ // ⚠️ The same rule as on a node grant, in the same place and for the same two reasons (#442):
1267
+ // in front of `setFlowGrant` so nothing that could never work is written, behind the replay so
1268
+ // a retry with the same key still answers with the grant its first attempt made. The long
1269
+ // form of the argument stands at the node grant in `nodes.ts`.
1270
+ requireFutureExpiry(input.expiresAt, deps.now());
1271
+ const timestamp = deps.now().toISOString();
1272
+ const grant = await deps.repository.setFlowGrant({
1273
+ grant: {
1274
+ id: deps.id(),
1275
+ resourceId: flow.id,
1276
+ principal,
1277
+ verb: input.verb,
1278
+ expiresAt: input.expiresAt,
1279
+ createdBy: actor.id,
1280
+ createdAt: timestamp,
1281
+ },
1282
+ flowId: flow.id,
1283
+ actorId: actor.id,
1284
+ idempotencyKey: input.idempotencyKey,
1285
+ auditId: deps.id(),
1286
+ });
1287
+ // ⚠️ After the grant is written, never before. The answer describes the access now in force,
1288
+ // and a warning computed a moment earlier would still list what this very grant just opened.
1289
+ return await withShareWarnings(actor, flow, grant, principal);
1290
+ },
1291
+ async revokeGrant(actor, input) {
1292
+ const flow = await requireShareable(actor, input.flowId);
1293
+ const replayed = await deps.repository.findIdempotentFlowRevocation(actor.id, input.idempotencyKey);
1294
+ if (replayed !== null)
1295
+ return { revoked: replayed };
1296
+ return {
1297
+ revoked: await deps.repository.revokeFlowGrant({
1298
+ flowId: flow.id,
1299
+ grantId: input.grantId,
1300
+ actorId: actor.id,
1301
+ idempotencyKey: input.idempotencyKey,
1302
+ auditId: deps.id(),
1303
+ occurredAt: deps.now().toISOString(),
1304
+ }),
1305
+ };
1306
+ },
1128
1307
  async create(actor, input) {
1129
1308
  const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
1130
1309
  if (replayed)
@@ -1,7 +1,7 @@
1
1
  import type { ArchiveFlowInput, CreateFlowInput, Flow, FlowDocument, FlowGraph, FlowPublishPreview, FlowRequirements, FlowValidation, FlowVersion, FlowVersionList, FlowVersionSummary, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, RelationGraph, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput } from "@anchrd/intel-contract/flow";
2
2
  import type { CancelFlowRunInput, CompleteFlowRunStepInput, FlowRun, FlowRunHistory, FlowRunList, FlowRunStep, ListFlowRunsInput, StartFlowRunInput } from "@anchrd/intel-contract/flow-run";
3
3
  import type { Node } from "@anchrd/intel-contract/node";
4
- import type { ResourceVerb } from "@anchrd/intel-contract/share";
4
+ import type { ResourceAccessList, ResourceGrant, ResourceGrantList, ResourceVerb, RevokeFlowGrantInput, RevokeGrantResult, ShareFlowInput, ShareResult } from "@anchrd/intel-contract/share";
5
5
  export type FlowPrincipal = Pick<FlowActor, "id" | "email" | "isAdmin">;
6
6
  export type FlowCallReach = "subtree" | "library" | "out-of-reach";
7
7
  export interface FlowRunChainEntry {
@@ -42,8 +42,8 @@ export interface FlowActor {
42
42
  canRun: boolean;
43
43
  isAdmin?: boolean;
44
44
  }
45
- export type FlowVerb = Extract<ResourceVerb, "read" | "write" | "execute">;
46
- export type FlowOperation = "flows.create" | "flows.update" | "flows.archive" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.cancel" | "flows.unpublish";
45
+ export type FlowVerb = ResourceVerb;
46
+ export type FlowOperation = "flows.create" | "flows.update" | "flows.archive" | "flows.save" | "flows.publish" | "flows.run" | "flows.complete" | "flows.cancel" | "flows.unpublish" | "flows.share" | "flows.revoke";
47
47
  export interface BoundedLevel<T> {
48
48
  items: T[];
49
49
  total: number;
@@ -53,6 +53,27 @@ export interface FlowRepository {
53
53
  listVisibleBounded(actor: FlowActor, folderId: string | null, limit: number): Promise<BoundedLevel<Flow>>;
54
54
  getVisible(actor: FlowActor, flowId: string): Promise<Flow | null>;
55
55
  can(actor: FlowActor, flowId: string, verb: FlowVerb): Promise<boolean>;
56
+ listFlowGrants(flowId: string): Promise<ResourceGrant[]>;
57
+ listEffectiveFlowAccess(flowId: string): Promise<{
58
+ ownerIds: string[];
59
+ items: ResourceGrant[];
60
+ }>;
61
+ setFlowGrant(input: {
62
+ grant: ResourceGrant;
63
+ flowId: string;
64
+ actorId: string;
65
+ idempotencyKey: string;
66
+ auditId: string;
67
+ }): Promise<ResourceGrant>;
68
+ revokeFlowGrant(input: {
69
+ flowId: string;
70
+ grantId: string;
71
+ actorId: string;
72
+ idempotencyKey: string;
73
+ auditId: string;
74
+ occurredAt: string;
75
+ }): Promise<boolean>;
76
+ findIdempotentFlowRevocation(actorId: string, idempotencyKey: string): Promise<boolean | null>;
56
77
  getCallable(actor: FlowActor, flowId: string): Promise<Flow | null>;
57
78
  listCallable(actor: FlowActor, flowIds: string[]): Promise<Flow[]>;
58
79
  callReach(callerFolderId: string | null, calleeFolderId: string | null): Promise<FlowCallReach>;
@@ -61,6 +82,21 @@ export interface FlowRepository {
61
82
  hidden: number;
62
83
  }>;
63
84
  nodeReferences(actor: FlowPrincipal, folderId: string): Promise<string[]>;
85
+ /**
86
+ * Which of these ids still have a row in the tree at all — archived or not, for anybody (#509).
87
+ *
88
+ * ⚠️ It takes NO actor, and that is the honest shape rather than an oversight: the answer does
89
+ * not depend on who is asking, so an `actor` parameter would suggest a narrowing that is not
90
+ * there. Its one caller has already asked `visibleNodes` and only reaches this for the ids that
91
+ * came back empty, so what it adds is a single bit about ids the caller sent us out of a graph
92
+ * they may already read.
93
+ *
94
+ * ⚠️ An ARCHIVED node counts as existing. Archiving is reversible — the row is still there and a
95
+ * restore brings the reference back — so calling it invalid would tell an author to replace a
96
+ * step that is about to work again. Only a purge takes the row (`DELETE FROM nodes`, #457), and
97
+ * only that is what "there is nothing here any more" means.
98
+ */
99
+ existingNodes(nodeIds: string[]): Promise<string[]>;
64
100
  publishedCallees(flowId: string): Promise<{
65
101
  title: string;
66
102
  calleeIds: string[];
@@ -207,6 +243,12 @@ export interface FlowDeps {
207
243
  * requirements list names it; and every tree link of every run is authorized by it, nested
208
244
  * calls included (ADR-0004 §4). A second lookup beside it is how a drawing or a message ends up
209
245
  * kinder than the door it describes — which is what #17 and #19 were sent back for.
246
+ *
247
+ * ⚠️ `existingNodes` on the repository is the ONE thing asked beside it, and it is the exception
248
+ * that shows the rule rather than a hole in it: it runs only on ids this door already refused, it
249
+ * takes no actor, and it can only ever make the answer HARSHER — "this names nothing" instead of
250
+ * "you cannot see it". Nothing about a node it does hold reaches the caller, so no drawing and no
251
+ * message becomes kinder. Adding a third would need the same three properties (#492, #509).
210
252
  */
211
253
  visibleNodes(actor: FlowActor, nodeId: string): Promise<Node | null>;
212
254
  toolSurfaceFingerprint(actor: FlowActor, server: string, allow: string[] | null): Promise<string | null>;
@@ -228,6 +270,10 @@ export interface FlowService {
228
270
  validate(actor: FlowActor, flowId: string): Promise<FlowValidation>;
229
271
  relationGraph(actor: FlowActor, input: RelationGraphInput): Promise<RelationGraph>;
230
272
  listRequirements(actor: FlowActor, flowId: string): Promise<FlowRequirements>;
273
+ listGrants(actor: FlowActor, flowId: string): Promise<ResourceGrantList>;
274
+ listEffectiveAccess(actor: FlowActor, flowId: string): Promise<ResourceAccessList>;
275
+ share(actor: FlowActor, input: ShareFlowInput): Promise<ShareResult>;
276
+ revokeGrant(actor: FlowActor, input: RevokeFlowGrantInput): Promise<RevokeGrantResult>;
231
277
  create(actor: FlowActor, input: CreateFlowInput): Promise<Flow>;
232
278
  update(actor: FlowActor, input: UpdateFlowInput): Promise<Flow>;
233
279
  archive(actor: FlowActor, input: ArchiveFlowInput): Promise<Flow>;
package/dist/http/http.js CHANGED
@@ -1,7 +1,7 @@
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
3
  import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, PurgeNodePreviewInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
4
- import { RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
4
+ import { RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, 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";
7
7
  import { Hono } from "hono";
@@ -470,9 +470,33 @@ export function createHttp(deps) {
470
470
  const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
471
471
  return context.json(await deps.flows.listRequirements(asFlowActor(auth), input.flowId));
472
472
  });
473
- // A flow has no grant route of its own. It is shared through the folder it is filed in, under
474
- // /nodes/:nodeId/grants one place answers the question for the documents and the flows in
475
- // that folder alike (ADR-0004 §2).
473
+ // The flow's own sharing (#530), segment for segment the node routes one screen up. `flows/share`
474
+ // was declared to Gate from the beginning and had no consumer until now; these four are it.
475
+ app.get("/flows/:flowId/grants", async (context) => {
476
+ const auth = requireCapability(context, "flows", "share");
477
+ return context.json(await deps.flows.listGrants(asFlowActor(auth), context.req.param("flowId")));
478
+ });
479
+ app.get("/flows/:flowId/effective-access", async (context) => {
480
+ const auth = requireCapability(context, "flows", "share");
481
+ return context.json(await deps.flows.listEffectiveAccess(asFlowActor(auth), context.req.param("flowId")));
482
+ });
483
+ app.post("/flows/:flowId/grants", async (context) => {
484
+ const auth = requireCapability(context, "flows", "share");
485
+ const input = ShareFlowInput.parse(await context.req.json().catch(() => null));
486
+ if (input.flowId !== context.req.param("flowId")) {
487
+ throw new IntelError(400, "flow_id_mismatch", "Path and body flow IDs differ");
488
+ }
489
+ return context.json(await deps.flows.share(asFlowActor(auth), input), 201);
490
+ });
491
+ app.post("/flows/:flowId/grants/:grantId/revoke", async (context) => {
492
+ const auth = requireCapability(context, "flows", "share");
493
+ const input = RevokeFlowGrantInput.parse(await context.req.json().catch(() => null));
494
+ if (input.flowId !== context.req.param("flowId") ||
495
+ input.grantId !== context.req.param("grantId")) {
496
+ throw new IntelError(400, "grant_id_mismatch", "Path and body grant IDs differ");
497
+ }
498
+ return context.json(await deps.flows.revokeGrant(asFlowActor(auth), input));
499
+ });
476
500
  app.post("/flows", async (context) => {
477
501
  const auth = requireCapability(context, "flows", "create");
478
502
  const input = CreateFlowInput.parse(await context.req.json().catch(() => null));