insta 0.0.73 → 0.0.75
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/agent.js +4 -1
- package/dist/api.js +11 -4
- package/dist/commands/agent-policy.js +119 -19
- package/dist/commands/auth.js +115 -7
- package/dist/commands/compute.js +1132 -2
- package/dist/commands/domain.js +3 -1
- package/dist/commands/env.js +1 -0
- package/dist/commands/ssh-config.js +710 -0
- package/dist/commands/upgrade.js +15 -2
- package/dist/config.js +1 -0
- package/dist/index.js +21 -3
- package/dist/util.js +99 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -42,6 +42,8 @@ export async function saveAgentSession(session, cwd = process.cwd()) {
|
|
|
42
42
|
await chmod(join(root, rel), 0o600);
|
|
43
43
|
}
|
|
44
44
|
export async function setupProjectAgentSession(api, projectId) {
|
|
45
|
+
if (api.agentCredential)
|
|
46
|
+
return false; // Nothing to enroll: the key itself is the agent identity.
|
|
45
47
|
const id = projectId ?? (await readProject())?.projectId;
|
|
46
48
|
if (!id)
|
|
47
49
|
return false;
|
|
@@ -68,7 +70,8 @@ export async function loadAgentSession(apiUrl, projectId, cwd = process.cwd()) {
|
|
|
68
70
|
// account-level paths in src/commands/.
|
|
69
71
|
export const ACCOUNT_ROUTES = new Set(['agent', 'auth', 'me', 'orgs', 'regions', 'templates', 'tokens']);
|
|
70
72
|
export async function agentHeaders(api, method, path, rawBody, scope = {}) {
|
|
71
|
-
|
|
73
|
+
// A key minted by agent auth is already an agent principal on the platform and may not enroll a session.
|
|
74
|
+
if (!mode || api.agentCredential)
|
|
72
75
|
return {};
|
|
73
76
|
if (canonicalTarget(path) === '/agent/sessions' && method === 'POST')
|
|
74
77
|
return {
|
package/dist/api.js
CHANGED
|
@@ -25,11 +25,15 @@ export class AgentApprovalRequired extends Error {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
// Store a durable insta_ key as the credential: set it as the bearer and drop any refresh token (an insta_ key never rotates; a stale one would leak to /auth/refresh on a 401).
|
|
28
|
-
export function storeApiKeyCredential(cfg, token, user) {
|
|
28
|
+
export function storeApiKeyCredential(cfg, token, user, agentCredential = false) {
|
|
29
29
|
cfg.accessToken = token;
|
|
30
30
|
delete cfg.refreshToken;
|
|
31
31
|
if (user)
|
|
32
32
|
cfg.user = user;
|
|
33
|
+
if (agentCredential)
|
|
34
|
+
cfg.agentCredential = true;
|
|
35
|
+
else
|
|
36
|
+
delete cfg.agentCredential;
|
|
33
37
|
}
|
|
34
38
|
export class ApiClient {
|
|
35
39
|
cfg;
|
|
@@ -48,15 +52,18 @@ export class ApiClient {
|
|
|
48
52
|
this.cfg.refreshToken = tokens.refreshToken;
|
|
49
53
|
if (user)
|
|
50
54
|
this.cfg.user = user;
|
|
55
|
+
delete this.cfg.agentCredential;
|
|
51
56
|
}
|
|
52
57
|
// Adopt a durable insta_ key as the credential (non-interactive `login --api-key`).
|
|
53
|
-
setApiKey(token, user) {
|
|
54
|
-
storeApiKeyCredential(this.cfg, token, user);
|
|
58
|
+
setApiKey(token, user, agentCredential) {
|
|
59
|
+
storeApiKeyCredential(this.cfg, token, user, agentCredential);
|
|
55
60
|
}
|
|
61
|
+
get agentCredential() { return this.cfg.agentCredential === true; }
|
|
56
62
|
clearSession() {
|
|
57
63
|
delete this.cfg.accessToken;
|
|
58
64
|
delete this.cfg.refreshToken;
|
|
59
65
|
delete this.cfg.user;
|
|
66
|
+
delete this.cfg.agentCredential;
|
|
60
67
|
}
|
|
61
68
|
// Returns parsed body for status < 400 (incl. 202); throws ApiError otherwise.
|
|
62
69
|
async request(method, path, body, opts = {}) {
|
|
@@ -86,7 +93,7 @@ export class ApiClient {
|
|
|
86
93
|
const headers = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT };
|
|
87
94
|
if (auth && this.cfg.accessToken)
|
|
88
95
|
headers.Authorization = `Bearer ${this.cfg.accessToken}`;
|
|
89
|
-
if (auth)
|
|
96
|
+
if (auth && scope.evidence !== false)
|
|
90
97
|
Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body), scope));
|
|
91
98
|
const res = await this.fetchImpl(this.apiUrl + path, {
|
|
92
99
|
method,
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { ApiClient, requireProject } from '../api.js';
|
|
2
2
|
import { handleApproval, info, printJson } from '../util.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
const PRESETS = ['full_access', 'read_only', 'branch_specific'];
|
|
4
|
+
const DECISIONS = ['allow', 'deny', 'approve'];
|
|
5
|
+
async function current(deps) {
|
|
6
|
+
const api = deps?.api ?? await ApiClient.load();
|
|
7
|
+
const project = deps?.project ?? await requireProject();
|
|
6
8
|
const path = `/projects/${project.projectId}/agent-policy`;
|
|
7
9
|
const out = await api.request('GET', path);
|
|
10
|
+
// `branchDeveloperRules` is a deprecated mirror of `rules`, kept in the response so that a CLI
|
|
11
|
+
// predating the rename can still edit it in place. Platform takes the legacy field over `rules`
|
|
12
|
+
// when a body carries both -- exactly so that old CLI's edit is not dropped -- so echoing the
|
|
13
|
+
// mirror back would silently discard everything this version writes. Only where Platform speaks
|
|
14
|
+
// the new contract, though: `actionCatalog` is how it announces that, and without one the mirror
|
|
15
|
+
// is the only rule set that exists, so dropping it would erase the project's overrides on the
|
|
16
|
+
// next PUT -- from `rule set`, but also from a `protect-branch` that never mentions rules.
|
|
17
|
+
if (out.policy && out.actionCatalog)
|
|
18
|
+
delete out.policy.branchDeveloperRules;
|
|
8
19
|
return { api, project, path, policy: out.policy, out };
|
|
9
20
|
}
|
|
10
21
|
export function displayPolicy(out, opts, output = { info, printJson }) {
|
|
@@ -12,7 +23,9 @@ export function displayPolicy(out, opts, output = { info, printJson }) {
|
|
|
12
23
|
const { policy, agentSessionEpoch } = out;
|
|
13
24
|
if (opts.json)
|
|
14
25
|
return printJson(out);
|
|
15
|
-
|
|
26
|
+
const overrides = Object.keys(policy.rules ?? policy.branchDeveloperRules ?? {}).length;
|
|
27
|
+
info(`agent policy: ${policy.mode}${overrides ? ` (${overrides} rule${overrides === 1 ? '' : 's'})` : ''}`
|
|
28
|
+
+ `\nprotected branches: ${policy.protectedBranchIds.join(', ') || '(none)'}\nsession epoch: ${agentSessionEpoch}`);
|
|
16
29
|
if (out.effectiveRules) {
|
|
17
30
|
for (const [scope, rules] of Object.entries(out.effectiveRules)) {
|
|
18
31
|
info(`\n${scope}:`);
|
|
@@ -31,35 +44,122 @@ export async function get(opts) {
|
|
|
31
44
|
// Forward the public response, never the internal API client (which holds credentials).
|
|
32
45
|
displayPolicy(out, opts);
|
|
33
46
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The decisions that moved but were NOT asked for. Switching off `full_access` is the case that
|
|
49
|
+
* needs this: the fixed invariants (project.delete, agent_policy.update, …) stop being allowed and
|
|
50
|
+
* protected branches begin to apply, so a one-rule edit can move a dozen decisions. Diffed from
|
|
51
|
+
* Platform's own resolved view before and after, never re-derived here — the same reason this CLI
|
|
52
|
+
* does not evaluate a policy locally.
|
|
53
|
+
*/
|
|
54
|
+
export function sideEffects(before, after, asked) {
|
|
55
|
+
const from = before.effectiveRules, to = after.effectiveRules;
|
|
56
|
+
if (!from || !to)
|
|
57
|
+
return [];
|
|
58
|
+
const lines = [];
|
|
59
|
+
for (const scope of Object.keys(to)) {
|
|
60
|
+
for (const [action, decision] of Object.entries(to[scope])) {
|
|
61
|
+
// The named action is suppressed only in the context the rule governs. That same action
|
|
62
|
+
// moving in another context is a consequence of the mode change, not the edit that was asked
|
|
63
|
+
// for — `rule set deploy deny` off full_access also starts denying deploy on protected
|
|
64
|
+
// branches, and that is precisely what the caller needs told.
|
|
65
|
+
if ((scope === 'unprotectedBranch' && asked.includes(action)) || from[scope]?.[action] === decision)
|
|
66
|
+
continue;
|
|
67
|
+
lines.push(` ${scope}.${action}: ${from[scope]?.[action] ?? '(unknown)'} -> ${decision}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The policy a `rule set` should PUT. Exported because the interesting part is not the request:
|
|
74
|
+
* leaving a preset has to snapshot every decision it was already making, or setting one rule
|
|
75
|
+
* silently rewrites all the others to whatever the new mode's defaults happen to be.
|
|
76
|
+
*/
|
|
77
|
+
export function applyRule(policy, out, action, decision) {
|
|
78
|
+
const catalog = out.actionCatalog;
|
|
79
|
+
// A Platform predating the rename has neither a catalog nor a `rules` field, and reads only the
|
|
80
|
+
// old one. Nothing below applies there.
|
|
81
|
+
if (!catalog)
|
|
82
|
+
return { ...policy, branchDeveloperRules: { ...(policy.branchDeveloperRules ?? {}), [action]: decision } };
|
|
83
|
+
const entry = catalog.find(e => e.action === action);
|
|
84
|
+
if (!entry)
|
|
85
|
+
throw new Error(`unknown action: ${action}\nrun: insta agent-policy get --json`);
|
|
86
|
+
if (!entry.editable)
|
|
87
|
+
throw new Error(`${action} is a fixed policy invariant and cannot be overridden`);
|
|
88
|
+
// Snapshot taken from the resolved unprotected-branch view, which is the context these rules
|
|
89
|
+
// apply to; protected branches stay a separate, fixed denial.
|
|
90
|
+
const base = policy.mode === 'customize'
|
|
91
|
+
? policy.rules
|
|
92
|
+
: Object.fromEntries(catalog.filter(e => e.editable).map(e => [e.action, out.effectiveRules.unprotectedBranch[e.action]]));
|
|
93
|
+
return { ...policy, mode: 'customize', rules: { ...base, [action]: decision } };
|
|
94
|
+
}
|
|
95
|
+
/** `change` either mutates the policy in place or returns the replacement. */
|
|
96
|
+
// `asked` opts into the side-effect report and names the actions the caller chose. Only `rule set`
|
|
97
|
+
// passes it: a mode change is expected to move everything, so listing the whole table is noise.
|
|
98
|
+
async function update(change, opts, asked, deps) {
|
|
99
|
+
const state = await current(deps);
|
|
100
|
+
const next = (await change(state.policy, state)) ?? state.policy;
|
|
101
|
+
const result = await state.api.rawRequest('PUT', state.path, next);
|
|
38
102
|
if (handleApproval(result, opts.json))
|
|
39
103
|
return;
|
|
104
|
+
// Printing does not end the command: the side-effect report below is owed to `--json` too, since
|
|
105
|
+
// a scripted caller is the one least able to notice for itself that leaving a preset moved
|
|
106
|
+
// decisions it never named. The report is on stderr, so stdout stays a parseable document.
|
|
40
107
|
if (opts.json)
|
|
41
|
-
|
|
42
|
-
|
|
108
|
+
printJson(result.body);
|
|
109
|
+
else
|
|
110
|
+
info(`agent policy updated: ${result.body.policy.mode}`);
|
|
111
|
+
if (!asked)
|
|
112
|
+
return;
|
|
113
|
+
// The PUT already landed. A failed diagnostic GET costs the comparison, not the update: rejecting
|
|
114
|
+
// here would report a mutation that happened as a failure, and invite a retry of a done edit.
|
|
115
|
+
let after;
|
|
116
|
+
try {
|
|
117
|
+
after = await state.api.request('GET', state.path);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
process.stderr.write('note: could not determine whether this changed decisions you did not name\n');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const moved = sideEffects(state.out, after, asked);
|
|
124
|
+
if (moved.length)
|
|
125
|
+
process.stderr.write(`note: this also changed decisions you did not name:\n${moved.join('\n')}\n`);
|
|
43
126
|
}
|
|
44
|
-
export async function set(mode, opts) {
|
|
127
|
+
export async function set(mode, opts, deps) {
|
|
45
128
|
const normalized = mode.replace(/-/g, '_');
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
129
|
+
// `branch_developer` is the pre-rename name for `branch_specific`, still accepted here so a
|
|
130
|
+
// script written against the old CLI keeps working. `customize` is deliberately NOT settable:
|
|
131
|
+
// it means "carrying overrides", which is what `agent-policy rule set` produces.
|
|
132
|
+
const resolved = normalized === 'branch_developer' ? 'branch_specific' : normalized;
|
|
133
|
+
if (!PRESETS.includes(resolved))
|
|
134
|
+
throw new Error('mode must be full-access, read-only, or branch-specific');
|
|
135
|
+
// A preset carries no rules; Platform refuses one that does. Which field carries "no rules"
|
|
136
|
+
// depends on the same capability check `applyRule` makes: without an `actionCatalog` Platform
|
|
137
|
+
// predates the rename, so it only knows the old mode name and only reads the old rule field —
|
|
138
|
+
// clearing `rules` there would leave every override standing under the new mode.
|
|
139
|
+
return update((policy, { out }) => {
|
|
140
|
+
if (out.actionCatalog) {
|
|
141
|
+
policy.mode = resolved;
|
|
142
|
+
policy.rules = {};
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
policy.mode = resolved === 'branch_specific' ? 'branch_developer' : resolved;
|
|
146
|
+
policy.branchDeveloperRules = {};
|
|
147
|
+
}
|
|
148
|
+
}, opts, undefined, deps);
|
|
49
149
|
}
|
|
50
|
-
export async function protect(branch, enabled, opts) {
|
|
150
|
+
export async function protect(branch, enabled, opts, deps) {
|
|
51
151
|
return update(async (policy, { api, project }) => {
|
|
52
152
|
const { branches } = await api.request('GET', `/projects/${project.projectId}/branches`);
|
|
53
153
|
const found = branches.find((b) => b.id === branch || b.name === branch);
|
|
54
154
|
if (!found)
|
|
55
155
|
throw new Error('branch not found');
|
|
56
156
|
policy.protectedBranchIds = enabled ? [...new Set([...policy.protectedBranchIds, found.id])] : policy.protectedBranchIds.filter((id) => id !== found.id);
|
|
57
|
-
}, opts);
|
|
157
|
+
}, opts, undefined, deps);
|
|
58
158
|
}
|
|
59
|
-
export async function rule(action, decision, opts) {
|
|
60
|
-
if (!
|
|
159
|
+
export async function rule(action, decision, opts, deps) {
|
|
160
|
+
if (!DECISIONS.includes(decision))
|
|
61
161
|
throw new Error('decision must be allow, deny, or approve');
|
|
62
|
-
return update(policy =>
|
|
162
|
+
return update((policy, { out }) => applyRule(policy, out, action, decision), opts, [action], deps);
|
|
63
163
|
}
|
|
64
164
|
export async function revoke(opts) {
|
|
65
165
|
const api = await ApiClient.load();
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { ApiClient, ApiError, linkedProject } from '../api.js';
|
|
4
|
+
import { agentMode } from '../agent.js';
|
|
4
5
|
import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js';
|
|
5
6
|
import { info, die, printJson, promptPassword, openUrl } from '../util.js';
|
|
6
7
|
/** --api-url and --env both set the target host; --api-url wins (more specific), matching the
|
|
@@ -16,15 +17,23 @@ function targetApiUrl(opts) {
|
|
|
16
17
|
die(`unknown --env "${opts.env}" — expected one of: ${ENV_NAMES.join(', ')}`);
|
|
17
18
|
return ENVS[want].api;
|
|
18
19
|
}
|
|
19
|
-
// `device`
|
|
20
|
-
export async function login(opts, device = loginDevice) {
|
|
20
|
+
// `device`/`claim` are injectable so the dispatch itself is testable (repo pattern: DI fakes, no mocks).
|
|
21
|
+
export async function login(opts, device = loginDevice, claim = loginClaim) {
|
|
21
22
|
// Login modes are exclusive — pick one. Check presence (not truthiness) so an explicit
|
|
22
23
|
// empty --api-key= is rejected by validation rather than silently falling through.
|
|
23
24
|
if (opts.apiKey !== undefined) {
|
|
24
|
-
if (opts.device || opts.oauth || opts.email)
|
|
25
|
-
die('choose one login mode: --api-key, --device, --oauth, or --email');
|
|
25
|
+
if (opts.device || opts.oauth !== undefined || opts.email !== undefined || opts.claim !== undefined || opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined)
|
|
26
|
+
die('choose one login mode: --api-key, --claim, --device, --oauth, or --email');
|
|
26
27
|
return loginApiKey(opts.apiKey, opts);
|
|
27
28
|
}
|
|
29
|
+
if (opts.claim !== undefined) {
|
|
30
|
+
if (opts.device || opts.oauth !== undefined || opts.email !== undefined || opts.password !== undefined || process.env.INSTA_PASSWORD !== undefined)
|
|
31
|
+
die('choose one login mode: --api-key, --claim, --device, --oauth, or --email (a password belongs to --email)');
|
|
32
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(opts.claim))
|
|
33
|
+
die('--claim needs an email address: the account that will authorize this agent');
|
|
34
|
+
// The human types the code on the console; open it here only when a browser is on this machine.
|
|
35
|
+
return claim(opts.claim, opts, agentMode() ? undefined : openUrl);
|
|
36
|
+
}
|
|
28
37
|
if (opts.device)
|
|
29
38
|
return device(opts);
|
|
30
39
|
if (opts.oauth)
|
|
@@ -83,6 +92,19 @@ export async function loginDevice(opts, open) {
|
|
|
83
92
|
await api.persist();
|
|
84
93
|
info(`logged in as ${me.user.email ?? me.user.id} @ ${api.apiUrl}`);
|
|
85
94
|
}
|
|
95
|
+
// `insta login --claim <email>`: the auth.md user claimed flow. The named user confirms a code on
|
|
96
|
+
// the console, the platform mints an insta_ key, and it is stored exactly as --api-key stores one.
|
|
97
|
+
export async function loginClaim(email, opts, open, grant = claimGrant) {
|
|
98
|
+
const api = await ApiClient.load();
|
|
99
|
+
const target = targetApiUrl(opts);
|
|
100
|
+
if (target)
|
|
101
|
+
api.setApiUrl(target);
|
|
102
|
+
const client = agentMode()?.client ?? 'unknown';
|
|
103
|
+
const key = await grant(email, client, (path, body, signal) => api.request('POST', path, body, { auth: false, signal }), sleepSeconds, open);
|
|
104
|
+
const user = await applyApiKeyLogin(api, key);
|
|
105
|
+
await api.persist();
|
|
106
|
+
info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
|
|
107
|
+
}
|
|
86
108
|
// Non-interactive login with a durable insta_ key (minted via POST /tokens): store it and confirm against /me. No browser, no polling.
|
|
87
109
|
export async function loginApiKey(key, opts) {
|
|
88
110
|
const api = await ApiClient.load();
|
|
@@ -93,7 +115,7 @@ export async function loginApiKey(key, opts) {
|
|
|
93
115
|
await api.persist();
|
|
94
116
|
info(`logged in as ${user.email ?? user.id} @ ${api.apiUrl}`);
|
|
95
117
|
}
|
|
96
|
-
// Verify an insta_ key
|
|
118
|
+
// Verify an insta_ key with a bare /me probe (an agent-minted key cannot enroll a session, and /me says which kind this is), then store it with the user and that kind.
|
|
97
119
|
export async function applyApiKeyLogin(client, key) {
|
|
98
120
|
key = key.trim(); // tolerate a trailing newline / stray whitespace from `--api-key "$(cat token)"`
|
|
99
121
|
if (!key.startsWith('insta_'))
|
|
@@ -101,7 +123,7 @@ export async function applyApiKeyLogin(client, key) {
|
|
|
101
123
|
client.setApiKey(key);
|
|
102
124
|
let me;
|
|
103
125
|
try {
|
|
104
|
-
me = await client.request('GET', '/me');
|
|
126
|
+
me = await client.request('GET', '/me', undefined, { evidence: false });
|
|
105
127
|
}
|
|
106
128
|
catch (e) {
|
|
107
129
|
if (e instanceof ApiError && e.status === 401)
|
|
@@ -110,7 +132,7 @@ export async function applyApiKeyLogin(client, key) {
|
|
|
110
132
|
}
|
|
111
133
|
if (!me?.user)
|
|
112
134
|
throw new Error('unexpected response while verifying the API key');
|
|
113
|
-
client.setApiKey(key, me.user);
|
|
135
|
+
client.setApiKey(key, me.user, me.agentCredential === true);
|
|
114
136
|
return me.user;
|
|
115
137
|
}
|
|
116
138
|
const sleepSeconds = (s) => new Promise((r) => setTimeout(r, s * 1000));
|
|
@@ -192,6 +214,92 @@ export async function deviceGrant(post, wait = sleepSeconds, open) {
|
|
|
192
214
|
}
|
|
193
215
|
throw new Error(`device login expired before it was approved — run \`insta login${open ? '' : ' --device'}\` again`);
|
|
194
216
|
}
|
|
217
|
+
// auth.md user claimed flow (service_auth). The agent knows the user's email; InstaCloud gives a
|
|
218
|
+
// 6-digit code and a console link; only a session for that email can type the code. We poll the
|
|
219
|
+
// standard token endpoint with the WorkOS claim grant and get an insta_ key back. Poster + wait
|
|
220
|
+
// are injected like deviceGrant's. JSON bodies: the platform's token route accepts them.
|
|
221
|
+
const CLAIM_GRANT = 'urn:workos:agent-auth:grant-type:claim';
|
|
222
|
+
// A poll interval is seconds, from the server: clamp it so a silly value cannot become a ~1 ms timer.
|
|
223
|
+
const pollInterval = (value, fallback) => {
|
|
224
|
+
const n = Number(value);
|
|
225
|
+
return Number.isFinite(n) ? Math.min(Math.max(n, 1), 3600) : fallback;
|
|
226
|
+
};
|
|
227
|
+
export async function claimGrant(email, client, post, wait = sleepSeconds, open, now = Date.now) {
|
|
228
|
+
const start = (await post('/agent/auth', { type: 'service_auth', login_hint: email, client }));
|
|
229
|
+
if (!start?.claim_token || !start.claim?.user_code || !start.claim.verification_uri) {
|
|
230
|
+
throw new Error('malformed registration response (missing claim) — is the platform up to date?');
|
|
231
|
+
}
|
|
232
|
+
const expiresAt = Date.parse(start.claim_token_expires);
|
|
233
|
+
const deadline = Math.min(Number.isFinite(expiresAt) ? expiresAt : Infinity, now() + 86_400_000);
|
|
234
|
+
const show = (block, fresh) => {
|
|
235
|
+
if (fresh)
|
|
236
|
+
info('the code expired — here is a new one.');
|
|
237
|
+
if (open) {
|
|
238
|
+
info('opening your browser…');
|
|
239
|
+
open(block.verification_uri);
|
|
240
|
+
}
|
|
241
|
+
info(`to authorize this agent, open this link, sign in as ${email}, and enter this code: ${block.user_code}`);
|
|
242
|
+
info(` ${block.verification_uri}`);
|
|
243
|
+
};
|
|
244
|
+
show(start.claim, false);
|
|
245
|
+
info(`waiting for ${email} to confirm… (ctrl-c to abort)`);
|
|
246
|
+
let interval = pollInterval(start.claim.interval, 5);
|
|
247
|
+
let reminted = false;
|
|
248
|
+
const expired = () => new Error(`the request expired before ${email} confirmed it — run \`insta login --claim ${email}\` again`);
|
|
249
|
+
while (now() < deadline) {
|
|
250
|
+
await wait(interval);
|
|
251
|
+
const remaining = deadline - now();
|
|
252
|
+
if (remaining <= 0)
|
|
253
|
+
throw expired();
|
|
254
|
+
const signal = AbortSignal.timeout(Math.ceil(Math.min(remaining, 30_000)));
|
|
255
|
+
let grant = null;
|
|
256
|
+
try {
|
|
257
|
+
grant = (await post('/api/auth/oauth2/token', { grant_type: CLAIM_GRANT, claim_token: start.claim_token }, signal));
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
if (e instanceof Error && (e.name === 'TimeoutError' || e.name === 'AbortError')) {
|
|
261
|
+
if (now() >= deadline)
|
|
262
|
+
throw expired();
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (!(e instanceof ApiError))
|
|
266
|
+
continue; // transport blip — keep polling until the deadline
|
|
267
|
+
const code = e.message;
|
|
268
|
+
if (code === 'authorization_pending')
|
|
269
|
+
continue;
|
|
270
|
+
if (code === 'slow_down' || e.status === 429) {
|
|
271
|
+
interval = Math.min(interval + 5, 3600);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (code === 'expired_token') {
|
|
275
|
+
if (reminted)
|
|
276
|
+
throw expired();
|
|
277
|
+
let again;
|
|
278
|
+
try {
|
|
279
|
+
again = (await post('/agent/auth/claim', { claim_token: start.claim_token, email }, signal));
|
|
280
|
+
}
|
|
281
|
+
catch (re) {
|
|
282
|
+
if (re instanceof ApiError && re.message === 'claim_expired')
|
|
283
|
+
throw expired();
|
|
284
|
+
if (!(re instanceof ApiError))
|
|
285
|
+
continue; // re-mint timeout or transport blip: the next expired_token asks again
|
|
286
|
+
throw re;
|
|
287
|
+
}
|
|
288
|
+
reminted = true;
|
|
289
|
+
if (!again?.claim_attempt?.user_code || !again.claim_attempt.verification_uri)
|
|
290
|
+
throw new Error('malformed claim response (missing claim_attempt)');
|
|
291
|
+
interval = pollInterval(again.claim_attempt.interval, interval);
|
|
292
|
+
show(again.claim_attempt, true);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
throw e; // invalid_grant and friends: not retryable
|
|
296
|
+
}
|
|
297
|
+
if (!grant?.access_token)
|
|
298
|
+
throw new Error('malformed token response (missing access_token)');
|
|
299
|
+
return grant.access_token;
|
|
300
|
+
}
|
|
301
|
+
throw expired();
|
|
302
|
+
}
|
|
195
303
|
// Start a loopback server, open the browser at the platform bridge, and await the token.
|
|
196
304
|
function browserOauth(apiUrl, provider) {
|
|
197
305
|
return new Promise((resolve, reject) => {
|