@steerpane/sdk 0.1.0 → 0.2.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/LICENSE +202 -202
- package/README.md +110 -106
- package/dist/client.d.ts +101 -4
- package/dist/client.js +103 -8
- package/dist/types.d.ts +301 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +30 -30
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { APIError } from "./errors.js";
|
|
2
|
-
import type { AgentRun, Capture, Credential, CrawlJob, EmbedLink, FetchResult, HelpRequest, HumanResult, Json, Page, Profile, SearchResponse, Session, StoredFile, UploadUrl, Webhook } from "./types.js";
|
|
2
|
+
import type { ActionGuard, ActionGuardRule, ActionGuardUnlock, AgentRun, CaptchaSolveResult, Capture, Credential, CrawlJob, Device, DevicePairingCode, EmbedLink, EndUser, FetchResult, HelpRequest, HumanResult, Json, Page, Profile, SearchResponse, SecretFillResult, Session, StoredFile, UploadUrl, WaitForCodeOptions, WaitForCodeResult, Webhook, WebhookDelivery } from "./types.js";
|
|
3
3
|
export declare const DEFAULT_BASE_URL = "https://api.steerpane.app";
|
|
4
4
|
export interface SteerpaneOptions {
|
|
5
5
|
/** Falls back to `STEERPANE_API_TOKEN`. */
|
|
@@ -12,6 +12,9 @@ export interface SteerpaneOptions {
|
|
|
12
12
|
allowInsecureHttp?: boolean;
|
|
13
13
|
/** Replace `fetch`, for tests. */
|
|
14
14
|
fetch?: typeof fetch;
|
|
15
|
+
/** Who your program is, e.g. "Acme support bot" (falls back to `STEERPANE_AGENT_NAME`). The dashboard shows it on the sessions it
|
|
16
|
+
* drives, their activity and the agent cursor (sent as `X-Steerpane-Agent`). Without it the token's name is shown. */
|
|
17
|
+
agentName?: string;
|
|
15
18
|
}
|
|
16
19
|
interface RequestOptions {
|
|
17
20
|
json?: unknown;
|
|
@@ -53,6 +56,24 @@ declare class Actions {
|
|
|
53
56
|
extract(sessionId: string, instruction: string, args?: Json): Promise<Json>;
|
|
54
57
|
download(sessionId: string, args?: Json): Promise<Json>;
|
|
55
58
|
uploadFile(sessionId: string, args?: Json): Promise<Json>;
|
|
59
|
+
/** Type a value you hold (a password, an access code) into ONE field of the page, on the site you name. `POST /v1/sessions/{id}/secret-fill`; needs the `vault:use` scope.
|
|
60
|
+
* The runtime checks the origin of the frame holding the field against `site` in the same step that types (`exact: true`: the host must match exactly, no subdomains).
|
|
61
|
+
* `kind` is `password` (default; the field must be a password input) or `code`. `submit` and `exact` must be real booleans.
|
|
62
|
+
*
|
|
63
|
+
* The value is never logged, recorded, queued or returned (the result shows `value: "••••••"`). The SDK doesn't echo it either, and this call is NEVER retried: no
|
|
64
|
+
* automatic retry, no Idempotency-Key (the API refuses one). Treat every failure that isn't a definitive refusal before typing (`origin_mismatch`, `insecure_origin`,
|
|
65
|
+
* `field_type_mismatch`, `element_not_found`, ...) as "maybe typed": look at the page before you send it again.
|
|
66
|
+
* **`typed: true` in the result, or a 409 with `details.typed`, means the value is already in the page: do not send it again.** A human in control or a paused agent
|
|
67
|
+
* answers 409 `human_in_control` with nothing typed; a secret is never queued. */
|
|
68
|
+
secretFill(sessionId: string, input: {
|
|
69
|
+
site: string;
|
|
70
|
+
selector: string;
|
|
71
|
+
value: string;
|
|
72
|
+
kind?: "password" | "code";
|
|
73
|
+
submit?: boolean;
|
|
74
|
+
exact?: boolean;
|
|
75
|
+
frame?: string;
|
|
76
|
+
}): Promise<SecretFillResult>;
|
|
56
77
|
/** Type a stored credential into the page. The secret never comes back. Refused off the credential's site. */
|
|
57
78
|
vaultFill(sessionId: string, credentialId: string, args?: Json): Promise<Json>;
|
|
58
79
|
}
|
|
@@ -64,15 +85,18 @@ declare class Sessions {
|
|
|
64
85
|
profile_id?: string;
|
|
65
86
|
q?: string;
|
|
66
87
|
project_id?: string;
|
|
88
|
+
end_user_id?: string;
|
|
67
89
|
limit?: number;
|
|
68
90
|
cursor?: string | null;
|
|
69
91
|
}): Promise<Page<Session>>;
|
|
70
|
-
/** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `auto_close`, `timeout_min`.
|
|
92
|
+
/** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `end_user_id`, `auto_close`, `timeout_min`.
|
|
93
|
+
* `profile_wait: { timeout_s: 300 }` queues behind the profile's current writer instead of failing with 409 `profile_locked`: the session comes back with
|
|
94
|
+
* `status: "queued"` and `queue_position`; stop it to cancel. Without `profile_id` you get a throwaway browser that saves nothing. */
|
|
71
95
|
create(config?: Json, options?: {
|
|
72
96
|
idempotencyKey?: string;
|
|
73
97
|
}): Promise<Session>;
|
|
74
98
|
get(sessionId: string): Promise<Session>;
|
|
75
|
-
/** Wait for the browser to leave `starting
|
|
99
|
+
/** Wait for the browser to leave `starting` (and `queued`, for a session created with `profile_wait`: raise `timeoutS` to cover the wait). Throws if it ends instead, or doesn't start in time. */
|
|
76
100
|
waitUntilReady(sessionId: string, options?: {
|
|
77
101
|
timeoutS?: number;
|
|
78
102
|
pollS?: number;
|
|
@@ -86,6 +110,28 @@ declare class Sessions {
|
|
|
86
110
|
outcome?: "done" | "not_done";
|
|
87
111
|
note?: string;
|
|
88
112
|
}): Promise<Session>;
|
|
113
|
+
/** Name what the agent may not click or submit without approval. `rules` replaces the current ones; `[]` removes the guard. A blocked action fails with the
|
|
114
|
+
* `action_guard_blocked` error code until `unlockAction` is called. The service ships no rules of its own. */
|
|
115
|
+
setActionGuard(sessionId: string, rules: ActionGuardRule[]): Promise<ActionGuard>;
|
|
116
|
+
getActionGuard(sessionId: string): Promise<ActionGuard>;
|
|
117
|
+
removeActionGuard(sessionId: string): Promise<void>;
|
|
118
|
+
/** Approve ONE blocked gesture (the next matching click, Enter-submit or form submit, optionally only for `ruleId`) within `ttlS` seconds (1 to 120, default 30). Needs the `takeover` scope.
|
|
119
|
+
* The result's `late_targets` counts frames where a page script may have registered a listener ahead of the guard; if you need certainty, decline to rely on the guard when it is above 0.
|
|
120
|
+
* An action that spends the unlock returns `unlock_spent: true`, and an error it ends with carries `details.unlock_spent: true` (read it from `APIError.details`/`ToolError.details`): the act may have happened, so never click again, whatever the error.
|
|
121
|
+
* A session whose guard can't be installed refuses guarded actions with `action_guard_unavailable`. */
|
|
122
|
+
unlockAction(sessionId: string, options?: {
|
|
123
|
+
ruleId?: string;
|
|
124
|
+
ttlS?: number;
|
|
125
|
+
}): Promise<ActionGuardUnlock>;
|
|
126
|
+
/** Run the CAPTCHA solver on the session's page. One call waits about 45 s: while `status` is `in_progress`, call again (the worker keeps running, it never restarts).
|
|
127
|
+
* `onFailure` is `wait_for_human` (default), `request_help` or `return`. Needs a CAPTCHA model on the organization. */
|
|
128
|
+
solveCaptcha(sessionId: string, options?: {
|
|
129
|
+
onFailure?: "wait_for_human" | "request_help" | "return";
|
|
130
|
+
maxReloads?: number;
|
|
131
|
+
model?: string;
|
|
132
|
+
captchaImageSelector?: string;
|
|
133
|
+
captchaInputSelector?: string;
|
|
134
|
+
}): Promise<CaptchaSolveResult>;
|
|
89
135
|
/** Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires. */
|
|
90
136
|
requestHelp(sessionId: string, input: {
|
|
91
137
|
reason: string;
|
|
@@ -110,10 +156,12 @@ declare class Profiles {
|
|
|
110
156
|
list(params?: {
|
|
111
157
|
tag?: string;
|
|
112
158
|
q?: string;
|
|
159
|
+
end_user_id?: string;
|
|
113
160
|
limit?: number;
|
|
114
161
|
cursor?: string | null;
|
|
115
162
|
}): Promise<Page<Profile>>;
|
|
116
163
|
get(profileId: string): Promise<Profile>;
|
|
164
|
+
/** `fields` include `end_user_id` to give the profile to one of your end users. */
|
|
117
165
|
create(name: string, fields?: Json): Promise<Profile>;
|
|
118
166
|
update(profileId: string, fields: Json): Promise<Profile>;
|
|
119
167
|
delete(profileId: string): Promise<void>;
|
|
@@ -127,6 +175,46 @@ declare class Profiles {
|
|
|
127
175
|
imported: number;
|
|
128
176
|
}>;
|
|
129
177
|
}
|
|
178
|
+
/** Your own customers. Profiles and sessions can belong to one; a session only uses profiles of its own end user. */
|
|
179
|
+
declare class EndUsers {
|
|
180
|
+
private readonly c;
|
|
181
|
+
constructor(c: Steerpane);
|
|
182
|
+
/** Idempotent on `external_id`: calling it again returns the existing end user unchanged. */
|
|
183
|
+
create(externalId: string, fields?: {
|
|
184
|
+
name?: string;
|
|
185
|
+
metadata?: Record<string, string>;
|
|
186
|
+
}): Promise<EndUser>;
|
|
187
|
+
list(params?: {
|
|
188
|
+
external_id?: string;
|
|
189
|
+
limit?: number;
|
|
190
|
+
cursor?: string | null;
|
|
191
|
+
}): Promise<Page<EndUser>>;
|
|
192
|
+
get(endUserId: string): Promise<EndUser>;
|
|
193
|
+
update(endUserId: string, fields: {
|
|
194
|
+
name?: string;
|
|
195
|
+
metadata?: Record<string, string>;
|
|
196
|
+
disabled?: boolean;
|
|
197
|
+
}): Promise<EndUser>;
|
|
198
|
+
/** 409 while it owns profiles or live sessions; `cascade: true` deletes its profiles too (never while a session is live). */
|
|
199
|
+
delete(endUserId: string, options?: {
|
|
200
|
+
cascade?: boolean;
|
|
201
|
+
}): Promise<void>;
|
|
202
|
+
/** A single-use code (valid a few minutes) to hand to this end user so they can pair their own computer. Answers 404 while devices are off for the service. */
|
|
203
|
+
createDevicePairingCode(endUserId: string): Promise<DevicePairingCode>;
|
|
204
|
+
}
|
|
205
|
+
/** Computers that run sessions: yours, shared ones, and your end users'. Answers 404 while devices are off for the service. */
|
|
206
|
+
declare class Devices {
|
|
207
|
+
private readonly c;
|
|
208
|
+
constructor(c: Steerpane);
|
|
209
|
+
/** `scope` is `user`, `org` or `end_user`; `end_user_id` narrows to one end user's devices. Capped at 200, no cursor. */
|
|
210
|
+
list(params?: {
|
|
211
|
+
scope?: "user" | "org" | "end_user";
|
|
212
|
+
end_user_id?: string;
|
|
213
|
+
}): Promise<{
|
|
214
|
+
data: Device[];
|
|
215
|
+
}>;
|
|
216
|
+
get(deviceId: string): Promise<Device>;
|
|
217
|
+
}
|
|
130
218
|
/** Hosted agent runs. These use your organization's own model key. */
|
|
131
219
|
declare class Runs {
|
|
132
220
|
private readonly c;
|
|
@@ -212,7 +300,7 @@ declare class Webhooks {
|
|
|
212
300
|
delete(webhookId: string): Promise<void>;
|
|
213
301
|
sendTest(webhookId: string, input?: {
|
|
214
302
|
event?: string;
|
|
215
|
-
}): Promise<
|
|
303
|
+
}): Promise<WebhookDelivery>;
|
|
216
304
|
}
|
|
217
305
|
/** Stored logins. Passwords and TOTP secrets go in and never come back to an API token. */
|
|
218
306
|
declare class Vault {
|
|
@@ -260,18 +348,27 @@ declare class Mcp {
|
|
|
260
348
|
call<T = Json>(tool: string, args?: Json, options?: {
|
|
261
349
|
timeoutS?: number;
|
|
262
350
|
}): Promise<T>;
|
|
351
|
+
/** Wait for an email or SMS verification code (the MCP tool `wait_for_code`). With `sessionId` the code is typed into the page and never reaches you
|
|
352
|
+
* (`status: "entered"`); with `returnCode: true` you get it back (`status: "received"`). The server answers in rounds of about 45 s with `status: "waiting"`:
|
|
353
|
+
* this keeps calling, with the `started_at` it was given, until the code arrives or `timeoutS` runs out (`status: "timeout"`), so the result is always final.
|
|
354
|
+
* Needs the `vault:use` scope and a code source set up on the organization. */
|
|
355
|
+
waitForCode(options?: WaitForCodeOptions): Promise<WaitForCodeResult>;
|
|
263
356
|
}
|
|
264
357
|
/** Client for the Steerpane API. */
|
|
265
358
|
export declare class Steerpane {
|
|
266
359
|
readonly baseUrl: string;
|
|
267
360
|
readonly timeoutMs: number;
|
|
268
361
|
readonly maxRetries: number;
|
|
362
|
+
/** Sent as X-Steerpane-Agent: the name the dashboard shows for this program. */
|
|
363
|
+
readonly agentName: string | undefined;
|
|
269
364
|
/** The token, for helpers that need it. Not enumerable, so logging the client doesn't print it. */
|
|
270
365
|
readonly token: string;
|
|
271
366
|
private readonly fetchImpl;
|
|
272
367
|
readonly sessions: Sessions;
|
|
273
368
|
readonly actions: Actions;
|
|
274
369
|
readonly profiles: Profiles;
|
|
370
|
+
readonly endUsers: EndUsers;
|
|
371
|
+
readonly devices: Devices;
|
|
275
372
|
readonly runs: Runs;
|
|
276
373
|
readonly files: Files;
|
|
277
374
|
readonly embeds: Embeds;
|
package/dist/client.js
CHANGED
|
@@ -51,6 +51,18 @@ class Actions {
|
|
|
51
51
|
extract(sessionId, instruction, args = {}) { return this.run(sessionId, "extract", { instruction, ...args }); }
|
|
52
52
|
download(sessionId, args = {}) { return this.run(sessionId, "download", args); }
|
|
53
53
|
uploadFile(sessionId, args = {}) { return this.run(sessionId, "upload_file", args); }
|
|
54
|
+
/** Type a value you hold (a password, an access code) into ONE field of the page, on the site you name. `POST /v1/sessions/{id}/secret-fill`; needs the `vault:use` scope.
|
|
55
|
+
* The runtime checks the origin of the frame holding the field against `site` in the same step that types (`exact: true`: the host must match exactly, no subdomains).
|
|
56
|
+
* `kind` is `password` (default; the field must be a password input) or `code`. `submit` and `exact` must be real booleans.
|
|
57
|
+
*
|
|
58
|
+
* The value is never logged, recorded, queued or returned (the result shows `value: "••••••"`). The SDK doesn't echo it either, and this call is NEVER retried: no
|
|
59
|
+
* automatic retry, no Idempotency-Key (the API refuses one). Treat every failure that isn't a definitive refusal before typing (`origin_mismatch`, `insecure_origin`,
|
|
60
|
+
* `field_type_mismatch`, `element_not_found`, ...) as "maybe typed": look at the page before you send it again.
|
|
61
|
+
* **`typed: true` in the result, or a 409 with `details.typed`, means the value is already in the page: do not send it again.** A human in control or a paused agent
|
|
62
|
+
* answers 409 `human_in_control` with nothing typed; a secret is never queued. */
|
|
63
|
+
secretFill(sessionId, input) {
|
|
64
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/secret-fill`, { json: clean(input) });
|
|
65
|
+
}
|
|
54
66
|
/** Type a stored credential into the page. The secret never comes back. Refused off the credential's site. */
|
|
55
67
|
vaultFill(sessionId, credentialId, args = {}) { return this.run(sessionId, "vault_fill", { credential_id: credentialId, ...args }); }
|
|
56
68
|
}
|
|
@@ -62,12 +74,14 @@ class Sessions {
|
|
|
62
74
|
list(params = {}) {
|
|
63
75
|
return this.c.request("GET", "/v1/sessions", { params });
|
|
64
76
|
}
|
|
65
|
-
/** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `auto_close`, `timeout_min`.
|
|
77
|
+
/** Start a browser. `config` takes the session fields in the API docs, such as `initial_url`, `profile_id`, `end_user_id`, `auto_close`, `timeout_min`.
|
|
78
|
+
* `profile_wait: { timeout_s: 300 }` queues behind the profile's current writer instead of failing with 409 `profile_locked`: the session comes back with
|
|
79
|
+
* `status: "queued"` and `queue_position`; stop it to cancel. Without `profile_id` you get a throwaway browser that saves nothing. */
|
|
66
80
|
create(config = {}, options = {}) {
|
|
67
81
|
return this.c.request("POST", "/v1/sessions", { json: clean(config), headers: { "Idempotency-Key": options.idempotencyKey ?? crypto.randomUUID() } });
|
|
68
82
|
}
|
|
69
83
|
get(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}`); }
|
|
70
|
-
/** Wait for the browser to leave `starting
|
|
84
|
+
/** Wait for the browser to leave `starting` (and `queued`, for a session created with `profile_wait`: raise `timeoutS` to cover the wait). Throws if it ends instead, or doesn't start in time. */
|
|
71
85
|
async waitUntilReady(sessionId, options = {}) {
|
|
72
86
|
const timeoutS = options.timeoutS ?? 90;
|
|
73
87
|
const deadline = Date.now() + timeoutS * 1000;
|
|
@@ -75,10 +89,10 @@ class Sessions {
|
|
|
75
89
|
const session = await this.get(sessionId);
|
|
76
90
|
if (session.status === "stopped" || session.status === "error")
|
|
77
91
|
throw new SteerpaneError(`Session ${sessionId} ended before it was ready: ${session.error_message ?? session.status}`);
|
|
78
|
-
if (session.status !== "starting")
|
|
92
|
+
if (session.status !== "starting" && session.status !== "queued")
|
|
79
93
|
return session;
|
|
80
94
|
if (Date.now() >= deadline)
|
|
81
|
-
throw new SteerpaneError(`Session ${sessionId} was still
|
|
95
|
+
throw new SteerpaneError(`Session ${sessionId} was still ${session.status} after ${timeoutS} s`);
|
|
82
96
|
await sleep((options.pollS ?? 0.5) * 1000);
|
|
83
97
|
}
|
|
84
98
|
}
|
|
@@ -90,6 +104,26 @@ class Sessions {
|
|
|
90
104
|
handback(sessionId, input = {}) {
|
|
91
105
|
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/handback`, { json: clean(input) });
|
|
92
106
|
}
|
|
107
|
+
/** Name what the agent may not click or submit without approval. `rules` replaces the current ones; `[]` removes the guard. A blocked action fails with the
|
|
108
|
+
* `action_guard_blocked` error code until `unlockAction` is called. The service ships no rules of its own. */
|
|
109
|
+
setActionGuard(sessionId, rules) { return this.c.request("PUT", `/v1/sessions/${seg(sessionId)}/action-guard`, { json: { rules } }); }
|
|
110
|
+
getActionGuard(sessionId) { return this.c.request("GET", `/v1/sessions/${seg(sessionId)}/action-guard`); }
|
|
111
|
+
async removeActionGuard(sessionId) { await this.c.request("DELETE", `/v1/sessions/${seg(sessionId)}/action-guard`); }
|
|
112
|
+
/** Approve ONE blocked gesture (the next matching click, Enter-submit or form submit, optionally only for `ruleId`) within `ttlS` seconds (1 to 120, default 30). Needs the `takeover` scope.
|
|
113
|
+
* The result's `late_targets` counts frames where a page script may have registered a listener ahead of the guard; if you need certainty, decline to rely on the guard when it is above 0.
|
|
114
|
+
* An action that spends the unlock returns `unlock_spent: true`, and an error it ends with carries `details.unlock_spent: true` (read it from `APIError.details`/`ToolError.details`): the act may have happened, so never click again, whatever the error.
|
|
115
|
+
* A session whose guard can't be installed refuses guarded actions with `action_guard_unavailable`. */
|
|
116
|
+
unlockAction(sessionId, options = {}) {
|
|
117
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/action-guard/unlock`, { json: clean({ rule_id: options.ruleId, ttl_s: options.ttlS }) });
|
|
118
|
+
}
|
|
119
|
+
/** Run the CAPTCHA solver on the session's page. One call waits about 45 s: while `status` is `in_progress`, call again (the worker keeps running, it never restarts).
|
|
120
|
+
* `onFailure` is `wait_for_human` (default), `request_help` or `return`. Needs a CAPTCHA model on the organization. */
|
|
121
|
+
solveCaptcha(sessionId, options = {}) {
|
|
122
|
+
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/captcha/solve`, {
|
|
123
|
+
json: clean({ on_failure: options.onFailure, max_reloads: options.maxReloads, model: options.model, captcha_image_selector: options.captchaImageSelector, captcha_input_selector: options.captchaInputSelector }),
|
|
124
|
+
timeoutMs: Math.max(this.c.timeoutMs, 90_000),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
93
127
|
/** Ask a person for help. The agent goes on hold, and `agent.human_help_requested` fires. */
|
|
94
128
|
requestHelp(sessionId, input) {
|
|
95
129
|
return this.c.request("POST", `/v1/sessions/${seg(sessionId)}/help-requests`, { json: { message: "", ...input } });
|
|
@@ -113,6 +147,7 @@ class Profiles {
|
|
|
113
147
|
}
|
|
114
148
|
list(params = {}) { return this.c.request("GET", "/v1/profiles", { params }); }
|
|
115
149
|
get(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}`); }
|
|
150
|
+
/** `fields` include `end_user_id` to give the profile to one of your end users. */
|
|
116
151
|
create(name, fields = {}) { return this.c.request("POST", "/v1/profiles", { json: { name, ...clean(fields) } }); }
|
|
117
152
|
update(profileId, fields) { return this.c.request("PATCH", `/v1/profiles/${seg(profileId)}`, { json: fields }); }
|
|
118
153
|
async delete(profileId) { await this.c.request("DELETE", `/v1/profiles/${seg(profileId)}`); }
|
|
@@ -122,6 +157,38 @@ class Profiles {
|
|
|
122
157
|
lock(profileId) { return this.c.request("GET", `/v1/profiles/${seg(profileId)}/lock`); }
|
|
123
158
|
importCookies(profileId, cookies) { return this.c.request("POST", `/v1/profiles/${seg(profileId)}/cookies/import`, { json: { cookies } }); }
|
|
124
159
|
}
|
|
160
|
+
/** Your own customers. Profiles and sessions can belong to one; a session only uses profiles of its own end user. */
|
|
161
|
+
class EndUsers {
|
|
162
|
+
c;
|
|
163
|
+
constructor(c) {
|
|
164
|
+
this.c = c;
|
|
165
|
+
}
|
|
166
|
+
/** Idempotent on `external_id`: calling it again returns the existing end user unchanged. */
|
|
167
|
+
create(externalId, fields = {}) {
|
|
168
|
+
return this.c.request("POST", "/v1/end-users", { json: clean({ external_id: externalId, ...fields }) });
|
|
169
|
+
}
|
|
170
|
+
list(params = {}) { return this.c.request("GET", "/v1/end-users", { params }); }
|
|
171
|
+
get(endUserId) { return this.c.request("GET", `/v1/end-users/${seg(endUserId)}`); }
|
|
172
|
+
update(endUserId, fields) {
|
|
173
|
+
return this.c.request("PATCH", `/v1/end-users/${seg(endUserId)}`, { json: clean(fields) });
|
|
174
|
+
}
|
|
175
|
+
/** 409 while it owns profiles or live sessions; `cascade: true` deletes its profiles too (never while a session is live). */
|
|
176
|
+
async delete(endUserId, options = {}) {
|
|
177
|
+
await this.c.request("DELETE", `/v1/end-users/${seg(endUserId)}`, { params: options.cascade ? { cascade: "true" } : {} });
|
|
178
|
+
}
|
|
179
|
+
/** A single-use code (valid a few minutes) to hand to this end user so they can pair their own computer. Answers 404 while devices are off for the service. */
|
|
180
|
+
createDevicePairingCode(endUserId) { return this.c.request("POST", `/v1/end-users/${seg(endUserId)}/device-pairing-codes`); }
|
|
181
|
+
}
|
|
182
|
+
/** Computers that run sessions: yours, shared ones, and your end users'. Answers 404 while devices are off for the service. */
|
|
183
|
+
class Devices {
|
|
184
|
+
c;
|
|
185
|
+
constructor(c) {
|
|
186
|
+
this.c = c;
|
|
187
|
+
}
|
|
188
|
+
/** `scope` is `user`, `org` or `end_user`; `end_user_id` narrows to one end user's devices. Capped at 200, no cursor. */
|
|
189
|
+
list(params = {}) { return this.c.request("GET", "/v1/devices", { params }); }
|
|
190
|
+
get(deviceId) { return this.c.request("GET", `/v1/devices/${seg(deviceId)}`); }
|
|
191
|
+
}
|
|
125
192
|
/** Hosted agent runs. These use your organization's own model key. */
|
|
126
193
|
class Runs {
|
|
127
194
|
c;
|
|
@@ -301,16 +368,39 @@ class Mcp {
|
|
|
301
368
|
}
|
|
302
369
|
return payload;
|
|
303
370
|
}
|
|
371
|
+
/** Wait for an email or SMS verification code (the MCP tool `wait_for_code`). With `sessionId` the code is typed into the page and never reaches you
|
|
372
|
+
* (`status: "entered"`); with `returnCode: true` you get it back (`status: "received"`). The server answers in rounds of about 45 s with `status: "waiting"`:
|
|
373
|
+
* this keeps calling, with the `started_at` it was given, until the code arrives or `timeoutS` runs out (`status: "timeout"`), so the result is always final.
|
|
374
|
+
* Needs the `vault:use` scope and a code source set up on the organization. */
|
|
375
|
+
async waitForCode(options = {}) {
|
|
376
|
+
const args = {
|
|
377
|
+
session_id: options.sessionId, profile_id: options.profileId, credential_id: options.credentialId, site: options.site, sender: options.sender, subject: options.subject,
|
|
378
|
+
timeout_s: options.timeoutS, max_age_s: options.maxAgeS, selector: options.selector, frame: options.frame, submit: options.submit, return_code: options.returnCode, code_id: options.codeId,
|
|
379
|
+
};
|
|
380
|
+
const timeoutS = options.timeoutS ?? 120;
|
|
381
|
+
for (let round = 0;; round++) {
|
|
382
|
+
const result = await this.call("wait_for_code", args, { timeoutS: 90 });
|
|
383
|
+
if (result.status !== "waiting")
|
|
384
|
+
return result;
|
|
385
|
+
if (round > timeoutS)
|
|
386
|
+
throw new SteerpaneError("wait_for_code kept answering 'waiting' past its own timeout");
|
|
387
|
+
args["started_at"] = result["started_at"];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
304
390
|
}
|
|
305
391
|
/** Client for the Steerpane API. */
|
|
306
392
|
export class Steerpane {
|
|
307
393
|
baseUrl;
|
|
308
394
|
timeoutMs;
|
|
309
395
|
maxRetries;
|
|
396
|
+
/** Sent as X-Steerpane-Agent: the name the dashboard shows for this program. */
|
|
397
|
+
agentName;
|
|
310
398
|
fetchImpl;
|
|
311
399
|
sessions = new Sessions(this);
|
|
312
400
|
actions = new Actions(this);
|
|
313
401
|
profiles = new Profiles(this);
|
|
402
|
+
endUsers = new EndUsers(this);
|
|
403
|
+
devices = new Devices(this);
|
|
314
404
|
runs = new Runs(this);
|
|
315
405
|
files = new Files(this);
|
|
316
406
|
embeds = new Embeds(this);
|
|
@@ -339,6 +429,7 @@ export class Steerpane {
|
|
|
339
429
|
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
340
430
|
this.maxRetries = options.maxRetries ?? 2;
|
|
341
431
|
this.fetchImpl = options.fetch ?? fetch;
|
|
432
|
+
this.agentName = (options.agentName ?? process.env["STEERPANE_AGENT_NAME"] ?? "").trim().slice(0, 80) || undefined;
|
|
342
433
|
}
|
|
343
434
|
/** One HTTP call with your token and the retry rules. Doesn't throw on an error status. */
|
|
344
435
|
async rawRequest(method, path, options = {}) {
|
|
@@ -347,25 +438,29 @@ export class Steerpane {
|
|
|
347
438
|
if (v !== undefined && v !== null)
|
|
348
439
|
query.set(k, String(v));
|
|
349
440
|
const url = this.baseUrl + path + (query.size ? `?${query}` : "");
|
|
350
|
-
const headers = {
|
|
441
|
+
const headers = {
|
|
442
|
+
Authorization: `Bearer ${this.token}`, "User-Agent": `steerpane-sdk-node/${VERSION}`,
|
|
443
|
+
...(this.agentName ? { "X-Steerpane-Agent": this.agentName } : {}), ...options.headers,
|
|
444
|
+
};
|
|
351
445
|
if (options.json !== undefined)
|
|
352
446
|
headers["Content-Type"] = "application/json";
|
|
353
447
|
const body = options.body ?? (options.json !== undefined ? JSON.stringify(options.json) : undefined);
|
|
354
448
|
const keyed = "Idempotency-Key" in headers;
|
|
449
|
+
const noRetry = path.split("?")[0].replace(/\/+$/, "").endsWith("/secret-fill"); // typing a secret twice can't be undone: never retried, whatever the error
|
|
355
450
|
const idempotent = method === "GET" || method === "HEAD" || method === "DELETE" || keyed;
|
|
356
451
|
for (let attempt = 0;; attempt++) {
|
|
357
452
|
let delay;
|
|
358
453
|
try {
|
|
359
454
|
const res = await this.fetchImpl(url, { method, headers, body, signal: AbortSignal.timeout(options.timeoutMs ?? this.timeoutMs) });
|
|
360
455
|
// A stored 429 replays under an Idempotency-Key, so a keyed POST isn't retried on it. A rate-limited request never ran.
|
|
361
|
-
const rateLimited = res.status === 429 && !keyed && (await isRateLimited(res));
|
|
362
|
-
if (!((idempotent && RETRY_STATUSES.has(res.status)) || rateLimited) || attempt >= this.maxRetries)
|
|
456
|
+
const rateLimited = !noRetry && res.status === 429 && !keyed && (await isRateLimited(res));
|
|
457
|
+
if (!((!noRetry && idempotent && RETRY_STATUSES.has(res.status)) || rateLimited) || attempt >= this.maxRetries)
|
|
363
458
|
return res;
|
|
364
459
|
delay = retryAfterOf(res) ?? 0.5 * 2 ** attempt;
|
|
365
460
|
}
|
|
366
461
|
catch (e) {
|
|
367
462
|
const timedOut = e instanceof Error && e.name === "TimeoutError";
|
|
368
|
-
if (!idempotent || timedOut || attempt >= this.maxRetries)
|
|
463
|
+
if (noRetry || !idempotent || timedOut || attempt >= this.maxRetries)
|
|
369
464
|
throw e;
|
|
370
465
|
delay = 0.5 * 2 ** attempt;
|
|
371
466
|
}
|