@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/intel/intel.js
CHANGED
|
@@ -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({
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import { IdempotencyKey, IntelId } from "@anchrd/intel-contract";
|
|
|
2
2
|
import { ArchiveFlowInput, CreateFlowInput, GetFlowInput, GetFlowVersionInput, ListFlowsInput, PreviewFlowPublishInput, PublishFlowInput, PurgeFlowInput, RelationGraphInput, SaveFlowVersionInput, UnpublishFlowInput, UpdateFlowInput, } from "@anchrd/intel-contract/flow";
|
|
3
3
|
import { CancelFlowRunInput, CompleteFlowRunStepInput, GetFlowRunInput, ListFlowRunsInput, StartFlowRunInput, } from "@anchrd/intel-contract/flow-run";
|
|
4
4
|
import { ArchiveNodeInput, CreateNodeInput, GetNodeInput, GetNodeVersionInput, ListNodesInput, NodeGraphInput, PurgeNodeInput, ResolveNodeLinksInput, SaveAttachmentInput, SaveNodeVersionInput, SearchInput, UpdateNodeInput, } from "@anchrd/intel-contract/node";
|
|
5
|
-
import { ListGrantsInput, RevokeGrantInput, ShareInput } from "@anchrd/intel-contract/share";
|
|
5
|
+
import { ListFlowGrantsInput, ListGrantsInput, RevokeFlowGrantInput, RevokeGrantInput, ShareFlowInput, ShareInput, } from "@anchrd/intel-contract/share";
|
|
6
6
|
import { AppendTableRowsInput, DefineTableInput, DeleteTableRowsInput, GetTableInput, RedefineTableInput, UpdateTableRowsInput, } from "@anchrd/intel-contract/table";
|
|
7
7
|
import { ExecuteToolInput, TestToolInput } from "@anchrd/intel-contract/tool";
|
|
8
8
|
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -666,8 +666,46 @@ export async function handleMcp(request, deps) {
|
|
|
666
666
|
},
|
|
667
667
|
}, async (input) => text(await deps.flows.listRunSteps(flowActor, input.runId)));
|
|
668
668
|
}
|
|
669
|
-
//
|
|
670
|
-
//
|
|
669
|
+
// The flow's own sharing (#530), named by the same grammar as the node tools: domain singular,
|
|
670
|
+
// the way narrowing left to right, the verb last.
|
|
671
|
+
if (permits(deps.authorization, "flows", "share")) {
|
|
672
|
+
server.registerTool("flow_grant_list", {
|
|
673
|
+
title: "List flow grants",
|
|
674
|
+
description: "List the grants sitting on one flow. What the folder it is filed in passes down is not listed here.",
|
|
675
|
+
inputSchema: ListFlowGrantsInput,
|
|
676
|
+
annotations: {
|
|
677
|
+
title: "List flow grants",
|
|
678
|
+
readOnlyHint: true,
|
|
679
|
+
destructiveHint: false,
|
|
680
|
+
idempotentHint: true,
|
|
681
|
+
openWorldHint: false,
|
|
682
|
+
},
|
|
683
|
+
}, async (input) => text(await deps.flows.listGrants(flowActor, input.flowId)));
|
|
684
|
+
server.registerTool("flow_grant_create", {
|
|
685
|
+
title: "Grant flow access",
|
|
686
|
+
description: "Grant access to one flow for a Gate user, verified email, or the organization. The grant reaches this flow alone — not the folder it is filed in, and not the flows it calls; the answer names what it does not cover.",
|
|
687
|
+
inputSchema: ShareFlowInput,
|
|
688
|
+
annotations: {
|
|
689
|
+
title: "Grant flow access",
|
|
690
|
+
readOnlyHint: false,
|
|
691
|
+
destructiveHint: false,
|
|
692
|
+
idempotentHint: true,
|
|
693
|
+
openWorldHint: false,
|
|
694
|
+
},
|
|
695
|
+
}, async (input) => text(await deps.flows.share(flowActor, input)));
|
|
696
|
+
server.registerTool("flow_grant_revoke", {
|
|
697
|
+
title: "Revoke flow grant",
|
|
698
|
+
description: "Revoke one grant sitting on a flow by ID.",
|
|
699
|
+
inputSchema: RevokeFlowGrantInput,
|
|
700
|
+
annotations: {
|
|
701
|
+
title: "Revoke flow grant",
|
|
702
|
+
readOnlyHint: false,
|
|
703
|
+
destructiveHint: true,
|
|
704
|
+
idempotentHint: true,
|
|
705
|
+
openWorldHint: false,
|
|
706
|
+
},
|
|
707
|
+
}, async (input) => text(await deps.flows.revokeGrant(flowActor, input)));
|
|
708
|
+
}
|
|
671
709
|
if (permits(deps.authorization, "flows", "create")) {
|
|
672
710
|
server.registerTool("flow_create", {
|
|
673
711
|
title: "Create flow",
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TableMediaType } from "@anchrd/intel-contract/table";
|
|
2
2
|
import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
|
|
3
|
+
import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
|
|
3
4
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
4
5
|
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
5
6
|
import { documentLinkTargets } from "./document-links/document-links.js";
|
|
@@ -10,6 +11,11 @@ import { documentLinkTargets } from "./document-links/document-links.js";
|
|
|
10
11
|
function contentKeyFor(nodeId, versionId) {
|
|
11
12
|
return `nodes/${nodeId}/versions/${versionId}`;
|
|
12
13
|
}
|
|
14
|
+
// A node grant covers every flow beneath the node it sits on, so there is never a sub-flow it fails
|
|
15
|
+
// to reach and this half of the warning is empty here by construction (#530). Written out rather
|
|
16
|
+
// than left to the schema's default: nothing parses a service's answer on the way out, so a default
|
|
17
|
+
// would arrive at the UI as `undefined`.
|
|
18
|
+
const nothingWithheld = { titles: [], hidden: 0 };
|
|
13
19
|
// A verb that cannot apply to a node is neither offered on it nor accepted for it (ADR-0004 §2).
|
|
14
20
|
// The answer lives here rather than in the screen so HTTP, MCP and the UI cannot disagree about it.
|
|
15
21
|
//
|
|
@@ -1016,9 +1022,24 @@ export function createNodes(deps) {
|
|
|
1016
1022
|
return {
|
|
1017
1023
|
grant: replayed,
|
|
1018
1024
|
unreadable: await unreadableForPrincipal(actor, node.id, replayed.principal),
|
|
1025
|
+
unrunnable: nothingWithheld,
|
|
1019
1026
|
};
|
|
1020
1027
|
}
|
|
1021
1028
|
}
|
|
1029
|
+
/**
|
|
1030
|
+
* ⚠️ Behind the replay and in front of `setGrant` — both halves matter (#442).
|
|
1031
|
+
*
|
|
1032
|
+
* In front of `setGrant` is the rule itself: nothing that could never work gets written, so
|
|
1033
|
+
* no row, no idempotency key and no audit event. Every path that reaches the write passes
|
|
1034
|
+
* here, including a replay whose grant was revoked in between and falls through.
|
|
1035
|
+
*
|
|
1036
|
+
* Behind the replay because otherwise this refusal would break the promise `IdempotencyKey`
|
|
1037
|
+
* makes. A caller retries with the SAME key and the SAME body; if the retry arrives after the
|
|
1038
|
+
* expiry the first attempt named, the value is no longer in the future — and the second call
|
|
1039
|
+
* would be refused for a grant that is already written. The replay path writes nothing, so
|
|
1040
|
+
* standing behind it costs the rule nothing and keeps the retry answering with what happened.
|
|
1041
|
+
*/
|
|
1042
|
+
requireFutureExpiry(input.expiresAt, deps.now());
|
|
1022
1043
|
const timestamp = deps.now().toISOString();
|
|
1023
1044
|
const grant = await deps.repository.setGrant({
|
|
1024
1045
|
grant: {
|
|
@@ -1037,7 +1058,11 @@ export function createNodes(deps) {
|
|
|
1037
1058
|
// ⚠️ After the grant is written, never before. The answer has to describe the access that is
|
|
1038
1059
|
// now in force — sharing `read` on this folder is exactly what makes the documents inside it
|
|
1039
1060
|
// readable, and a warning computed a moment earlier would name them all.
|
|
1040
|
-
return {
|
|
1061
|
+
return {
|
|
1062
|
+
grant,
|
|
1063
|
+
unreadable: await unreadableForPrincipal(actor, node.id, principal),
|
|
1064
|
+
unrunnable: nothingWithheld,
|
|
1065
|
+
};
|
|
1041
1066
|
},
|
|
1042
1067
|
async revokeGrant(actor, input) {
|
|
1043
1068
|
await requireVisible(actor, input.resourceId);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ A grant whose expiry has already passed is refused, never written (#442).
|
|
3
|
+
*
|
|
4
|
+
* It would otherwise be created, answered with a `201`, and listed — while reaching nobody. The
|
|
5
|
+
* sharer reads the row as done; the grantee gets no notification and finds out days later, if at
|
|
6
|
+
* all. That is the failure mode this repository keeps meeting: something that looks like success,
|
|
7
|
+
* and the only feedback is the wrong one.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ The check cannot live in the Zod schema. That boundary knows nothing about `deps.now()`, and
|
|
10
|
+
* the rule is decided by the very clock that evaluates the grant afterwards — so it belongs in the
|
|
11
|
+
* application layer, where every surface passes through it. The `.describe()` on `expiresAt`
|
|
12
|
+
* documents the rule; this refuses it.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Equal to `now` is refused too: a grant that expires this instant is spent before the answer
|
|
15
|
+
* reaches the caller.
|
|
16
|
+
*
|
|
17
|
+
* `null` is untouched and stays what it always meant — a grant that does not expire on its own.
|
|
18
|
+
*/
|
|
19
|
+
export declare function requireFutureExpiry(expiresAt: string | null, now: Date): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { IntelError } from "../intel-error/intel-error.js";
|
|
2
|
+
/**
|
|
3
|
+
* ⚠️ A grant whose expiry has already passed is refused, never written (#442).
|
|
4
|
+
*
|
|
5
|
+
* It would otherwise be created, answered with a `201`, and listed — while reaching nobody. The
|
|
6
|
+
* sharer reads the row as done; the grantee gets no notification and finds out days later, if at
|
|
7
|
+
* all. That is the failure mode this repository keeps meeting: something that looks like success,
|
|
8
|
+
* and the only feedback is the wrong one.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ The check cannot live in the Zod schema. That boundary knows nothing about `deps.now()`, and
|
|
11
|
+
* the rule is decided by the very clock that evaluates the grant afterwards — so it belongs in the
|
|
12
|
+
* application layer, where every surface passes through it. The `.describe()` on `expiresAt`
|
|
13
|
+
* documents the rule; this refuses it.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ Equal to `now` is refused too: a grant that expires this instant is spent before the answer
|
|
16
|
+
* reaches the caller.
|
|
17
|
+
*
|
|
18
|
+
* `null` is untouched and stays what it always meant — a grant that does not expire on its own.
|
|
19
|
+
*/
|
|
20
|
+
export function requireFutureExpiry(expiresAt, now) {
|
|
21
|
+
if (expiresAt === null)
|
|
22
|
+
return;
|
|
23
|
+
if (Date.parse(expiresAt) > now.getTime())
|
|
24
|
+
return;
|
|
25
|
+
throw new IntelError(400, "grant_already_expired", "A grant's expiry has to lie in the future; pass null for one that does not expire on its own");
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- #530: a flow is shared on its own again, the way a document and a table are.
|
|
2
|
+
--
|
|
3
|
+
-- `0003` moved every per-flow grant onto a folder because ADR-0004 §3 promised something a narrower
|
|
4
|
+
-- grant would have broken: "everything A calls is covered by the same grant — automatically, and
|
|
5
|
+
-- for all time". The word that carried the promise was *automatically*. A grant on the flow alone
|
|
6
|
+
-- does not reach the sub-flow filed beside it, and in 2026-08 the run would simply have stopped at
|
|
7
|
+
-- that step, without anyone having been told beforehand.
|
|
8
|
+
--
|
|
9
|
+
-- Being told beforehand is what has been built since: `flow_validate` gathers everything standing
|
|
10
|
+
-- between one person and one run — missing `execute`, an unreachable sub-flow, an unreachable tool,
|
|
11
|
+
-- an unreadable tree link — and answers it per person and per moment, on all three surfaces. The
|
|
12
|
+
-- promise is therefore not restored here; it is replaced by a question anybody can ask. ADR-0004 §2
|
|
13
|
+
-- carries the dated amendment, D49 the reason.
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ A table of its own, not a widened `node_grants`. A flow is not a node — that is the one thing
|
|
16
|
+
-- ADR-0004 §1 was most explicit about — and `node_grants.node_id` carries a REFERENCES clause into
|
|
17
|
+
-- `nodes`. Making the column polymorphic would mean dropping that clause, and an ACL table whose
|
|
18
|
+
-- rows can outlive their subject is precisely where a stale row keeps handing out access.
|
|
19
|
+
--
|
|
20
|
+
-- Everything else is deliberately identical to `node_grants`, down to the index shape: the two are
|
|
21
|
+
-- read by one predicate builder (`db-grants.ts`), and a difference here would become a difference
|
|
22
|
+
-- in the questions it can ask.
|
|
23
|
+
CREATE TABLE flow_grants (
|
|
24
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
25
|
+
flow_id TEXT NOT NULL REFERENCES flows(id),
|
|
26
|
+
principal_type TEXT NOT NULL CHECK (principal_type IN ('user', 'email', 'organization')),
|
|
27
|
+
principal_id TEXT NOT NULL,
|
|
28
|
+
verb TEXT NOT NULL CHECK (verb IN ('read', 'write', 'execute', 'share')),
|
|
29
|
+
expires_at TEXT,
|
|
30
|
+
created_by TEXT NOT NULL,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
-- The verb belongs in the key, for the reason `0003` gives: without it one principal holds one
|
|
33
|
+
-- verb per flow, which is the ladder the four independent verbs exist instead of.
|
|
34
|
+
UNIQUE (flow_id, principal_type, principal_id, verb)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX flow_grants_principal_idx ON flow_grants(
|
|
38
|
+
principal_type,
|
|
39
|
+
principal_id,
|
|
40
|
+
verb,
|
|
41
|
+
expires_at
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX flow_grants_flow_idx ON flow_grants(flow_id, verb);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@anchrd/gate-sdk": "^0.
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
45
|
+
"@anchrd/gate-sdk": "^0.19.0",
|
|
46
|
+
"@anchrd/intel-contract": "^0.21.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|