@armoriq/sdk-dev 0.6.10 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -1
- package/dist/_version.d.ts +1 -1
- package/dist/_version.d.ts.map +1 -1
- package/dist/_version.js +1 -1
- package/dist/_version.js.map +1 -1
- package/dist/cli/commands/auth.d.ts +12 -0
- package/dist/cli/commands/auth.d.ts.map +1 -1
- package/dist/cli/commands/auth.js +522 -49
- package/dist/cli/commands/auth.js.map +1 -1
- package/dist/client.d.ts +8 -15
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -18
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +0 -17
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -19
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -14
- package/dist/index.js.map +1 -1
- package/dist/integrations/google_adk.d.ts +155 -7
- package/dist/integrations/google_adk.d.ts.map +1 -1
- package/dist/integrations/google_adk.js +727 -46
- package/dist/integrations/google_adk.js.map +1 -1
- package/dist/integrations/langchain.d.ts +48 -2
- package/dist/integrations/langchain.d.ts.map +1 -1
- package/dist/integrations/langchain.js +528 -33
- package/dist/integrations/langchain.js.map +1 -1
- package/dist/integrations/strands.d.ts +65 -1
- package/dist/integrations/strands.d.ts.map +1 -1
- package/dist/integrations/strands.js +456 -36
- package/dist/integrations/strands.js.map +1 -1
- package/dist/models.d.ts +2 -2
- package/dist/models.d.ts.map +1 -1
- package/dist/observability/content-capture.d.ts +103 -0
- package/dist/observability/content-capture.d.ts.map +1 -0
- package/dist/observability/content-capture.js +423 -0
- package/dist/observability/content-capture.js.map +1 -0
- package/dist/observability/index.d.ts +6 -7
- package/dist/observability/index.d.ts.map +1 -1
- package/dist/observability/index.js +18 -27
- package/dist/observability/index.js.map +1 -1
- package/dist/observability/otel-config.d.ts +47 -0
- package/dist/observability/otel-config.d.ts.map +1 -0
- package/dist/observability/otel-config.js +268 -0
- package/dist/observability/otel-config.js.map +1 -0
- package/dist/observability/otel-export-ceiling.d.ts +96 -0
- package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
- package/dist/observability/otel-export-ceiling.js +271 -0
- package/dist/observability/otel-export-ceiling.js.map +1 -0
- package/dist/observability/otel-runtime.d.ts +103 -0
- package/dist/observability/otel-runtime.d.ts.map +1 -0
- package/dist/observability/otel-runtime.js +680 -0
- package/dist/observability/otel-runtime.js.map +1 -0
- package/dist/observability/otel-session.d.ts +168 -0
- package/dist/observability/otel-session.d.ts.map +1 -0
- package/dist/observability/otel-session.js +630 -0
- package/dist/observability/otel-session.js.map +1 -0
- package/dist/observability/otel-shutdown.d.ts +17 -0
- package/dist/observability/otel-shutdown.d.ts.map +1 -0
- package/dist/observability/otel-shutdown.js +54 -0
- package/dist/observability/otel-shutdown.js.map +1 -0
- package/dist/observability/policy-lease.d.ts +22 -0
- package/dist/observability/policy-lease.d.ts.map +1 -0
- package/dist/observability/policy-lease.js +102 -0
- package/dist/observability/policy-lease.js.map +1 -0
- package/dist/plan_builder.d.ts +5 -4
- package/dist/plan_builder.d.ts.map +1 -1
- package/dist/plan_builder.js +14 -15
- package/dist/plan_builder.js.map +1 -1
- package/dist/session.d.ts +61 -93
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +388 -804
- package/dist/session.js.map +1 -1
- package/dist/token_usage.d.ts +11 -18
- package/dist/token_usage.d.ts.map +1 -1
- package/dist/token_usage.js +29 -94
- package/dist/token_usage.js.map +1 -1
- package/dist/tool_name.d.ts +18 -0
- package/dist/tool_name.d.ts.map +1 -0
- package/dist/tool_name.js +29 -0
- package/dist/tool_name.js.map +1 -0
- package/dist/tool_push.d.ts +28 -0
- package/dist/tool_push.d.ts.map +1 -0
- package/dist/tool_push.js +151 -0
- package/dist/tool_push.js.map +1 -0
- package/dist/tool_registry.d.ts +100 -0
- package/dist/tool_registry.d.ts.map +1 -0
- package/dist/tool_registry.js +440 -0
- package/dist/tool_registry.js.map +1 -0
- package/dist/tool_schema.d.ts +22 -0
- package/dist/tool_schema.d.ts.map +1 -0
- package/dist/tool_schema.js +163 -0
- package/dist/tool_schema.js.map +1 -0
- package/package.json +12 -7
|
@@ -44,12 +44,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
44
44
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
45
45
|
};
|
|
46
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
+
exports.loadOrCreateDeviceId = loadOrCreateDeviceId;
|
|
47
48
|
exports.cmdLogin = cmdLogin;
|
|
48
49
|
exports.cmdLogout = cmdLogout;
|
|
49
50
|
exports.cmdWhoami = cmdWhoami;
|
|
50
51
|
const crypto = __importStar(require("crypto"));
|
|
52
|
+
const fs = __importStar(require("fs"));
|
|
51
53
|
const http = __importStar(require("http"));
|
|
52
54
|
const net = __importStar(require("net"));
|
|
55
|
+
const os = __importStar(require("os"));
|
|
56
|
+
const path = __importStar(require("path"));
|
|
53
57
|
const url_1 = require("url");
|
|
54
58
|
const child_process_1 = require("child_process");
|
|
55
59
|
const axios_1 = __importDefault(require("axios"));
|
|
@@ -64,15 +68,214 @@ const PRODUCT_LABELS = {
|
|
|
64
68
|
platform: 'ArmorIQ Platform',
|
|
65
69
|
sdk: 'ArmorIQ SDK',
|
|
66
70
|
};
|
|
71
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
72
|
+
function getDeviceIdentityPath() {
|
|
73
|
+
return (process.env.ARMORIQ_DEVICE_ID_PATH ?? path.join(path.dirname((0, credentials_1.getCredentialsPath)()), 'device-id'));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A device identity was written but could not be durably persisted.
|
|
77
|
+
*
|
|
78
|
+
* Distinct from a collision or a corrupt file: callers may read a concurrent
|
|
79
|
+
* winner, while corrupt files require manual recovery. This invocation must
|
|
80
|
+
* report no identity rather than vouch for bytes whose write or fsync failed.
|
|
81
|
+
*/
|
|
82
|
+
class DurabilityError extends Error {
|
|
83
|
+
}
|
|
84
|
+
/** One reusable cell for Atomics.wait; the value is never read. */
|
|
85
|
+
const IDLE_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
86
|
+
const WAIT_ATTEMPTS = 20;
|
|
87
|
+
const WAIT_INTERVAL_MS = 10;
|
|
88
|
+
function readDeviceId(identityPath, identityFs) {
|
|
89
|
+
try {
|
|
90
|
+
const existing = identityFs.readFileSync(identityPath, 'utf8').trim();
|
|
91
|
+
return UUID_RE.test(existing) ? existing.toLowerCase() : null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function isAlreadyExists(error) {
|
|
98
|
+
return error?.code === 'EEXIST';
|
|
99
|
+
}
|
|
100
|
+
function deviceIdentityPathExists(identityPath, identityFs) {
|
|
101
|
+
try {
|
|
102
|
+
identityFs.statSync(identityPath);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
// Treat an inaccessible path as occupied. Attempting publication cannot
|
|
107
|
+
// repair permissions and must never replace something we could not read.
|
|
108
|
+
return error?.code !== 'ENOENT';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Wait briefly for a valid identity to appear. Reached whenever the path holds
|
|
113
|
+
* something unparseable, which is either a creator caught between publishing
|
|
114
|
+
* the path and writing its UUID (a create race, or an in-progress write another
|
|
115
|
+
* process started before this one looked) or a file that really is corrupt.
|
|
116
|
+
*/
|
|
117
|
+
function awaitIdentity(identityPath, identityFs) {
|
|
118
|
+
for (let attempt = 0; attempt < WAIT_ATTEMPTS; attempt += 1) {
|
|
119
|
+
const value = readDeviceId(identityPath, identityFs);
|
|
120
|
+
if (value)
|
|
121
|
+
return value;
|
|
122
|
+
Atomics.wait(IDLE_CELL, 0, 0, WAIT_INTERVAL_MS);
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Install `tempPath` at `identityPath` only if nothing is there, and report the
|
|
128
|
+
* winner.
|
|
129
|
+
*
|
|
130
|
+
* link() is create-if-absent and publishes the path already fully written, so
|
|
131
|
+
* exactly one caller can ever install and no reader can observe a half-written
|
|
132
|
+
* file. With `flag: 'wx'` the file exists and is empty for an instant, and a
|
|
133
|
+
* concurrent login reading it in that window sees a corrupt identity.
|
|
134
|
+
*/
|
|
135
|
+
function publishIdentity(identityPath, tempPath, candidate, identityFs) {
|
|
136
|
+
try {
|
|
137
|
+
identityFs.linkSync(tempPath, identityPath);
|
|
138
|
+
return candidate;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (isAlreadyExists(error)) {
|
|
142
|
+
return readDeviceId(identityPath, identityFs) ?? awaitIdentity(identityPath, identityFs);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Filesystems without hard links (some network and Windows mounts).
|
|
146
|
+
// Exclusive create is the next best thing; it carries the brief empty-file
|
|
147
|
+
// window that link() avoids.
|
|
148
|
+
//
|
|
149
|
+
// Opened by descriptor rather than writeFileSync so the create and the write
|
|
150
|
+
// are separable: exclusive create publishes the path before it is written,
|
|
151
|
+
// and the failure branch has to know that it did.
|
|
152
|
+
let fd;
|
|
153
|
+
try {
|
|
154
|
+
fd = identityFs.openSync(identityPath, 'wx', 0o600);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (!isAlreadyExists(error))
|
|
158
|
+
return null;
|
|
159
|
+
return readDeviceId(identityPath, identityFs) ?? awaitIdentity(identityPath, identityFs);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
identityFs.writeSync(fd, `${candidate}\n`);
|
|
163
|
+
identityFs.fsyncSync(fd);
|
|
164
|
+
return candidate;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Whatever this created is deliberately left in place. Removing it cannot
|
|
168
|
+
// be done safely: verifying the inode and unlinking the pathname are
|
|
169
|
+
// separate operations, so a concurrent publisher could install a real
|
|
170
|
+
// identity in between and have its winner deleted. A later login reports
|
|
171
|
+
// the incomplete file and asks the operator to recover it manually.
|
|
172
|
+
//
|
|
173
|
+
// Thrown rather than returned: the bytes may well have landed before the
|
|
174
|
+
// fsync failed, and re-reading them would report an identity whose
|
|
175
|
+
// durability just failed as a stable one.
|
|
176
|
+
throw new DurabilityError('device identity could not be persisted');
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
try {
|
|
180
|
+
identityFs.closeSync(fd);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Already closed.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The installation's stable, opaque device id, or null when it could not be
|
|
189
|
+
* persisted.
|
|
190
|
+
*
|
|
191
|
+
* Null rather than an unpersisted UUID: a value that is not on disk differs on
|
|
192
|
+
* every login, and the backend keys per-device key rotation on it, so returning
|
|
193
|
+
* one would provision a new key each time for what is one installation.
|
|
194
|
+
*/
|
|
195
|
+
function loadOrCreateDeviceId(identityFs = fs) {
|
|
196
|
+
const identityPath = getDeviceIdentityPath();
|
|
197
|
+
const existing = readDeviceId(identityPath, identityFs);
|
|
198
|
+
if (existing)
|
|
199
|
+
return existing;
|
|
200
|
+
// Existing but invalid identity files belong to the operator. Do not rename,
|
|
201
|
+
// truncate, or replace them automatically; recovery must be an explicit
|
|
202
|
+
// manual action after the contents have been inspected. But an unparseable
|
|
203
|
+
// file may also be a creator caught between publishing the path and writing
|
|
204
|
+
// its UUID before this process ever looked, so wait briefly first: giving up
|
|
205
|
+
// here would report no identity for a file that is simply not written yet.
|
|
206
|
+
// Only a file that stays unparseable through the wait is the operator's, and
|
|
207
|
+
// awaitIdentity returns null for it, leaving it untouched.
|
|
208
|
+
if (deviceIdentityPathExists(identityPath, identityFs)) {
|
|
209
|
+
return awaitIdentity(identityPath, identityFs);
|
|
210
|
+
}
|
|
211
|
+
const candidate = crypto.randomUUID();
|
|
212
|
+
const tempPath = `${identityPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}`;
|
|
213
|
+
try {
|
|
214
|
+
identityFs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
|
215
|
+
identityFs.writeFileSync(tempPath, `${candidate}\n`, { mode: 0o600 });
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
try {
|
|
222
|
+
const published = publishIdentity(identityPath, tempPath, candidate, identityFs);
|
|
223
|
+
return published;
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (error instanceof DurabilityError)
|
|
227
|
+
return null;
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
try {
|
|
233
|
+
identityFs.unlinkSync(tempPath);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// Already consumed by rename(), or never created.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const DEFAULT_DASHBOARD_URL = 'https://tools.armoriq.ai';
|
|
241
|
+
const TOOL_CATALOG_PRODUCTS = new Set(['armorclaude', 'armorcodex']);
|
|
242
|
+
const CALLBACK_SETTLED_GRACE_MS = 1000;
|
|
243
|
+
const PROOF_SERVER_DEADLINE_MS = 15_000;
|
|
244
|
+
function configuredDashboardUrl() {
|
|
245
|
+
let dashboardUrl;
|
|
246
|
+
try {
|
|
247
|
+
dashboardUrl = new url_1.URL(process.env.ARMORIQ_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL);
|
|
248
|
+
if (dashboardUrl.protocol !== 'https:' && dashboardUrl.protocol !== 'http:') {
|
|
249
|
+
dashboardUrl = new url_1.URL(DEFAULT_DASHBOARD_URL);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
dashboardUrl = new url_1.URL(DEFAULT_DASHBOARD_URL);
|
|
254
|
+
}
|
|
255
|
+
dashboardUrl.username = '';
|
|
256
|
+
dashboardUrl.password = '';
|
|
257
|
+
return dashboardUrl;
|
|
258
|
+
}
|
|
259
|
+
function buildDashboardRedirect(product) {
|
|
260
|
+
const dashboardUrl = configuredDashboardUrl();
|
|
261
|
+
// Treat the configured value as an origin only. This prevents stale query,
|
|
262
|
+
// fragment, or user-info data from being carried into the callback redirect.
|
|
263
|
+
dashboardUrl.pathname = '/tools';
|
|
264
|
+
dashboardUrl.search = '';
|
|
265
|
+
dashboardUrl.hash = '';
|
|
266
|
+
const productKey = (product ?? '').toLowerCase().trim();
|
|
267
|
+
if (TOOL_CATALOG_PRODUCTS.has(productKey)) {
|
|
268
|
+
dashboardUrl.searchParams.set('product', productKey);
|
|
269
|
+
}
|
|
270
|
+
return dashboardUrl.toString();
|
|
271
|
+
}
|
|
67
272
|
function escapeHtml(s) {
|
|
68
273
|
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c);
|
|
69
274
|
}
|
|
70
275
|
function renderSuccessHtml(opts) {
|
|
71
276
|
const productKey = (opts.product ?? '').toLowerCase().trim();
|
|
72
277
|
const productLabel = PRODUCT_LABELS[productKey] ?? null;
|
|
73
|
-
const emailHtml = opts.email
|
|
74
|
-
? `<div class="email">${escapeHtml(opts.email)}</div>`
|
|
75
|
-
: '';
|
|
278
|
+
const emailHtml = opts.email ? `<div class="email">${escapeHtml(opts.email)}</div>` : '';
|
|
76
279
|
const productHtml = productLabel
|
|
77
280
|
? `<div class="chip">Connected to ${escapeHtml(productLabel)}</div>`
|
|
78
281
|
: '';
|
|
@@ -166,7 +369,7 @@ function renderSuccessHtml(opts) {
|
|
|
166
369
|
<div>© ${year} ArmorIQ Inc.</div>
|
|
167
370
|
<nav>
|
|
168
371
|
<a href="https://docs.armoriq.ai" target="_blank" rel="noreferrer">Docs</a>
|
|
169
|
-
<a href="https://
|
|
372
|
+
<a href="https://tools.armoriq.ai/tools" target="_blank" rel="noreferrer">Tools</a>
|
|
170
373
|
<a href="https://armoriq.ai/privacy" target="_blank" rel="noreferrer">Privacy</a>
|
|
171
374
|
<a href="https://armoriq.ai/terms" target="_blank" rel="noreferrer">Terms</a>
|
|
172
375
|
</nav>
|
|
@@ -191,57 +394,189 @@ function findFreePort() {
|
|
|
191
394
|
});
|
|
192
395
|
}
|
|
193
396
|
function startCallbackServer(port, expectedState, product) {
|
|
397
|
+
let lifecycleState = 'accepting';
|
|
398
|
+
let acceptedResult;
|
|
194
399
|
let resolveResult;
|
|
195
400
|
const once = new Promise((resolve) => {
|
|
196
401
|
resolveResult = resolve;
|
|
197
402
|
});
|
|
403
|
+
let resolveProofConsumed;
|
|
404
|
+
const proofConsumed = new Promise((resolve) => {
|
|
405
|
+
resolveProofConsumed = resolve;
|
|
406
|
+
});
|
|
407
|
+
let proofWasConsumed = false;
|
|
408
|
+
const markProofConsumed = () => {
|
|
409
|
+
if (proofWasConsumed)
|
|
410
|
+
return;
|
|
411
|
+
proofWasConsumed = true;
|
|
412
|
+
resolveProofConsumed();
|
|
413
|
+
};
|
|
414
|
+
const endResponse = (res, statusCode, body, headers = {}, onFinished) => {
|
|
415
|
+
if (res.writableEnded || res.destroyed)
|
|
416
|
+
return;
|
|
417
|
+
res.writeHead(statusCode, {
|
|
418
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
419
|
+
'Cache-Control': 'no-store',
|
|
420
|
+
Connection: 'close',
|
|
421
|
+
...headers,
|
|
422
|
+
});
|
|
423
|
+
res.end(body, onFinished);
|
|
424
|
+
};
|
|
425
|
+
const settle = () => {
|
|
426
|
+
lifecycleState = 'settled';
|
|
427
|
+
if (acceptedResult?.response && !acceptedResult.response.headersSent) {
|
|
428
|
+
endResponse(acceptedResult.response, 409, 'Authorization completed before this callback could be applied.');
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
const dashboardOrigin = configuredDashboardUrl().origin;
|
|
198
432
|
const server = http.createServer((req, res) => {
|
|
199
433
|
const url = new url_1.URL(req.url ?? '/', `http://localhost:${port}`);
|
|
434
|
+
if (url.pathname === '/.well-known/armoriq-device-proof') {
|
|
435
|
+
const requestOrigin = req.headers.origin;
|
|
436
|
+
if (requestOrigin !== dashboardOrigin) {
|
|
437
|
+
endResponse(res, 403, 'Origin is not allowed.');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const candidateState = url.searchParams.get('state');
|
|
441
|
+
const candidateBytes = Buffer.from(candidateState ?? '');
|
|
442
|
+
const expectedBytes = Buffer.from(expectedState);
|
|
443
|
+
const stateMatches = candidateBytes.length === expectedBytes.length &&
|
|
444
|
+
crypto.timingSafeEqual(candidateBytes, expectedBytes);
|
|
445
|
+
if (!stateMatches) {
|
|
446
|
+
endResponse(res, 400, 'Invalid or missing state parameter.');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const corsHeaders = {
|
|
450
|
+
'Access-Control-Allow-Origin': dashboardOrigin,
|
|
451
|
+
Vary: 'Origin',
|
|
452
|
+
};
|
|
453
|
+
if (req.method === 'OPTIONS') {
|
|
454
|
+
endResponse(res, 204, '', {
|
|
455
|
+
...corsHeaders,
|
|
456
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (req.method !== 'GET') {
|
|
461
|
+
endResponse(res, 404, 'Not found.', corsHeaders);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
endResponse(res, 200, JSON.stringify({ ok: true }), {
|
|
465
|
+
...corsHeaders,
|
|
466
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
467
|
+
}, markProofConsumed);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
200
470
|
if (url.pathname === '/callback') {
|
|
201
471
|
const params = Object.fromEntries(url.searchParams.entries());
|
|
202
472
|
// Reject any callback that doesn't carry the state nonce we generated.
|
|
203
473
|
// Without this, any local process or web page could hit the loopback
|
|
204
474
|
// port with ?key=<attacker> during the login window (key fixation).
|
|
205
475
|
if (!params.state || params.state !== expectedState) {
|
|
206
|
-
res
|
|
207
|
-
res.end('Invalid or missing state parameter.');
|
|
476
|
+
endResponse(res, 400, 'Invalid or missing state parameter.');
|
|
208
477
|
return;
|
|
209
478
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
email: params.email,
|
|
214
|
-
product: callbackProduct,
|
|
215
|
-
orgId: params.org_id,
|
|
216
|
-
}));
|
|
479
|
+
// Product routing is established by the local command invocation. Do
|
|
480
|
+
// not allow callback query data to upgrade or change that decision.
|
|
481
|
+
const callbackProduct = product;
|
|
217
482
|
const result = {
|
|
218
483
|
api_key: params.key,
|
|
219
484
|
email: params.email,
|
|
220
485
|
user_id: params.user_id,
|
|
221
486
|
org_id: params.org_id,
|
|
487
|
+
product: callbackProduct,
|
|
222
488
|
};
|
|
223
|
-
if (result.api_key)
|
|
489
|
+
if (result.api_key) {
|
|
490
|
+
if (lifecycleState === 'pending') {
|
|
491
|
+
endResponse(res, 409, 'Authorization callback already received.');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (lifecycleState === 'settled') {
|
|
495
|
+
endResponse(res, 410, 'Authorization flow is no longer accepting callbacks.');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Keep the loopback response pending until cmdLogin has persisted the
|
|
499
|
+
// credentials. The remote redirect is produced only from bounded,
|
|
500
|
+
// non-secret local context.
|
|
501
|
+
result.response = res;
|
|
502
|
+
acceptedResult = result;
|
|
503
|
+
lifecycleState = 'pending';
|
|
224
504
|
resolveResult(result);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
// Preserve the existing response for callbacks without a key. Polling
|
|
508
|
+
// remains active and may still complete the device flow.
|
|
509
|
+
res.writeHead(200, {
|
|
510
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
511
|
+
'Cache-Control': 'no-store',
|
|
512
|
+
Connection: 'close',
|
|
513
|
+
});
|
|
514
|
+
res.end(renderSuccessHtml({
|
|
515
|
+
email: params.email,
|
|
516
|
+
product: callbackProduct,
|
|
517
|
+
orgId: params.org_id,
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
225
520
|
}
|
|
226
521
|
else {
|
|
227
|
-
res.
|
|
228
|
-
res.end();
|
|
522
|
+
endResponse(res, 404, 'Not found.');
|
|
229
523
|
}
|
|
230
524
|
});
|
|
525
|
+
const sockets = new Set();
|
|
526
|
+
server.on('connection', (socket) => {
|
|
527
|
+
sockets.add(socket);
|
|
528
|
+
socket.once('close', () => sockets.delete(socket));
|
|
529
|
+
});
|
|
231
530
|
server.listen(port, '127.0.0.1');
|
|
232
|
-
return { server, once };
|
|
531
|
+
return { server, sockets, once, proofConsumed, settle };
|
|
233
532
|
}
|
|
234
533
|
/**
|
|
235
534
|
* close() does nothing while the socket is still binding, so a device-code
|
|
236
535
|
* request that fails before 'listening' fires would leave the callback server
|
|
237
536
|
* holding the loopback port for the rest of the process.
|
|
238
537
|
*/
|
|
239
|
-
function closeCallbackServer(server) {
|
|
240
|
-
|
|
241
|
-
|
|
538
|
+
function closeCallbackServer(server, sockets, graceMs = 0) {
|
|
539
|
+
const close = () => {
|
|
540
|
+
const destroyConnections = () => {
|
|
541
|
+
// Node 18's server.close() does not reliably reap idle keep-alive or
|
|
542
|
+
// incomplete HTTP connections. Use the supported helpers when present,
|
|
543
|
+
// then enforce the bound with the sockets observed by this server.
|
|
544
|
+
server.closeIdleConnections?.();
|
|
545
|
+
server.closeAllConnections?.();
|
|
546
|
+
for (const socket of sockets)
|
|
547
|
+
socket.destroy();
|
|
548
|
+
};
|
|
549
|
+
if (server.listening) {
|
|
550
|
+
server.close();
|
|
551
|
+
destroyConnections();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
server.once('listening', () => {
|
|
555
|
+
server.close();
|
|
556
|
+
destroyConnections();
|
|
557
|
+
});
|
|
558
|
+
};
|
|
559
|
+
if (graceMs > 0) {
|
|
560
|
+
setTimeout(close, graceMs);
|
|
242
561
|
return;
|
|
243
562
|
}
|
|
244
|
-
|
|
563
|
+
close();
|
|
564
|
+
}
|
|
565
|
+
function closeCallbackServerAfterProof(server, sockets, proofConsumed, deadlineMs) {
|
|
566
|
+
let closed = false;
|
|
567
|
+
const close = () => {
|
|
568
|
+
if (closed)
|
|
569
|
+
return;
|
|
570
|
+
closed = true;
|
|
571
|
+
closeCallbackServer(server, sockets);
|
|
572
|
+
};
|
|
573
|
+
const deadline = setTimeout(close, deadlineMs);
|
|
574
|
+
void proofConsumed.then(() => {
|
|
575
|
+
if (closed)
|
|
576
|
+
return;
|
|
577
|
+
clearTimeout(deadline);
|
|
578
|
+
close();
|
|
579
|
+
});
|
|
245
580
|
}
|
|
246
581
|
function openBrowser(url) {
|
|
247
582
|
const cmd = process.platform === 'darwin'
|
|
@@ -253,24 +588,90 @@ function openBrowser(url) {
|
|
|
253
588
|
/* best-effort */
|
|
254
589
|
});
|
|
255
590
|
}
|
|
256
|
-
function
|
|
257
|
-
|
|
591
|
+
function cancellableDelay(ms) {
|
|
592
|
+
let settled = false;
|
|
593
|
+
let resolveDelay;
|
|
594
|
+
const promise = new Promise((resolve) => {
|
|
595
|
+
resolveDelay = resolve;
|
|
596
|
+
});
|
|
597
|
+
const timer = setTimeout(() => {
|
|
598
|
+
settled = true;
|
|
599
|
+
resolveDelay('tick');
|
|
600
|
+
}, ms);
|
|
601
|
+
return {
|
|
602
|
+
promise,
|
|
603
|
+
cancel: () => {
|
|
604
|
+
if (settled)
|
|
605
|
+
return;
|
|
606
|
+
settled = true;
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
resolveDelay('cancelled');
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Ask the backend whether the cached API key is still usable.
|
|
614
|
+
*
|
|
615
|
+
* Returns the validation result, or `null` when the backend could not be
|
|
616
|
+
* reached (network error, timeout, or a 5xx). A `null` means "unknown": the
|
|
617
|
+
* caller keeps the existing session rather than forcing a re-auth while
|
|
618
|
+
* offline. A definitive 401/403 (revoked, expired, or issued by a different
|
|
619
|
+
* backend — e.g. a stale prod key against staging) comes back as
|
|
620
|
+
* `{ active: false }` so login re-mints instead of short-circuiting.
|
|
621
|
+
*/
|
|
622
|
+
async function checkStoredKey(backend, apiKey) {
|
|
623
|
+
try {
|
|
624
|
+
const r = await axios_1.default.post(`${backend}/api-keys/validate`, { apiKey }, { timeout: 10000, validateStatus: () => true });
|
|
625
|
+
if (r.status === 401 || r.status === 403)
|
|
626
|
+
return { active: false };
|
|
627
|
+
if (r.status >= 200 && r.status < 300) {
|
|
628
|
+
const data = r.data ?? {};
|
|
629
|
+
// `product` is only present on backends that surface it; when absent the
|
|
630
|
+
// product check is skipped and validity alone decides.
|
|
631
|
+
return {
|
|
632
|
+
active: data.success !== false && data.isActive !== false,
|
|
633
|
+
product: typeof data.product === 'string' ? data.product : undefined,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
258
641
|
}
|
|
259
642
|
async function cmdLogin(args) {
|
|
260
643
|
const backend = (args.backend ?? process.env.ARMORIQ_BACKEND_URL ?? (0, util_1.backendBase)()).replace(/\/+$/, '');
|
|
261
644
|
const requestedOrg = (args.org ?? '').trim();
|
|
262
645
|
const product = (args.product ?? process.env.ARMORIQ_PRODUCT ?? '').trim();
|
|
263
646
|
// Already authenticated? Skip the browser flow unless re-auth is forced or
|
|
264
|
-
// the user is switching org.
|
|
265
|
-
//
|
|
647
|
+
// the user is switching org. But only skip when the cached key is actually
|
|
648
|
+
// still usable: a fresh install can leave a stale key (revoked, expired, or
|
|
649
|
+
// minted against a different backend/product), and the old behavior of
|
|
650
|
+
// trusting the file's mere existence meant that key was reused forever and a
|
|
651
|
+
// new one was never created. So validate it first and re-mint when it no
|
|
652
|
+
// longer checks out.
|
|
266
653
|
if (!args.force && !requestedOrg) {
|
|
267
654
|
const existing = (0, credentials_1.loadCredentials)();
|
|
268
|
-
if (existing) {
|
|
269
|
-
(
|
|
270
|
-
|
|
271
|
-
|
|
655
|
+
if (existing?.apiKey) {
|
|
656
|
+
const check = await checkStoredKey(backend, existing.apiKey);
|
|
657
|
+
const productMismatch = !!product && !!check?.product && check.product !== product;
|
|
658
|
+
// Unreachable backend (check === null): keep the existing session rather
|
|
659
|
+
// than forcing a re-auth we cannot complete offline anyway.
|
|
660
|
+
if (!check || (check.active && !productMismatch)) {
|
|
661
|
+
(0, util_1.out)('');
|
|
662
|
+
(0, util_1.out)(` ${util_1.CHECK} Already logged in as \x1b[1m${existing.email || 'unknown'}\x1b[0m (org: ${existing.orgId || 'unknown'})`);
|
|
663
|
+
(0, util_1.out)(' \x1b[2mRun \x1b[0m\x1b[1marmoriq login --force\x1b[0m\x1b[2m to re-authenticate, or \x1b[0m\x1b[1marmoriq logout\x1b[0m\x1b[2m first.\x1b[0m');
|
|
664
|
+
(0, util_1.out)('');
|
|
665
|
+
return 0;
|
|
666
|
+
}
|
|
667
|
+
// Stale or wrong-product key: drop it and fall through to mint a fresh
|
|
668
|
+
// one. A known-bad key is worth nothing, so clearing it first is safe.
|
|
669
|
+
const reason = productMismatch
|
|
670
|
+
? `the saved key is for a different product (${check.product})`
|
|
671
|
+
: 'the saved key is no longer valid';
|
|
272
672
|
(0, util_1.out)('');
|
|
273
|
-
|
|
673
|
+
(0, util_1.out)(` ${util_1.WARN} Reconnecting: ${reason}. Creating a new API key...`);
|
|
674
|
+
(0, credentials_1.clearCredentials)();
|
|
274
675
|
}
|
|
275
676
|
}
|
|
276
677
|
(0, util_1.out)('');
|
|
@@ -279,17 +680,32 @@ async function cmdLogin(args) {
|
|
|
279
680
|
const port = await findFreePort();
|
|
280
681
|
const state = crypto.randomBytes(16).toString('hex');
|
|
281
682
|
const callbackUrl = `http://localhost:${port}/callback?state=${state}`;
|
|
282
|
-
const { server, once: callbackOnce } = startCallbackServer(port, state, product);
|
|
683
|
+
const { server, sockets: callbackSockets, once: callbackOnce, proofConsumed, settle: settleCallbackServer, } = startCallbackServer(port, state, product);
|
|
283
684
|
let dc;
|
|
284
685
|
try {
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
686
|
+
const deviceId = loadOrCreateDeviceId();
|
|
687
|
+
if (!deviceId) {
|
|
688
|
+
const identityPath = getDeviceIdentityPath();
|
|
689
|
+
if (!readDeviceId(identityPath, fs) && deviceIdentityPathExists(identityPath, fs)) {
|
|
690
|
+
(0, util_1.out)(` ${util_1.WARN} Device identity at ${identityPath} is invalid or unreadable; ` +
|
|
691
|
+
'move or remove it manually, then run `armoriq login --force` again.');
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const deviceCodeRequest = {
|
|
695
|
+
callback_url: callbackUrl,
|
|
696
|
+
...(product ? { product } : {}),
|
|
697
|
+
// Both fields or neither: deviceName exists only to label deviceId, so
|
|
698
|
+
// sending it alone would attach a hostname to no device.
|
|
699
|
+
...(deviceId ? { deviceId, deviceName: os.hostname().trim().slice(0, 200) } : {}),
|
|
700
|
+
};
|
|
701
|
+
const r = await axios_1.default.post(`${backend}/auth/device/code`, deviceCodeRequest, {
|
|
702
|
+
timeout: 10000,
|
|
703
|
+
});
|
|
289
704
|
dc = r.data;
|
|
290
705
|
}
|
|
291
706
|
catch (e) {
|
|
292
|
-
|
|
707
|
+
settleCallbackServer();
|
|
708
|
+
closeCallbackServer(server, callbackSockets);
|
|
293
709
|
(0, util_1.out)(` ${util_1.CROSS} Failed to request device code: ${e.message}`);
|
|
294
710
|
return 1;
|
|
295
711
|
}
|
|
@@ -304,7 +720,7 @@ async function cmdLogin(args) {
|
|
|
304
720
|
browserUrl += `&product=${encodeURIComponent(product)}`;
|
|
305
721
|
(0, util_1.out)(' Opening browser...\n');
|
|
306
722
|
openBrowser(browserUrl);
|
|
307
|
-
(0, util_1.out)(
|
|
723
|
+
(0, util_1.out)(" If the browser didn't open, visit:");
|
|
308
724
|
(0, util_1.out)(` \x1b[36m\x1b[1m${browserUrl}\x1b[0m\n`);
|
|
309
725
|
(0, util_1.out)(` Confirm this code in your browser: \x1b[1m${user_code}\x1b[0m\n`);
|
|
310
726
|
process.stdout.write(' Waiting for authorization...');
|
|
@@ -312,19 +728,43 @@ async function cmdLogin(args) {
|
|
|
312
728
|
let result;
|
|
313
729
|
let lastErr;
|
|
314
730
|
while (Date.now() < deadline && !result) {
|
|
315
|
-
//
|
|
731
|
+
// Wait for a callback until the next polling interval.
|
|
732
|
+
const delay = cancellableDelay(interval * 1000);
|
|
316
733
|
const callbackOrTimeout = Promise.race([
|
|
317
734
|
callbackOnce.then((r) => ({ kind: 'cb', value: r })),
|
|
318
|
-
|
|
735
|
+
delay.promise.then((value) => ({ kind: value })),
|
|
319
736
|
]);
|
|
320
737
|
const tick = await callbackOrTimeout;
|
|
321
738
|
if (tick.kind === 'cb' && tick.value.api_key) {
|
|
739
|
+
delay.cancel();
|
|
322
740
|
result = tick.value;
|
|
323
741
|
break;
|
|
324
742
|
}
|
|
325
743
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
744
|
+
// Keep observing the callback while the token request is in flight. A
|
|
745
|
+
// callback accepted during polling owns an open browser response and
|
|
746
|
+
// must be persisted and redirected instead of being discarded when a
|
|
747
|
+
// concurrent poll also succeeds.
|
|
748
|
+
const pollController = new AbortController();
|
|
749
|
+
const pollOutcome = axios_1.default
|
|
750
|
+
.post(`${backend}/auth/device/token`, { deviceCode: device_code }, { timeout: 10000, validateStatus: () => true, signal: pollController.signal })
|
|
751
|
+
.then((r) => ({ kind: 'poll', value: r }), () => ({ kind: 'poll-error' }));
|
|
752
|
+
const pollOrCallback = await Promise.race([
|
|
753
|
+
callbackOnce.then((r) => ({ kind: 'cb', value: r })),
|
|
754
|
+
pollOutcome,
|
|
755
|
+
]);
|
|
756
|
+
if (pollOrCallback.kind === 'cb') {
|
|
757
|
+
pollController.abort();
|
|
758
|
+
await pollOutcome;
|
|
759
|
+
if (pollOrCallback.value.api_key) {
|
|
760
|
+
result = pollOrCallback.value;
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (pollOrCallback.kind === 'poll-error')
|
|
766
|
+
continue;
|
|
767
|
+
const data = pollOrCallback.value.data ?? {};
|
|
328
768
|
const err = data.error;
|
|
329
769
|
if (err === 'authorization_pending' || err === 'slow_down')
|
|
330
770
|
continue;
|
|
@@ -346,19 +786,52 @@ async function cmdLogin(args) {
|
|
|
346
786
|
// network blip; keep polling
|
|
347
787
|
}
|
|
348
788
|
}
|
|
349
|
-
closeCallbackServer(server);
|
|
350
789
|
if (!result || !result.api_key) {
|
|
790
|
+
settleCallbackServer();
|
|
791
|
+
closeCallbackServer(server, callbackSockets, CALLBACK_SETTLED_GRACE_MS);
|
|
351
792
|
(0, util_1.out)(` ${util_1.CROSS}`);
|
|
352
793
|
(0, util_1.out)(` ${util_1.CROSS} ${lastErr ?? 'Timed out waiting for authorization. Run `armoriq login` again.'}`);
|
|
353
794
|
return 1;
|
|
354
795
|
}
|
|
355
|
-
(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
796
|
+
if (!result.response)
|
|
797
|
+
settleCallbackServer();
|
|
798
|
+
try {
|
|
799
|
+
(0, credentials_1.saveCredentials)({
|
|
800
|
+
apiKey: result.api_key,
|
|
801
|
+
email: result.email ?? '',
|
|
802
|
+
userId: result.user_id ?? '',
|
|
803
|
+
orgId: result.org_id ?? '',
|
|
804
|
+
savedAt: new Date().toISOString(),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
if (result.response && !result.response.headersSent) {
|
|
809
|
+
result.response.writeHead(500, {
|
|
810
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
811
|
+
'Cache-Control': 'no-store',
|
|
812
|
+
Connection: 'close',
|
|
813
|
+
});
|
|
814
|
+
result.response.end('Authorization succeeded, but credentials could not be saved locally.');
|
|
815
|
+
}
|
|
816
|
+
settleCallbackServer();
|
|
817
|
+
closeCallbackServer(server, callbackSockets, CALLBACK_SETTLED_GRACE_MS);
|
|
818
|
+
throw error;
|
|
819
|
+
}
|
|
820
|
+
if (result.response && !result.response.headersSent) {
|
|
821
|
+
result.response.writeHead(302, {
|
|
822
|
+
Location: buildDashboardRedirect(result.product),
|
|
823
|
+
'Cache-Control': 'no-store',
|
|
824
|
+
Connection: 'close',
|
|
825
|
+
});
|
|
826
|
+
result.response.end();
|
|
827
|
+
}
|
|
828
|
+
settleCallbackServer();
|
|
829
|
+
if (result.response) {
|
|
830
|
+
closeCallbackServerAfterProof(server, callbackSockets, proofConsumed, PROOF_SERVER_DEADLINE_MS);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
closeCallbackServer(server, callbackSockets, CALLBACK_SETTLED_GRACE_MS);
|
|
834
|
+
}
|
|
362
835
|
(0, util_1.out)(` ${util_1.CHECK}`);
|
|
363
836
|
(0, util_1.out)('');
|
|
364
837
|
(0, util_1.out)(` ${util_1.CHECK} Logged in as \x1b[1m${result.email ?? 'unknown'}\x1b[0m (org: ${result.org_id ?? 'unknown'})`);
|