@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/README.md +41 -9
- package/dist/adapters/db/db-flows.js +210 -16
- package/dist/adapters/db/db-grants.d.ts +61 -9
- package/dist/adapters/db/db-grants.js +118 -16
- package/dist/adapters/db/db.js +6 -37
- package/dist/auth/auth.js +46 -19
- package/dist/build/build.js +39 -0
- package/dist/bundle/bundle.js +104 -15
- package/dist/cli/cli.js +114 -16
- package/dist/flows/flows.js +196 -17
- package/dist/flows/flows.types.d.ts +49 -3
- package/dist/http/http.js +28 -4
- package/dist/intel/intel.js +29 -1
- package/dist/mcp/mcp.js +41 -3
- package/dist/nodes/nodes.js +26 -1
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -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,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;
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
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));
|