@anchrd/intel-api 0.24.0 → 0.26.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 +37 -7
- package/dist/adapters/db/db-flows.js +16 -0
- package/dist/auth/auth.js +46 -19
- package/dist/build/build.js +39 -0
- package/dist/bundle/bundle.js +165 -24
- package/dist/cli/cli.js +114 -16
- package/dist/flows/flows.js +85 -28
- package/dist/flows/flows.types.d.ts +21 -0
- package/dist/http/http.js +24 -4
- package/dist/intel/intel.js +31 -3
- package/dist/mcp/mcp.js +117 -4
- package/dist/nodes/nodes.js +25 -7
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +18 -1
- package/dist/shared/intel-error/intel-error.js +18 -1
- package/dist/shared/problem-details/problem-details.d.ts +22 -0
- package/dist/shared/problem-details/problem-details.js +14 -0
- package/package.json +3 -3
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
|
-
|
|
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
|
|
8
|
-
// is a NEW one. Every grant on the old name keeps pointing at the old name,
|
|
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
|
|
16
|
-
// It is a harmless leftover on installations that
|
|
17
|
-
// more; removing it is an act in Gate, by hand,
|
|
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.
|
|
21
|
-
//
|
|
22
|
-
// not
|
|
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
|
-
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/dist/flows/flows.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { flowNodeLayer } from "@anchrd/intel-contract/flow";
|
|
2
|
+
import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
|
|
3
|
+
import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
|
|
2
4
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
5
|
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
4
6
|
function invalid(detail) {
|
|
@@ -110,11 +112,32 @@ function toolStepDetail(missing) {
|
|
|
110
112
|
? `You do not reach this tool server in the portal: ${missing.join(", ")}`
|
|
111
113
|
: `You do not reach these tool servers in the portal: ${missing.join(", ")}`;
|
|
112
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* ⚠️ How every refusal on this file names a node: by the label the canvas draws, because the id
|
|
117
|
+
* appears nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author
|
|
118
|
+
* looking for a string the editor never draws, and the way to the cause led through the contract
|
|
119
|
+
* schema instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP
|
|
120
|
+
* as readily as from the canvas, and there the id is what the caller holds.
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ One function rather than one spelling per call site (#551): the publish-time refusals name a
|
|
123
|
+
* node too, and two places building the same string by hand is how the two halves of one sentence
|
|
124
|
+
* drift while both keep looking right.
|
|
125
|
+
*/
|
|
126
|
+
function nodeName(node) {
|
|
127
|
+
return `"${node.label}" (${node.id})`;
|
|
128
|
+
}
|
|
113
129
|
export function compileFlow(graph) {
|
|
114
130
|
const nodes = new Map();
|
|
131
|
+
// ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
|
|
132
|
+
// it too — that one names the node the map already holds, which is the FIRST of the two. An id
|
|
133
|
+
// nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
|
|
134
|
+
const named = (id) => {
|
|
135
|
+
const node = nodes.get(id);
|
|
136
|
+
return node ? nodeName(node) : id;
|
|
137
|
+
};
|
|
115
138
|
for (const node of graph.nodes) {
|
|
116
139
|
if (nodes.has(node.id))
|
|
117
|
-
invalid(`Duplicate node ID: ${node.id}`);
|
|
140
|
+
invalid(`Duplicate node ID: ${named(node.id)}`);
|
|
118
141
|
nodes.set(node.id, node);
|
|
119
142
|
}
|
|
120
143
|
// ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
|
|
@@ -137,7 +160,7 @@ export function compileFlow(graph) {
|
|
|
137
160
|
// One holder per attached node: two steps claiming the same material would make "what does
|
|
138
161
|
// this step work with" answerable two ways, and the second answer would never be shown.
|
|
139
162
|
if (attachedTo.has(edge.target))
|
|
140
|
-
invalid(`Node ${edge.target} is already attached to ${attachedTo.get(edge.target)}`);
|
|
163
|
+
invalid(`Node ${named(edge.target)} is already attached to ${named(attachedTo.get(edge.target) ?? "")}`);
|
|
141
164
|
attachedTo.set(edge.target, edge.source);
|
|
142
165
|
attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
|
|
143
166
|
continue;
|
|
@@ -168,44 +191,44 @@ export function compileFlow(graph) {
|
|
|
168
191
|
// Before this, a link could stand in the chain — which is how a start with an attachment once
|
|
169
192
|
// began its run at the attachment (#37).
|
|
170
193
|
if (layer === "link" && holderId === undefined) {
|
|
171
|
-
invalid(`Node ${node.id} is a link and must be attached to a step`);
|
|
194
|
+
invalid(`Node ${named(node.id)} is a link and must be attached to a step`);
|
|
172
195
|
}
|
|
173
196
|
if (layer !== "link" && holderId !== undefined) {
|
|
174
|
-
invalid(`Node ${node.id} is not a link and cannot be attached to another node`);
|
|
197
|
+
invalid(`Node ${named(node.id)} is not a link and cannot be attached to another node`);
|
|
175
198
|
}
|
|
176
199
|
if (holderId !== undefined) {
|
|
177
200
|
if (parents !== 0 || children.length !== 0) {
|
|
178
|
-
invalid(`Node ${node.id} is attached as context and cannot also be a step`);
|
|
201
|
+
invalid(`Node ${named(node.id)} is attached as context and cannot also be a step`);
|
|
179
202
|
}
|
|
180
203
|
const holder = nodes.get(holderId);
|
|
181
204
|
// Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
|
|
182
205
|
// flow it names brings its own links, and lending it one from here would be steering another
|
|
183
206
|
// flow from outside.
|
|
184
207
|
if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
|
|
185
|
-
invalid(`Node ${node.id} can only be attached to an instruction or a condition`);
|
|
208
|
+
invalid(`Node ${named(node.id)} can only be attached to an instruction or a condition`);
|
|
186
209
|
}
|
|
187
210
|
continue;
|
|
188
211
|
}
|
|
189
212
|
if (node.kind === "trigger" && parents !== 0)
|
|
190
213
|
invalid("The trigger cannot have an incoming edge");
|
|
191
214
|
if (node.kind !== "trigger" && parents !== 1) {
|
|
192
|
-
invalid(`Node ${node.id} requires exactly one incoming edge`);
|
|
215
|
+
invalid(`Node ${named(node.id)} requires exactly one incoming edge`);
|
|
193
216
|
}
|
|
194
217
|
if (node.kind === "output" && children.length !== 0)
|
|
195
|
-
invalid(`Output ${node.id} must be terminal`);
|
|
218
|
+
invalid(`Output ${named(node.id)} must be terminal`);
|
|
196
219
|
if (node.kind === "condition") {
|
|
197
220
|
if (children.length < 2)
|
|
198
|
-
invalid(`Node ${node.id} requires at least two branches`);
|
|
221
|
+
invalid(`Node ${named(node.id)} requires at least two branches`);
|
|
199
222
|
const handles = new Set(children.map((edge) => edge.sourceHandle));
|
|
200
223
|
if (handles.has(null) || handles.size !== children.length) {
|
|
201
|
-
invalid(`Node ${node.id} requires unique branch handles`);
|
|
224
|
+
invalid(`Node ${named(node.id)} requires unique branch handles`);
|
|
202
225
|
}
|
|
203
226
|
}
|
|
204
227
|
else if (node.kind !== "output") {
|
|
205
228
|
if (children.length !== 1)
|
|
206
|
-
invalid(`Node ${node.id} requires exactly one outgoing edge`);
|
|
229
|
+
invalid(`Node ${named(node.id)} requires exactly one outgoing edge`);
|
|
207
230
|
if (children[0]?.sourceHandle !== null)
|
|
208
|
-
invalid(`Node ${node.id} cannot define a branch handle`);
|
|
231
|
+
invalid(`Node ${named(node.id)} cannot define a branch handle`);
|
|
209
232
|
}
|
|
210
233
|
}
|
|
211
234
|
const visited = new Set();
|
|
@@ -300,14 +323,14 @@ function decodeCursor(cursor) {
|
|
|
300
323
|
return { createdAt, id };
|
|
301
324
|
}
|
|
302
325
|
// ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
|
|
303
|
-
// refusal follows (`
|
|
304
|
-
// flow one cannot see exists (ADR-0004 §3).
|
|
326
|
+
// refusal follows, out of the same place (`callersPhrase`): a refusal must not become a way of
|
|
327
|
+
// learning that a flow one cannot see exists (ADR-0004 §3).
|
|
328
|
+
//
|
|
329
|
+
// ⚠️ The SENTENCE stays its own, and that is deliberate (#593): the folder's says a grant cannot be
|
|
330
|
+
// narrowed yet, this one says a flow cannot be deleted for good. Same facts, different consequence
|
|
331
|
+
// — a single parameterised sentence would save a string and cost each refusal its reason.
|
|
305
332
|
function callersDetail(callers) {
|
|
306
|
-
|
|
307
|
-
const rest = callers.hidden === 0
|
|
308
|
-
? ""
|
|
309
|
-
: `${named ? " and " : ""}${callers.hidden} more flow${callers.hidden === 1 ? "" : "s"} you cannot see`;
|
|
310
|
-
return `Published flows still call this one: ${named}${rest}. Change or unpublish them first.`;
|
|
333
|
+
return `Published flows still call this one: ${callersPhrase(callers)}. Change or unpublish them first.`;
|
|
311
334
|
}
|
|
312
335
|
export function createFlows(deps) {
|
|
313
336
|
async function requireFlow(actor, flowId) {
|
|
@@ -1187,6 +1210,7 @@ export function createFlows(deps) {
|
|
|
1187
1210
|
flowId: flow.id,
|
|
1188
1211
|
versionId: null,
|
|
1189
1212
|
nodes: [],
|
|
1213
|
+
invalidNodes: [],
|
|
1190
1214
|
hiddenNodes: 0,
|
|
1191
1215
|
servers: [],
|
|
1192
1216
|
};
|
|
@@ -1194,16 +1218,31 @@ export function createFlows(deps) {
|
|
|
1194
1218
|
const version = await requireVersion(versionId, flow.id);
|
|
1195
1219
|
const referenced = graphReferences(version.graph);
|
|
1196
1220
|
// ⚠️ The same lookup a tree link passes through, not a second rule written for a list.
|
|
1197
|
-
// What it hands back is named;
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
// whether the portal offers them is a question only their own token can answer.
|
|
1221
|
+
// What it hands back is named; a title is exactly what someone without access may not learn
|
|
1222
|
+
// (#17, #19). Tool names are not filtered: they come from a graph this actor may already
|
|
1223
|
+
// read, and whether the portal offers them is a question only their own token can answer.
|
|
1201
1224
|
const reachable = await reachableNodes(actor, referenced.nodes);
|
|
1225
|
+
const named = new Set(reachable.map((reference) => reference.id));
|
|
1226
|
+
const unreachable = referenced.nodes.filter((resourceId) => !named.has(resourceId));
|
|
1227
|
+
// ⚠️ What is left over used to be ONE number, and the panel read it out as "you cannot see
|
|
1228
|
+
// it" — a sentence about a permission, in front of a link that names nothing at all (#509).
|
|
1229
|
+
// The two suggest opposite actions and only one of them helps: with a permission one asks for
|
|
1230
|
+
// access, with a dead reference the step has to be replaced before the flow can run.
|
|
1231
|
+
//
|
|
1232
|
+
// ⚠️ The second question is asked ONLY about what the visibility door already refused, and it
|
|
1233
|
+
// is asked without an actor because its answer does not depend on one. That is the narrowest
|
|
1234
|
+
// shape this distinction can have — the alternative, a lookup that is kinder than the door it
|
|
1235
|
+
// describes, is what #17 and #19 were sent back for. It is still a disclosure and it is a
|
|
1236
|
+
// deliberate one (#492): for an id the caller wrote into this graph themselves, they learn
|
|
1237
|
+
// whether the tree still holds a row for it.
|
|
1238
|
+
const alive = new Set(await deps.repository.existingNodes(unreachable));
|
|
1239
|
+
const invalidNodes = unreachable.filter((resourceId) => !alive.has(resourceId));
|
|
1202
1240
|
return {
|
|
1203
1241
|
flowId: flow.id,
|
|
1204
1242
|
versionId: version.id,
|
|
1205
1243
|
nodes: reachable,
|
|
1206
|
-
|
|
1244
|
+
invalidNodes,
|
|
1245
|
+
hiddenNodes: unreachable.length - invalidNodes.length,
|
|
1207
1246
|
servers: referenced.servers,
|
|
1208
1247
|
};
|
|
1209
1248
|
},
|
|
@@ -1233,6 +1272,11 @@ export function createFlows(deps) {
|
|
|
1233
1272
|
if (replayed)
|
|
1234
1273
|
return await withShareWarnings(actor, flow, replayed, replayed.principal);
|
|
1235
1274
|
}
|
|
1275
|
+
// ⚠️ The same rule as on a node grant, in the same place and for the same two reasons (#442):
|
|
1276
|
+
// in front of `setFlowGrant` so nothing that could never work is written, behind the replay so
|
|
1277
|
+
// a retry with the same key still answers with the grant its first attempt made. The long
|
|
1278
|
+
// form of the argument stands at the node grant in `nodes.ts`.
|
|
1279
|
+
requireFutureExpiry(input.expiresAt, deps.now());
|
|
1236
1280
|
const timestamp = deps.now().toISOString();
|
|
1237
1281
|
const grant = await deps.repository.setFlowGrant({
|
|
1238
1282
|
grant: {
|
|
@@ -1337,9 +1381,13 @@ export function createFlows(deps) {
|
|
|
1337
1381
|
throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
|
|
1338
1382
|
}
|
|
1339
1383
|
// A published caller would break at RUN TIME, in front of somebody who did not order this.
|
|
1384
|
+
// ⚠️ The callers travel as DATA beside the sentence (#593, the second half of #448): the
|
|
1385
|
+
// archive shows this refusal, and a German or Spanish screen could otherwise do nothing but
|
|
1386
|
+
// print the English one. The sentence stays for a model over MCP, which has nothing to
|
|
1387
|
+
// formulate with.
|
|
1340
1388
|
const callers = await deps.repository.flowCallers(actor, current.id);
|
|
1341
1389
|
if (callers.visible.length || callers.hidden) {
|
|
1342
|
-
throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers));
|
|
1390
|
+
throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers), namedOrCounted(callers));
|
|
1343
1391
|
}
|
|
1344
1392
|
// ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
|
|
1345
1393
|
// belonging to other flows. Deleting them would take the record of who started those runs
|
|
@@ -1483,9 +1531,18 @@ export function createFlows(deps) {
|
|
|
1483
1531
|
compileFlow(version.graph);
|
|
1484
1532
|
const wanted = [...new Set(treeLinkNodes(version.graph).map(resourceIdOf))];
|
|
1485
1533
|
const reachable = new Set((await reachableNodes(actor, wanted)).map((reference) => reference.id));
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1534
|
+
// ⚠️ Asked over the deduplicated resource ids and REFUSED over the nodes (#551). The resource
|
|
1535
|
+
// id is what the ACL answers about, and it is also the one thing the author cannot look up:
|
|
1536
|
+
// it stands in no place the editor draws, so `Node reference is unavailable: n-01H…` named
|
|
1537
|
+
// the failure after the only participant nobody can find. The link node beside it carries the
|
|
1538
|
+
// label on the canvas, so the refusal names that instead.
|
|
1539
|
+
//
|
|
1540
|
+
// ⚠️ Naming the label reveals nothing the ACL is keeping: it is the author's own text in
|
|
1541
|
+
// their own graph, already on their screen — unlike the run-time refusal, which counts the
|
|
1542
|
+
// documents rather than naming them because there the READER may not see them (#17, #19).
|
|
1543
|
+
for (const node of treeLinkNodes(version.graph)) {
|
|
1544
|
+
if (!reachable.has(resourceIdOf(node))) {
|
|
1545
|
+
throw new IntelError(409, "flow_node_unavailable", `Node ${nodeName(node)} names a document you cannot reach`);
|
|
1489
1546
|
}
|
|
1490
1547
|
}
|
|
1491
1548
|
for (const node of version.graph.nodes) {
|
|
@@ -82,6 +82,21 @@ export interface FlowRepository {
|
|
|
82
82
|
hidden: number;
|
|
83
83
|
}>;
|
|
84
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[]>;
|
|
85
100
|
publishedCallees(flowId: string): Promise<{
|
|
86
101
|
title: string;
|
|
87
102
|
calleeIds: string[];
|
|
@@ -228,6 +243,12 @@ export interface FlowDeps {
|
|
|
228
243
|
* requirements list names it; and every tree link of every run is authorized by it, nested
|
|
229
244
|
* calls included (ADR-0004 §4). A second lookup beside it is how a drawing or a message ends up
|
|
230
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).
|
|
231
252
|
*/
|
|
232
253
|
visibleNodes(actor: FlowActor, nodeId: string): Promise<Node | null>;
|
|
233
254
|
toolSurfaceFingerprint(actor: FlowActor, server: string, allow: string[] | null): Promise<string | null>;
|
package/dist/http/http.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Hono } from "hono";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { authorizeBearer, bearer, permits, } from "../shared/gate-authorization/gate-authorization.js";
|
|
10
10
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
11
|
-
import { problemDetails as problem } from "../shared/problem-details/problem-details.js";
|
|
11
|
+
import { intelProblem, problemDetails as problem, } from "../shared/problem-details/problem-details.js";
|
|
12
12
|
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
13
13
|
// A query string is a door to the outside like a body is, so what arrives through it is closed
|
|
14
14
|
// rather than tolerated: `z.strictObject` refuses an unknown field, and this is that refusal for the
|
|
@@ -94,7 +94,7 @@ export function createHttp(deps) {
|
|
|
94
94
|
}
|
|
95
95
|
app.onError((error, context) => {
|
|
96
96
|
if (error instanceof IntelError) {
|
|
97
|
-
return context.json(
|
|
97
|
+
return context.json(intelProblem(error), error.status);
|
|
98
98
|
}
|
|
99
99
|
if (error instanceof z.ZodError) {
|
|
100
100
|
return context.json(problem(400, "invalid_request", "Request validation failed", z.prettifyError(error)), 400);
|
|
@@ -440,8 +440,28 @@ export function createHttp(deps) {
|
|
|
440
440
|
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
|
441
441
|
return context.json(await deps.flows.get(asFlowActor(auth), input.flowId));
|
|
442
442
|
});
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
/**
|
|
444
|
+
* A single flow as the same bundle shape a node export makes: one entry plus the manifest
|
|
445
|
+
* (#136). `flows/read` like every other read of the graph.
|
|
446
|
+
*
|
|
447
|
+
* ⚠️ Deliberately no `flow_export` tool beside it, and the reason is NOT the one `node_export`
|
|
448
|
+
* carries (#436). There the memory argument decides — a subtree's zip would have to fit in Worker
|
|
449
|
+
* memory twice — and a flow bundle is one entry plus the manifest, so that argument does not
|
|
450
|
+
* reach here.
|
|
451
|
+
*
|
|
452
|
+
* What decides here is that the manifest of ONE entry answers nothing MCP does not already
|
|
453
|
+
* answer. `node_export` is worth a tool because its manifest is the PLAN of a subtree: every
|
|
454
|
+
* readable entry with its path, kind and media type, which is the map for fetching each one. For
|
|
455
|
+
* a single flow that map has one row, whose id the caller already holds — and the substance, the
|
|
456
|
+
* graph, is `flow_version_get` and `flow_graph`, with the record itself in `flow_get`. A tool
|
|
457
|
+
* whose whole answer is two other tools' answers costs bytes in every `tools/list` and makes the
|
|
458
|
+
* other names less reliable as a guide, which `packages/api/CLAUDE.md` treats as the cost it is.
|
|
459
|
+
*
|
|
460
|
+
* ⚠️ This is not the case `.claude/rules/destructive.md` warns about — "a way only the surface
|
|
461
|
+
* has is a way an agent works around". Nothing about a flow is unreachable over MCP; only the zip
|
|
462
|
+
* CONTAINER is HTTP-only, exactly as it is for nodes. `mcp.e2e.ts` pins that this exception is
|
|
463
|
+
* named rather than merely absent.
|
|
464
|
+
*/
|
|
445
465
|
app.get("/flows/:flowId/export", async (context) => {
|
|
446
466
|
const auth = requireCapability(context, "flows", "read");
|
|
447
467
|
const input = GetFlowInput.parse({ flowId: context.req.param("flowId") });
|
package/dist/intel/intel.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createHttp } from "../http/http.js";
|
|
|
3
3
|
import { handleMcp } from "../mcp/mcp.js";
|
|
4
4
|
import { authorize, authorizeBearer, bearer, } from "../shared/gate-authorization/gate-authorization.js";
|
|
5
5
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
6
|
-
import { problemDetails } from "../shared/problem-details/problem-details.js";
|
|
6
|
+
import { intelProblem, problemDetails } from "../shared/problem-details/problem-details.js";
|
|
7
7
|
import { reportUnexpectedError } from "../shared/report-unexpected-error/report-unexpected-error.js";
|
|
8
8
|
export function createIntel(deps) {
|
|
9
9
|
const baseUrl = deps.baseUrl.replace(/\/+$/, "");
|
|
@@ -16,7 +16,7 @@ export function createIntel(deps) {
|
|
|
16
16
|
const app = new Hono();
|
|
17
17
|
app.onError((error, context) => {
|
|
18
18
|
if (error instanceof IntelError) {
|
|
19
|
-
return context.json(
|
|
19
|
+
return context.json(intelProblem(error), error.status);
|
|
20
20
|
}
|
|
21
21
|
// An IntelError is an expected refusal and explains itself; the unknown exception must leave a
|
|
22
22
|
// trace, or the 500 is undiagnosable — the worker answered, so the platform records no
|
|
@@ -97,7 +97,35 @@ export function createIntel(deps) {
|
|
|
97
97
|
return context.redirect("/tools?connectError=portal_sign_in_failed");
|
|
98
98
|
}
|
|
99
99
|
});
|
|
100
|
-
|
|
100
|
+
// The other end of the same walk: `/auth/connect` starts it, `/auth/callback` finishes it, and
|
|
101
|
+
// a reader who lands in a white JSON page does not distinguish which of the two put them there
|
|
102
|
+
// (#444). Everything that reaches this catch has already lost its handoff — `callback` answers
|
|
103
|
+
// the paths that still know where the person wanted to go — so the destination here is a
|
|
104
|
+
// DECIDED one rather than a remembered one.
|
|
105
|
+
//
|
|
106
|
+
// ⚠️ `/tools` and not `/`: it is the only screen that reads `connectError` and turns it into a
|
|
107
|
+
// sentence (`portalAnswerOf`), and every code but the two about access lands on "the sign-in is
|
|
108
|
+
// broken" there — which is what happened. The root would take the reader somewhere that says
|
|
109
|
+
// nothing at all about the attempt they just made. Somebody who has no Intel session either is
|
|
110
|
+
// sent on to the login by the interface's own 401 handling, once, bounded by the sign-in loop
|
|
111
|
+
// guard (#118).
|
|
112
|
+
app.get("/auth/callback", async (context) => {
|
|
113
|
+
try {
|
|
114
|
+
return await browserAuth.callback(new URL(context.req.url), context.req.raw.headers);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// ⚠️ The IntelError is logged here although `app.onError` deliberately does not log one:
|
|
118
|
+
// an expected refusal explains itself through its body, and this route no longer has a
|
|
119
|
+
// body. `oauth_session_invalid` is raised for two different situations — no readable
|
|
120
|
+
// handoff at all, and a Gate login handoff that ran out — and a deployment where EVERY
|
|
121
|
+
// callback fails (a rotated session key, a clock that drifted, a cookie the browser stopped
|
|
122
|
+
// sending) looks from the outside exactly like one person with a stale tab. Without this
|
|
123
|
+
// line nothing anywhere tells the two apart.
|
|
124
|
+
reportUnexpectedError(error);
|
|
125
|
+
const code = error instanceof IntelError ? error.code : "portal_sign_in_failed";
|
|
126
|
+
return context.redirect(`/tools?connectError=${encodeURIComponent(code)}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
101
129
|
app.post("/auth/logout", () => browserAuth.logout());
|
|
102
130
|
}
|
|
103
131
|
app.route("/api/v1", createHttp({
|