runline 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/loader.js +41 -25
- package/dist/plugins/linear/src/attachments.js +29 -7
- package/dist/plugins/linear/src/comments.js +20 -6
- package/dist/plugins/linear/src/cycles.js +3 -1
- package/dist/plugins/linear/src/index.js +4 -0
- package/dist/plugins/linear/src/initiatives.js +9 -4
- package/dist/plugins/linear/src/issues.js +29 -7
- package/dist/plugins/linear/src/labels.js +6 -1
- package/dist/plugins/linear/src/organization.js +2 -1
- package/dist/plugins/linear/src/projects.js +12 -1
- package/dist/plugins/linear/src/shared.js +103 -0
- package/dist/plugins/linear/src/states.js +3 -1
- package/dist/plugins/linear/src/teams.js +4 -1
- package/dist/plugins/linear/src/users.js +2 -1
- package/dist/plugins/linear/src/views.js +24 -15
- package/dist/plugins/linear/src/webhooks.js +5 -1
- package/dist/plugins/steel/src/browser.js +175 -0
- package/dist/plugins/steel/src/captchas.js +19 -0
- package/dist/plugins/steel/src/credentials.js +38 -0
- package/dist/plugins/steel/src/extensions.js +46 -0
- package/dist/plugins/steel/src/files.js +96 -0
- package/dist/plugins/steel/src/index.js +21 -374
- package/dist/plugins/steel/src/profiles.js +55 -0
- package/dist/plugins/steel/src/sessions.js +119 -0
- package/dist/plugins/steel/src/shared.js +72 -0
- package/dist/plugins/vercel/src/account.js +11 -0
- package/dist/plugins/vercel/src/deployments.js +79 -0
- package/dist/plugins/vercel/src/env.js +101 -0
- package/dist/plugins/vercel/src/index.js +27 -0
- package/dist/plugins/vercel/src/projects.js +29 -0
- package/dist/plugins/vercel/src/shared.js +73 -0
- package/package.json +9 -1
|
@@ -19,6 +19,84 @@ export async function gql(apiKey, query, variables) {
|
|
|
19
19
|
export function key(ctx) {
|
|
20
20
|
return ctx.connection.config.apiKey;
|
|
21
21
|
}
|
|
22
|
+
export function scopeLabelIds(ctx) {
|
|
23
|
+
const raw = ctx.connection.config.scopeLabelIds;
|
|
24
|
+
if (Array.isArray(raw))
|
|
25
|
+
return raw.map(String).map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
if (typeof raw !== "string")
|
|
27
|
+
return [];
|
|
28
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
export function isScoped(ctx) {
|
|
31
|
+
return scopeLabelIds(ctx).length > 0;
|
|
32
|
+
}
|
|
33
|
+
export function mergeIssueScopeFilter(ctx, filter) {
|
|
34
|
+
const ids = scopeLabelIds(ctx);
|
|
35
|
+
if (ids.length === 0)
|
|
36
|
+
return filter;
|
|
37
|
+
const scopeFilter = { labels: { id: { in: ids } } };
|
|
38
|
+
if (!filter || Object.keys(filter).length === 0)
|
|
39
|
+
return scopeFilter;
|
|
40
|
+
return { and: [filter, scopeFilter] };
|
|
41
|
+
}
|
|
42
|
+
export function issueHasScope(ctx, issue) {
|
|
43
|
+
const ids = new Set(scopeLabelIds(ctx));
|
|
44
|
+
if (ids.size === 0)
|
|
45
|
+
return true;
|
|
46
|
+
const labels = issue?.labels?.nodes;
|
|
47
|
+
return Array.isArray(labels) && labels.some((label) => ids.has(String(label.id)));
|
|
48
|
+
}
|
|
49
|
+
export async function getIssueForScope(ctx, issueId) {
|
|
50
|
+
const data = await gql(key(ctx), `query($id: String!) { issue(id: $id) { id identifier labels { nodes { id name } } } }`, { id: issueId });
|
|
51
|
+
return data.issue ?? null;
|
|
52
|
+
}
|
|
53
|
+
export async function assertIssueInScope(ctx, issueId) {
|
|
54
|
+
if (!isScoped(ctx))
|
|
55
|
+
return null;
|
|
56
|
+
const issue = await getIssueForScope(ctx, issueId);
|
|
57
|
+
if (!issue || !issueHasScope(ctx, issue))
|
|
58
|
+
throw new Error("Linear issue is not available to this scoped connection");
|
|
59
|
+
return issue;
|
|
60
|
+
}
|
|
61
|
+
export async function assertCommentInScope(ctx, commentId) {
|
|
62
|
+
if (!isScoped(ctx))
|
|
63
|
+
return;
|
|
64
|
+
const data = await gql(key(ctx), `query($id: String!) { comment(id: $id) { id issue { id identifier labels { nodes { id name } } } } }`, { id: commentId });
|
|
65
|
+
const issue = data.comment?.issue;
|
|
66
|
+
if (!issue || !issueHasScope(ctx, issue))
|
|
67
|
+
throw new Error("Linear comment is not available to this scoped connection");
|
|
68
|
+
}
|
|
69
|
+
export async function assertAttachmentInScope(ctx, attachmentId) {
|
|
70
|
+
if (!isScoped(ctx))
|
|
71
|
+
return;
|
|
72
|
+
const data = await gql(key(ctx), `query($id: String!) { attachment(id: $id) { id issue { id identifier labels { nodes { id name } } } } }`, { id: attachmentId });
|
|
73
|
+
const issue = data.attachment?.issue;
|
|
74
|
+
if (!issue || !issueHasScope(ctx, issue))
|
|
75
|
+
throw new Error("Linear attachment is not available to this scoped connection");
|
|
76
|
+
}
|
|
77
|
+
export function forbidScopeLabelRemoval(ctx, labelIds) {
|
|
78
|
+
const scoped = new Set(scopeLabelIds(ctx));
|
|
79
|
+
if (scoped.size === 0)
|
|
80
|
+
return;
|
|
81
|
+
const ids = Array.isArray(labelIds) ? labelIds.map(String) : [String(labelIds)];
|
|
82
|
+
if (ids.some((id) => scoped.has(id))) {
|
|
83
|
+
throw new Error("Cannot remove a required Linear scope label");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function ensureScopeLabelsOnCreateOrReplace(ctx, labelIds) {
|
|
87
|
+
const scoped = scopeLabelIds(ctx);
|
|
88
|
+
if (scoped.length === 0)
|
|
89
|
+
return labelIds;
|
|
90
|
+
const ids = new Set(Array.isArray(labelIds) ? labelIds.map(String) : []);
|
|
91
|
+
for (const id of scoped)
|
|
92
|
+
ids.add(id);
|
|
93
|
+
return [...ids];
|
|
94
|
+
}
|
|
95
|
+
export function requireUnscoped(ctx, action) {
|
|
96
|
+
if (isScoped(ctx)) {
|
|
97
|
+
throw new Error(`${action} is not available to scoped Linear connections`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
22
100
|
export const ISSUE_FIELDS = `id identifier title description url priority estimate dueDate
|
|
23
101
|
state { id name type } assignee { id name email } creator { id name }
|
|
24
102
|
team { id key name } project { id name } cycle { id number name }
|
|
@@ -106,11 +184,35 @@ export function bindListAction(rl) {
|
|
|
106
184
|
export function bindGetAction(rl) {
|
|
107
185
|
return (...args) => registerGetAction(rl, ...args);
|
|
108
186
|
}
|
|
187
|
+
const SCOPED_BLOCKED_ROOT_FIELDS = new Set([
|
|
188
|
+
"attachments",
|
|
189
|
+
"comments",
|
|
190
|
+
"customView",
|
|
191
|
+
"customViews",
|
|
192
|
+
"cycle",
|
|
193
|
+
"cycles",
|
|
194
|
+
"initiative",
|
|
195
|
+
"initiatives",
|
|
196
|
+
"project",
|
|
197
|
+
"projects",
|
|
198
|
+
"projectMilestone",
|
|
199
|
+
"projectMilestones",
|
|
200
|
+
"projectUpdates",
|
|
201
|
+
"user",
|
|
202
|
+
"users",
|
|
203
|
+
"webhook",
|
|
204
|
+
"webhooks",
|
|
205
|
+
]);
|
|
206
|
+
function requireRootFieldAvailable(ctx, action, rootField) {
|
|
207
|
+
if (SCOPED_BLOCKED_ROOT_FIELDS.has(rootField))
|
|
208
|
+
requireUnscoped(ctx, action);
|
|
209
|
+
}
|
|
109
210
|
export function registerListAction(rl, name, description, rootField, filterTypeName, selection) {
|
|
110
211
|
rl.registerAction(name, {
|
|
111
212
|
description,
|
|
112
213
|
inputSchema: t.Object(LIST_INPUT_SCHEMA),
|
|
113
214
|
async execute(input, ctx) {
|
|
215
|
+
requireRootFieldAvailable(ctx, name, rootField);
|
|
114
216
|
const opts = (input ?? {});
|
|
115
217
|
const { argsDecl, argsCall, vars } = buildConnArgs(opts, filterTypeName);
|
|
116
218
|
const data = await gql(key(ctx), `query${argsDecl} { ${rootField}${argsCall} { nodes { ${selection} } pageInfo { hasNextPage endCursor } } }`, vars);
|
|
@@ -124,6 +226,7 @@ export function registerGetAction(rl, name, description, rootField, selection) {
|
|
|
124
226
|
description,
|
|
125
227
|
inputSchema: t.Object({ id: t.String({ description: "Identifier or slug" }) }),
|
|
126
228
|
async execute(input, ctx) {
|
|
229
|
+
requireRootFieldAvailable(ctx, name, rootField);
|
|
127
230
|
const data = await gql(key(ctx), `query($id: String!) { ${rootField}(id: $id) { ${selection} } }`, { id: input.id });
|
|
128
231
|
return data[rootField];
|
|
129
232
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { STATE_FIELDS, bindGetAction, bindListAction, gql, key } from "./shared.js";
|
|
2
|
+
import { STATE_FIELDS, bindGetAction, bindListAction, gql, key, requireUnscoped } from "./shared.js";
|
|
3
3
|
export function registerStateActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -17,6 +17,7 @@ export function registerStateActions(rl) {
|
|
|
17
17
|
id: t.Optional(t.String({ description: "The identifier in UUID v4 format. If none is provided, the backend will generate one" })),
|
|
18
18
|
}),
|
|
19
19
|
async execute(input, ctx) {
|
|
20
|
+
requireUnscoped(ctx, "state.create");
|
|
20
21
|
const data = await gql(key(ctx), `mutation($input: WorkflowStateCreateInput!) { workflowStateCreate(input: $input) { success workflowState { ${STATE_FIELDS} } } }`, { input: input });
|
|
21
22
|
return data.workflowStateCreate?.workflowState;
|
|
22
23
|
},
|
|
@@ -31,6 +32,7 @@ export function registerStateActions(rl) {
|
|
|
31
32
|
position: t.Optional(t.Number({ description: "The position of the state (Float)" })),
|
|
32
33
|
}),
|
|
33
34
|
async execute(input, ctx) {
|
|
35
|
+
requireUnscoped(ctx, "state.update");
|
|
34
36
|
const { id, ...fields } = input;
|
|
35
37
|
const data = await gql(key(ctx), `mutation($id: String!, $input: WorkflowStateUpdateInput!) { workflowStateUpdate(id: $id, input: $input) { success workflowState { ${STATE_FIELDS} } } }`, { id, input: fields });
|
|
36
38
|
return data.workflowStateUpdate?.workflowState;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { TEAM_FIELDS, USER_FIELDS, bindGetAction, bindListAction, gql, key, } from "./shared.js";
|
|
2
|
+
import { TEAM_FIELDS, USER_FIELDS, bindGetAction, bindListAction, gql, key, requireUnscoped, } from "./shared.js";
|
|
3
3
|
export function registerTeamActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -27,6 +27,7 @@ export function registerTeamActions(rl) {
|
|
|
27
27
|
copySettingsFromTeamId: t.Optional(t.String({ description: "The team id to copy settings from, if any" })),
|
|
28
28
|
}),
|
|
29
29
|
async execute(input, ctx) {
|
|
30
|
+
requireUnscoped(ctx, "team.create");
|
|
30
31
|
const { copySettingsFromTeamId, ...fields } = input;
|
|
31
32
|
const data = await gql(key(ctx), `mutation($input: TeamCreateInput!, $copySettingsFromTeamId: String) { teamCreate(input: $input, copySettingsFromTeamId: $copySettingsFromTeamId) { success team { ${TEAM_FIELDS} } } }`, { input: fields, copySettingsFromTeamId: copySettingsFromTeamId ?? null });
|
|
32
33
|
return data.teamCreate?.team;
|
|
@@ -52,6 +53,7 @@ export function registerTeamActions(rl) {
|
|
|
52
53
|
triageEnabled: t.Optional(t.Boolean({ description: "Whether triage mode is enabled for the team" })),
|
|
53
54
|
}),
|
|
54
55
|
async execute(input, ctx) {
|
|
56
|
+
requireUnscoped(ctx, "team.update");
|
|
55
57
|
const { id, ...fields } = input;
|
|
56
58
|
const data = await gql(key(ctx), `mutation($id: String!, $input: TeamUpdateInput!) { teamUpdate(id: $id, input: $input) { success team { ${TEAM_FIELDS} } } }`, { id, input: fields });
|
|
57
59
|
return data.teamUpdate?.team;
|
|
@@ -64,6 +66,7 @@ export function registerTeamActions(rl) {
|
|
|
64
66
|
limit: t.Optional(t.Number({ description: "Max members to return (default 50)" })),
|
|
65
67
|
}),
|
|
66
68
|
async execute(input, ctx) {
|
|
69
|
+
requireUnscoped(ctx, "team.members");
|
|
67
70
|
const { teamId, limit } = input;
|
|
68
71
|
const data = await gql(key(ctx), `query($id: String!, $first: Int) {
|
|
69
72
|
team(id: $id) { members(first: $first) { nodes { ${USER_FIELDS} } } }
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { USER_FIELDS, bindGetAction, bindListAction, gql, key } from "./shared.js";
|
|
2
|
+
import { USER_FIELDS, bindGetAction, bindListAction, gql, key, requireUnscoped } from "./shared.js";
|
|
3
3
|
export function registerUserActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -28,6 +28,7 @@ export function registerUserActions(rl) {
|
|
|
28
28
|
statusUntilAt: t.Optional(t.String({ description: "When the user status should be cleared (DateTime)" })),
|
|
29
29
|
}),
|
|
30
30
|
async execute(input, ctx) {
|
|
31
|
+
requireUnscoped(ctx, "user.update");
|
|
31
32
|
const { id, ...fields } = input;
|
|
32
33
|
const data = await gql(key(ctx), `mutation($id: String!, $input: UserUpdateInput!) { userUpdate(id: $id, input: $input) { success user { ${USER_FIELDS} } } }`, { id, input: fields });
|
|
33
34
|
return data.userUpdate?.user;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { CUSTOM_VIEW_FIELDS, FEED_ITEM_FIELDS, INITIATIVE_FIELDS, ISSUE_LITE, LIST_INPUT_SCHEMA, PROJECT_FIELDS, bindGetAction, bindListAction, buildConnArgs, gql, key, } from "./shared.js";
|
|
2
|
+
import { CUSTOM_VIEW_FIELDS, FEED_ITEM_FIELDS, INITIATIVE_FIELDS, ISSUE_LITE, LIST_INPUT_SCHEMA, PROJECT_FIELDS, bindGetAction, bindListAction, buildConnArgs, gql, key, mergeIssueScopeFilter, requireUnscoped, } from "./shared.js";
|
|
3
|
+
const ISSUE_FILTER_DESCRIPTION = "IssueFilter payload. Examples: label { labels: { id: { in: ['label-id'] } } }; project { project: { id: { eq: 'project-id' } } }; assignee { assignee: { id: { eq: 'user-id' } } }; state { state: { id: { eq: 'state-id' } } }; priority { priority: { eq: 1 } }; due window { dueDate: { gte: '2026-06-09', lte: '2026-06-16' } }; combine with and/or arrays.";
|
|
3
4
|
export function registerViewActions(rl) {
|
|
4
5
|
const listAction = bindListAction(rl);
|
|
5
6
|
const getAction = bindGetAction(rl);
|
|
@@ -15,7 +16,12 @@ export function registerViewActions(rl) {
|
|
|
15
16
|
}),
|
|
16
17
|
async execute(input, ctx) {
|
|
17
18
|
const opts = (input ?? {});
|
|
18
|
-
const
|
|
19
|
+
const scopedOpts = connectionField === "issues"
|
|
20
|
+
? { ...opts, filter: mergeIssueScopeFilter(ctx, opts.filter) }
|
|
21
|
+
: opts;
|
|
22
|
+
if (connectionField !== "issues")
|
|
23
|
+
requireUnscoped(ctx, name);
|
|
24
|
+
const { argsDecl, argsCall, vars } = buildConnArgs(scopedOpts, filterTypeName);
|
|
19
25
|
const declParts = ["$id: String!", argsDecl.slice(1, -1)];
|
|
20
26
|
const callParts = [argsCall.slice(1, -1)];
|
|
21
27
|
const includeSubTeamsSet = includeSubTeamsDescription !== undefined && opts.includeSubTeams !== undefined;
|
|
@@ -35,24 +41,25 @@ export function registerViewActions(rl) {
|
|
|
35
41
|
listAction("view.list", "List custom views accessible to the user, including personal and shared workspace views. Linear excludes views scoped to a specific project or initiative from this root query.", "customViews", "CustomViewFilter", CUSTOM_VIEW_FIELDS);
|
|
36
42
|
getAction("view.get", "Get a custom view by ID or slug.", "customView", CUSTOM_VIEW_FIELDS);
|
|
37
43
|
rl.registerAction("view.create", {
|
|
38
|
-
description: "Create a custom view. Set filterData for issue views; projectFilterData, initiativeFilterData, or feedItemFilterData for other view types.",
|
|
44
|
+
description: "Create a custom view. Set filterData for issue views; projectFilterData, initiativeFilterData, or feedItemFilterData for other view types. Read matches back with view.issues/projects/initiatives/updates.",
|
|
39
45
|
inputSchema: t.Object({
|
|
40
46
|
name: t.String({ description: "The name of the custom view" }),
|
|
41
47
|
description: t.Optional(t.String({ description: "The description of the custom view" })),
|
|
42
48
|
icon: t.Optional(t.String({ description: "The icon of the custom view" })),
|
|
43
49
|
color: t.Optional(t.String({ description: "The color of the custom view icon (hex)" })),
|
|
44
|
-
shared: t.Optional(t.Boolean({ description: "
|
|
45
|
-
filterData: t.Optional(t.Object({}, { description:
|
|
50
|
+
shared: t.Optional(t.Boolean({ description: "false creates a personal view; true shares the view with the workspace or scoped container" })),
|
|
51
|
+
filterData: t.Optional(t.Object({}, { description: ISSUE_FILTER_DESCRIPTION })),
|
|
46
52
|
projectFilterData: t.Optional(t.Object({}, { description: "ProjectFilter for project views" })),
|
|
47
53
|
initiativeFilterData: t.Optional(t.Object({}, { description: "InitiativeFilter for initiative views" })),
|
|
48
54
|
feedItemFilterData: t.Optional(t.Object({}, { description: "FeedItemFilter for update/feed item views" })),
|
|
49
|
-
teamId: t.Optional(t.String({ description: "
|
|
50
|
-
projectId: t.Optional(t.String({ description: "
|
|
51
|
-
initiativeId: t.Optional(t.String({ description: "
|
|
52
|
-
ownerId: t.Optional(t.String({ description: "
|
|
55
|
+
teamId: t.Optional(t.String({ description: "Scope the view to a team" })),
|
|
56
|
+
projectId: t.Optional(t.String({ description: "Scope the view to a project-specific view" })),
|
|
57
|
+
initiativeId: t.Optional(t.String({ description: "Scope the view to an initiative-specific view" })),
|
|
58
|
+
ownerId: t.Optional(t.String({ description: "Set the user that owns the view" })),
|
|
53
59
|
id: t.Optional(t.String({ description: "The identifier in UUID v4 format. If none is provided, the backend will generate one" })),
|
|
54
60
|
}),
|
|
55
61
|
async execute(input, ctx) {
|
|
62
|
+
requireUnscoped(ctx, "view.create");
|
|
56
63
|
const data = await gql(key(ctx), `mutation($input: CustomViewCreateInput!) { customViewCreate(input: $input) { success customView { ${CUSTOM_VIEW_FIELDS} } } }`, { input: input });
|
|
57
64
|
return data.customViewCreate?.customView;
|
|
58
65
|
},
|
|
@@ -65,17 +72,18 @@ export function registerViewActions(rl) {
|
|
|
65
72
|
description: t.Optional(t.String({ description: "The description of the custom view" })),
|
|
66
73
|
icon: t.Optional(t.String({ description: "The icon of the custom view" })),
|
|
67
74
|
color: t.Optional(t.String({ description: "The color of the custom view icon (hex)" })),
|
|
68
|
-
shared: t.Optional(t.Boolean({ description: "
|
|
69
|
-
filterData: t.Optional(t.Object({}, { description:
|
|
75
|
+
shared: t.Optional(t.Boolean({ description: "false creates a personal view; true shares the view with the workspace or scoped container" })),
|
|
76
|
+
filterData: t.Optional(t.Object({}, { description: ISSUE_FILTER_DESCRIPTION })),
|
|
70
77
|
projectFilterData: t.Optional(t.Object({}, { description: "ProjectFilter for project views" })),
|
|
71
78
|
initiativeFilterData: t.Optional(t.Object({}, { description: "InitiativeFilter for initiative views" })),
|
|
72
79
|
feedItemFilterData: t.Optional(t.Object({}, { description: "FeedItemFilter for update/feed item views" })),
|
|
73
|
-
teamId: t.Optional(t.String({ description: "
|
|
74
|
-
projectId: t.Optional(t.String({ description: "
|
|
75
|
-
initiativeId: t.Optional(t.String({ description: "
|
|
76
|
-
ownerId: t.Optional(t.String({ description: "
|
|
80
|
+
teamId: t.Optional(t.String({ description: "Scope the view to a team" })),
|
|
81
|
+
projectId: t.Optional(t.String({ description: "Scope the view to a project-specific view" })),
|
|
82
|
+
initiativeId: t.Optional(t.String({ description: "Scope the view to an initiative-specific view" })),
|
|
83
|
+
ownerId: t.Optional(t.String({ description: "Set the user that owns the view" })),
|
|
77
84
|
}),
|
|
78
85
|
async execute(input, ctx) {
|
|
86
|
+
requireUnscoped(ctx, "view.update");
|
|
79
87
|
const { id, ...fields } = input;
|
|
80
88
|
const data = await gql(key(ctx), `mutation($id: String!, $input: CustomViewUpdateInput!) { customViewUpdate(id: $id, input: $input) { success customView { ${CUSTOM_VIEW_FIELDS} } } }`, { id, input: fields });
|
|
81
89
|
return data.customViewUpdate?.customView;
|
|
@@ -85,6 +93,7 @@ export function registerViewActions(rl) {
|
|
|
85
93
|
description: "Delete a custom view.",
|
|
86
94
|
inputSchema: t.Object({ id: t.String({ description: "The identifier of the custom view to delete" }) }),
|
|
87
95
|
async execute(input, ctx) {
|
|
96
|
+
requireUnscoped(ctx, "view.delete");
|
|
88
97
|
const data = await gql(key(ctx), `mutation($id: String!) { customViewDelete(id: $id) { success } }`, { id: input.id });
|
|
89
98
|
return data.customViewDelete;
|
|
90
99
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as t from "typebox";
|
|
2
|
-
import { WEBHOOK_FIELDS, bindGetAction, bindListAction, gql, key } from "./shared.js";
|
|
2
|
+
import { WEBHOOK_FIELDS, bindGetAction, bindListAction, gql, key, requireUnscoped } from "./shared.js";
|
|
3
3
|
export function registerWebhookActions(rl) {
|
|
4
4
|
const listAction = bindListAction(rl);
|
|
5
5
|
const getAction = bindGetAction(rl);
|
|
@@ -18,6 +18,7 @@ export function registerWebhookActions(rl) {
|
|
|
18
18
|
id: t.Optional(t.String({ description: "The identifier in UUID v4 format. If none is provided, the backend will generate one" })),
|
|
19
19
|
}),
|
|
20
20
|
async execute(input, ctx) {
|
|
21
|
+
requireUnscoped(ctx, "webhooks.*");
|
|
21
22
|
const data = await gql(key(ctx), `mutation($input: WebhookCreateInput!) { webhookCreate(input: $input) { success webhook { ${WEBHOOK_FIELDS} } } }`, { input: input });
|
|
22
23
|
return data.webhookCreate?.webhook;
|
|
23
24
|
},
|
|
@@ -33,6 +34,7 @@ export function registerWebhookActions(rl) {
|
|
|
33
34
|
secret: t.Optional(t.String({ description: "A secret token used to sign the webhook payload" })),
|
|
34
35
|
}),
|
|
35
36
|
async execute(input, ctx) {
|
|
37
|
+
requireUnscoped(ctx, "webhooks.*");
|
|
36
38
|
const { id, ...fields } = input;
|
|
37
39
|
const data = await gql(key(ctx), `mutation($id: String!, $input: WebhookUpdateInput!) { webhookUpdate(id: $id, input: $input) { success webhook { ${WEBHOOK_FIELDS} } } }`, { id, input: fields });
|
|
38
40
|
return data.webhookUpdate?.webhook;
|
|
@@ -42,6 +44,7 @@ export function registerWebhookActions(rl) {
|
|
|
42
44
|
description: "Delete a webhook.",
|
|
43
45
|
inputSchema: t.Object({ id: t.String({ description: "The identifier of the webhook to delete" }) }),
|
|
44
46
|
async execute(input, ctx) {
|
|
47
|
+
requireUnscoped(ctx, "webhooks.*");
|
|
45
48
|
const data = await gql(key(ctx), `mutation($id: String!) { webhookDelete(id: $id) { success } }`, { id: input.id });
|
|
46
49
|
return data.webhookDelete;
|
|
47
50
|
},
|
|
@@ -50,6 +53,7 @@ export function registerWebhookActions(rl) {
|
|
|
50
53
|
description: "Rotate a webhook's signing secret. Returns the new secret.",
|
|
51
54
|
inputSchema: t.Object({ id: t.String({ description: "The identifier of the webhook to rotate the secret for" }) }),
|
|
52
55
|
async execute(input, ctx) {
|
|
56
|
+
requireUnscoped(ctx, "webhooks.*");
|
|
53
57
|
const data = await gql(key(ctx), `mutation($id: String!) { webhookRotateSecret(id: $id) { success secret } }`, { id: input.id });
|
|
54
58
|
return data.webhookRotateSecret;
|
|
55
59
|
},
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { SESSION_OPTIONS_SCHEMA, api, apiKey, compactRecord } from "./shared.js";
|
|
3
|
+
const SCRAPE_SCHEMA = {
|
|
4
|
+
url: t.String({ description: "URL to scrape" }),
|
|
5
|
+
format: t.Optional(t.Array(t.String(), { description: "Formats: html, cleaned_html, markdown, readability" })),
|
|
6
|
+
delay: t.Optional(t.Number({ description: "Milliseconds to wait after navigation" })),
|
|
7
|
+
useProxy: t.Optional(t.Any({ description: "true or proxy config" })),
|
|
8
|
+
screenshot: t.Optional(t.Boolean({ description: "Also capture a screenshot URL" })),
|
|
9
|
+
pdf: t.Optional(t.Boolean({ description: "Also capture a PDF URL" })),
|
|
10
|
+
};
|
|
11
|
+
const SCREENSHOT_SCHEMA = {
|
|
12
|
+
url: t.String({ description: "URL to screenshot" }),
|
|
13
|
+
fullPage: t.Optional(t.Boolean({ description: "Capture full scrollable page" })),
|
|
14
|
+
delay: t.Optional(t.Number({ description: "Milliseconds to wait after navigation" })),
|
|
15
|
+
useProxy: t.Optional(t.Any({ description: "true or proxy config" })),
|
|
16
|
+
};
|
|
17
|
+
async function scrape(input, ctx) {
|
|
18
|
+
return api(ctx, "/v1/scrape", { method: "POST", body: compactRecord(input) });
|
|
19
|
+
}
|
|
20
|
+
async function screenshot(input, ctx) {
|
|
21
|
+
return api(ctx, "/v1/screenshot", { method: "POST", body: compactRecord(input) });
|
|
22
|
+
}
|
|
23
|
+
async function connectMiniCdp(cdpUrl) {
|
|
24
|
+
const ws = new WebSocket(cdpUrl);
|
|
25
|
+
let nextId = 0;
|
|
26
|
+
const pending = new Map();
|
|
27
|
+
await new Promise((resolve, reject) => {
|
|
28
|
+
const timer = setTimeout(() => reject(new Error("CDP websocket connection timed out")), 30000);
|
|
29
|
+
ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
30
|
+
ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("CDP websocket connection failed")); }, { once: true });
|
|
31
|
+
});
|
|
32
|
+
ws.addEventListener("message", (event) => {
|
|
33
|
+
let message;
|
|
34
|
+
try {
|
|
35
|
+
message = JSON.parse(String(event.data));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (typeof message.id !== "number")
|
|
41
|
+
return;
|
|
42
|
+
const wait = pending.get(message.id);
|
|
43
|
+
if (!wait)
|
|
44
|
+
return;
|
|
45
|
+
pending.delete(message.id);
|
|
46
|
+
if (message.error)
|
|
47
|
+
wait.reject(new Error(JSON.stringify(message.error)));
|
|
48
|
+
else
|
|
49
|
+
wait.resolve(message.result);
|
|
50
|
+
});
|
|
51
|
+
const send = (method, params = {}, sessionId) => new Promise((resolve, reject) => {
|
|
52
|
+
const id = ++nextId;
|
|
53
|
+
pending.set(id, { resolve, reject });
|
|
54
|
+
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
55
|
+
setTimeout(() => {
|
|
56
|
+
if (pending.delete(id))
|
|
57
|
+
reject(new Error(`CDP ${method} timed out`));
|
|
58
|
+
}, 30000);
|
|
59
|
+
});
|
|
60
|
+
const targets = await send("Target.getTargets");
|
|
61
|
+
const target = targets.targetInfos?.find((info) => info.type === "page") ?? targets.targetInfos?.[0];
|
|
62
|
+
if (!target)
|
|
63
|
+
throw new Error("Steel CDP session has no browser target");
|
|
64
|
+
const attached = await send("Target.attachToTarget", { targetId: target.targetId, flatten: true });
|
|
65
|
+
const sid = attached.sessionId;
|
|
66
|
+
const evaluate = async (expression) => {
|
|
67
|
+
const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, sid);
|
|
68
|
+
return result.result?.value;
|
|
69
|
+
};
|
|
70
|
+
const page = {
|
|
71
|
+
async goto(url, _options) {
|
|
72
|
+
await send("Page.navigate", { url }, sid);
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
74
|
+
return null;
|
|
75
|
+
},
|
|
76
|
+
title: () => evaluate("document.title"),
|
|
77
|
+
url: () => evaluate("location.href"),
|
|
78
|
+
text: () => evaluate("document.body?.innerText ?? ''"),
|
|
79
|
+
html: () => evaluate("document.documentElement?.outerHTML ?? ''"),
|
|
80
|
+
evaluate: (expression) => evaluate(`(${expression})()`),
|
|
81
|
+
};
|
|
82
|
+
return { page, browser: { close: () => ws.close() }, context: {}, close: () => ws.close() };
|
|
83
|
+
}
|
|
84
|
+
export function registerBrowserActions(rl) {
|
|
85
|
+
rl.registerAction("scrape", {
|
|
86
|
+
description: "One-shot Steel scrape. Loads a URL and returns requested formats such as markdown, html, cleaned_html, or readability.",
|
|
87
|
+
inputSchema: t.Object(SCRAPE_SCHEMA),
|
|
88
|
+
execute: scrape,
|
|
89
|
+
});
|
|
90
|
+
rl.registerAction("browser.scrape", {
|
|
91
|
+
description: "Backward-compatible alias for scrape.",
|
|
92
|
+
inputSchema: t.Object(SCRAPE_SCHEMA),
|
|
93
|
+
execute: scrape,
|
|
94
|
+
});
|
|
95
|
+
rl.registerAction("screenshot", {
|
|
96
|
+
description: "One-shot Steel screenshot. Returns a hosted PNG URL.",
|
|
97
|
+
inputSchema: t.Object(SCREENSHOT_SCHEMA),
|
|
98
|
+
execute: screenshot,
|
|
99
|
+
});
|
|
100
|
+
rl.registerAction("browser.screenshot", {
|
|
101
|
+
description: "Backward-compatible alias for screenshot.",
|
|
102
|
+
inputSchema: t.Object(SCREENSHOT_SCHEMA),
|
|
103
|
+
execute: screenshot,
|
|
104
|
+
});
|
|
105
|
+
rl.registerAction("browser.extract", {
|
|
106
|
+
description: "Fetch a page through Steel scrape and return selected content fields. Use selectors with browser.run for DOM-specific extraction.",
|
|
107
|
+
inputSchema: t.Object({
|
|
108
|
+
url: t.String({ description: "URL to scrape" }),
|
|
109
|
+
format: t.Optional(t.Array(t.String(), { description: "Formats to request; defaults to markdown and html" })),
|
|
110
|
+
delay: t.Optional(t.Number({ description: "Milliseconds to wait after navigation" })),
|
|
111
|
+
useProxy: t.Optional(t.Any({ description: "true or proxy config" })),
|
|
112
|
+
}),
|
|
113
|
+
async execute(input, ctx) {
|
|
114
|
+
const body = { format: ["markdown", "html"], ...input };
|
|
115
|
+
return api(ctx, "/v1/scrape", { method: "POST", body: compactRecord(body) });
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
rl.registerAction("pdf", {
|
|
119
|
+
description: "One-shot Steel PDF capture. Returns a hosted PDF URL.",
|
|
120
|
+
inputSchema: t.Object({
|
|
121
|
+
url: t.String({ description: "URL to render as PDF" }),
|
|
122
|
+
delay: t.Optional(t.Number({ description: "Milliseconds to wait after navigation" })),
|
|
123
|
+
useProxy: t.Optional(t.Any({ description: "true or proxy config" })),
|
|
124
|
+
}),
|
|
125
|
+
async execute(input, ctx) {
|
|
126
|
+
return api(ctx, "/v1/pdf", { method: "POST", body: compactRecord(input) });
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
rl.registerAction("browser.run", {
|
|
130
|
+
description: "Create a Steel session, connect with Playwright over CDP, run an async JavaScript script, then release by default. The script receives { page, browser, context, session }. Requires the host app to have playwright installed.",
|
|
131
|
+
inputSchema: t.Object({
|
|
132
|
+
script: t.String({ description: "Async JavaScript body. Example: await page.goto('https://example.com'); return { title: await page.title() };" }),
|
|
133
|
+
release: t.Optional(t.Boolean({ description: "Release the Steel session after the script finishes (default true)" })),
|
|
134
|
+
...SESSION_OPTIONS_SCHEMA,
|
|
135
|
+
}),
|
|
136
|
+
async execute(input, ctx) {
|
|
137
|
+
const { script, release, ...sessionOptions } = input;
|
|
138
|
+
let playwright;
|
|
139
|
+
try {
|
|
140
|
+
playwright = await import("playwright");
|
|
141
|
+
}
|
|
142
|
+
catch (_error) {
|
|
143
|
+
throw new Error("steel.browser.run requires the host project to install playwright. Install playwright or use session.create + session.cdpUrl instead.");
|
|
144
|
+
}
|
|
145
|
+
const session = await api(ctx, "/v1/sessions", { method: "POST", body: compactRecord(sessionOptions) });
|
|
146
|
+
const cdpUrl = `wss://connect.steel.dev?apiKey=${encodeURIComponent(apiKey(ctx))}&sessionId=${encodeURIComponent(String(session.id))}`;
|
|
147
|
+
let browser;
|
|
148
|
+
try {
|
|
149
|
+
let context;
|
|
150
|
+
let page;
|
|
151
|
+
try {
|
|
152
|
+
const playwrightBrowser = await playwright.chromium.connectOverCDP(cdpUrl, { timeout: 30000 });
|
|
153
|
+
browser = playwrightBrowser;
|
|
154
|
+
context = playwrightBrowser.contexts()[0] ?? await playwrightBrowser.newContext();
|
|
155
|
+
page = context.pages()[0] ?? await context.newPage();
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
const mini = await connectMiniCdp(cdpUrl);
|
|
159
|
+
browser = mini.browser;
|
|
160
|
+
context = mini.context;
|
|
161
|
+
page = mini.page;
|
|
162
|
+
}
|
|
163
|
+
const fn = new Function("page", "browser", "context", "session", `return (async () => {\n${script}\n})();`);
|
|
164
|
+
const result = await fn(page, browser, context, session);
|
|
165
|
+
return { session, result };
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await browser?.close()?.catch?.(() => { });
|
|
169
|
+
if (release !== false && session.id) {
|
|
170
|
+
await api(ctx, `/v1/sessions/${encodeURIComponent(String(session.id))}/release`, { method: "POST" }).catch(() => { });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { api, compactRecord } from "./shared.js";
|
|
3
|
+
export function registerCaptchaActions(rl) {
|
|
4
|
+
rl.registerAction("captcha.status", {
|
|
5
|
+
description: "Get CAPTCHA detection/solving status for a Steel session.",
|
|
6
|
+
inputSchema: t.Object({ sessionId: t.String() }),
|
|
7
|
+
async execute(input, ctx) { return api(ctx, `/v1/sessions/${encodeURIComponent(input.sessionId)}/captchas/status`); },
|
|
8
|
+
});
|
|
9
|
+
rl.registerAction("captcha.solve", {
|
|
10
|
+
description: "Trigger CAPTCHA solving for all detected CAPTCHAs or a specific task/url/page.",
|
|
11
|
+
inputSchema: t.Object({ sessionId: t.String(), taskId: t.Optional(t.String()), url: t.Optional(t.String()), pageId: t.Optional(t.String()) }),
|
|
12
|
+
async execute(input, ctx) { const { sessionId, ...body } = input; return api(ctx, `/v1/sessions/${encodeURIComponent(String(sessionId))}/captchas/solve`, { method: "POST", body: compactRecord(body) }); },
|
|
13
|
+
});
|
|
14
|
+
rl.registerAction("captcha.solveImage", {
|
|
15
|
+
description: "Solve an image CAPTCHA by XPath selectors.",
|
|
16
|
+
inputSchema: t.Object({ sessionId: t.String(), imageXPath: t.String(), inputXPath: t.String(), url: t.Optional(t.String()) }),
|
|
17
|
+
async execute(input, ctx) { const { sessionId, ...body } = input; return api(ctx, `/v1/sessions/${encodeURIComponent(String(sessionId))}/captchas/solve-image`, { method: "POST", body: compactRecord(body) }); },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { api, compactRecord } from "./shared.js";
|
|
3
|
+
const CREDENTIAL_KEY_SCHEMA = {
|
|
4
|
+
origin: t.String({ description: "Credential origin" }),
|
|
5
|
+
namespace: t.Optional(t.String({ description: "Credential namespace (defaults to Steel default)" })),
|
|
6
|
+
};
|
|
7
|
+
export function registerCredentialActions(rl) {
|
|
8
|
+
rl.registerAction("credential.list", {
|
|
9
|
+
description: "List Steel credentials. Filter by origin and/or namespace.",
|
|
10
|
+
inputSchema: t.Object({ namespace: t.Optional(t.String()), origin: t.Optional(t.String()) }),
|
|
11
|
+
async execute(input, ctx) {
|
|
12
|
+
return api(ctx, "/v1/credentials", { query: input });
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
rl.registerAction("credential.create", {
|
|
16
|
+
description: "Create a Steel credential for an origin/namespace. Value may include username, password, and totpSecret.",
|
|
17
|
+
inputSchema: t.Object({ ...CREDENTIAL_KEY_SCHEMA, value: t.Any({ description: "Credential payload" }) }),
|
|
18
|
+
async execute(input, ctx) {
|
|
19
|
+
return api(ctx, "/v1/credentials", { method: "POST", body: compactRecord(input) });
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
rl.registerAction("credential.get", {
|
|
23
|
+
description: "Retrieve credential metadata by origin and optional namespace.",
|
|
24
|
+
inputSchema: t.Object(CREDENTIAL_KEY_SCHEMA),
|
|
25
|
+
async execute(input, ctx) {
|
|
26
|
+
const result = await api(ctx, "/v1/credentials", { query: compactRecord(input) });
|
|
27
|
+
const credentials = result.credentials;
|
|
28
|
+
return Array.isArray(credentials) ? (credentials[0] ?? null) : null;
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
rl.registerAction("credential.delete", {
|
|
32
|
+
description: "Delete a Steel credential by origin and optional namespace.",
|
|
33
|
+
inputSchema: t.Object(CREDENTIAL_KEY_SCHEMA),
|
|
34
|
+
async execute(input, ctx) {
|
|
35
|
+
return api(ctx, "/v1/credentials", { method: "DELETE", body: compactRecord(input) });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as t from "typebox";
|
|
2
|
+
import { api } from "./shared.js";
|
|
3
|
+
function extensionForm(input) {
|
|
4
|
+
const form = new FormData();
|
|
5
|
+
if (input.url !== undefined && input.url !== null)
|
|
6
|
+
form.set("url", String(input.url));
|
|
7
|
+
return form;
|
|
8
|
+
}
|
|
9
|
+
export function registerExtensionActions(rl) {
|
|
10
|
+
rl.registerAction("extension.list", {
|
|
11
|
+
description: "List Steel Chrome extensions installed for the organization.",
|
|
12
|
+
inputSchema: t.Object({}),
|
|
13
|
+
async execute(_input, ctx) {
|
|
14
|
+
return api(ctx, "/v1/extensions");
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
rl.registerAction("extension.upload", {
|
|
18
|
+
description: "Upload an extension from a Chrome Web Store URL. Raw zip/crx uploads should use the API directly.",
|
|
19
|
+
inputSchema: t.Object({ url: t.String() }),
|
|
20
|
+
async execute(input, ctx) {
|
|
21
|
+
return api(ctx, "/v1/extensions", { method: "POST", body: extensionForm(input) });
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
rl.registerAction("extension.update", {
|
|
25
|
+
description: "Update an extension from a Chrome Web Store URL.",
|
|
26
|
+
inputSchema: t.Object({ id: t.String(), url: t.String() }),
|
|
27
|
+
async execute(input, ctx) {
|
|
28
|
+
const { id, ...body } = input;
|
|
29
|
+
return api(ctx, `/v1/extensions/${encodeURIComponent(String(id))}`, { method: "PUT", body: extensionForm(body) });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
rl.registerAction("extension.delete", {
|
|
33
|
+
description: "Delete an extension by ID.",
|
|
34
|
+
inputSchema: t.Object({ id: t.String() }),
|
|
35
|
+
async execute(input, ctx) {
|
|
36
|
+
return api(ctx, `/v1/extensions/${encodeURIComponent(input.id)}`, { method: "DELETE" });
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
rl.registerAction("extension.deleteAll", {
|
|
40
|
+
description: "Delete all organization extensions.",
|
|
41
|
+
inputSchema: t.Object({}),
|
|
42
|
+
async execute(_input, ctx) {
|
|
43
|
+
return api(ctx, "/v1/extensions", { method: "DELETE" });
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|