@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.
Files changed (96) hide show
  1. package/README.md +168 -1
  2. package/dist/_version.d.ts +1 -1
  3. package/dist/_version.d.ts.map +1 -1
  4. package/dist/_version.js +1 -1
  5. package/dist/_version.js.map +1 -1
  6. package/dist/cli/commands/auth.d.ts +12 -0
  7. package/dist/cli/commands/auth.d.ts.map +1 -1
  8. package/dist/cli/commands/auth.js +522 -49
  9. package/dist/cli/commands/auth.js.map +1 -1
  10. package/dist/client.d.ts +8 -15
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +20 -18
  13. package/dist/client.js.map +1 -1
  14. package/dist/config.d.ts +0 -17
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +1 -19
  17. package/dist/config.js.map +1 -1
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +8 -14
  21. package/dist/index.js.map +1 -1
  22. package/dist/integrations/google_adk.d.ts +155 -7
  23. package/dist/integrations/google_adk.d.ts.map +1 -1
  24. package/dist/integrations/google_adk.js +727 -46
  25. package/dist/integrations/google_adk.js.map +1 -1
  26. package/dist/integrations/langchain.d.ts +48 -2
  27. package/dist/integrations/langchain.d.ts.map +1 -1
  28. package/dist/integrations/langchain.js +528 -33
  29. package/dist/integrations/langchain.js.map +1 -1
  30. package/dist/integrations/strands.d.ts +65 -1
  31. package/dist/integrations/strands.d.ts.map +1 -1
  32. package/dist/integrations/strands.js +456 -36
  33. package/dist/integrations/strands.js.map +1 -1
  34. package/dist/models.d.ts +2 -2
  35. package/dist/models.d.ts.map +1 -1
  36. package/dist/observability/content-capture.d.ts +103 -0
  37. package/dist/observability/content-capture.d.ts.map +1 -0
  38. package/dist/observability/content-capture.js +423 -0
  39. package/dist/observability/content-capture.js.map +1 -0
  40. package/dist/observability/index.d.ts +6 -7
  41. package/dist/observability/index.d.ts.map +1 -1
  42. package/dist/observability/index.js +18 -27
  43. package/dist/observability/index.js.map +1 -1
  44. package/dist/observability/otel-config.d.ts +47 -0
  45. package/dist/observability/otel-config.d.ts.map +1 -0
  46. package/dist/observability/otel-config.js +268 -0
  47. package/dist/observability/otel-config.js.map +1 -0
  48. package/dist/observability/otel-export-ceiling.d.ts +96 -0
  49. package/dist/observability/otel-export-ceiling.d.ts.map +1 -0
  50. package/dist/observability/otel-export-ceiling.js +271 -0
  51. package/dist/observability/otel-export-ceiling.js.map +1 -0
  52. package/dist/observability/otel-runtime.d.ts +103 -0
  53. package/dist/observability/otel-runtime.d.ts.map +1 -0
  54. package/dist/observability/otel-runtime.js +680 -0
  55. package/dist/observability/otel-runtime.js.map +1 -0
  56. package/dist/observability/otel-session.d.ts +168 -0
  57. package/dist/observability/otel-session.d.ts.map +1 -0
  58. package/dist/observability/otel-session.js +630 -0
  59. package/dist/observability/otel-session.js.map +1 -0
  60. package/dist/observability/otel-shutdown.d.ts +17 -0
  61. package/dist/observability/otel-shutdown.d.ts.map +1 -0
  62. package/dist/observability/otel-shutdown.js +54 -0
  63. package/dist/observability/otel-shutdown.js.map +1 -0
  64. package/dist/observability/policy-lease.d.ts +22 -0
  65. package/dist/observability/policy-lease.d.ts.map +1 -0
  66. package/dist/observability/policy-lease.js +102 -0
  67. package/dist/observability/policy-lease.js.map +1 -0
  68. package/dist/plan_builder.d.ts +5 -4
  69. package/dist/plan_builder.d.ts.map +1 -1
  70. package/dist/plan_builder.js +14 -15
  71. package/dist/plan_builder.js.map +1 -1
  72. package/dist/session.d.ts +61 -93
  73. package/dist/session.d.ts.map +1 -1
  74. package/dist/session.js +388 -804
  75. package/dist/session.js.map +1 -1
  76. package/dist/token_usage.d.ts +11 -18
  77. package/dist/token_usage.d.ts.map +1 -1
  78. package/dist/token_usage.js +29 -94
  79. package/dist/token_usage.js.map +1 -1
  80. package/dist/tool_name.d.ts +18 -0
  81. package/dist/tool_name.d.ts.map +1 -0
  82. package/dist/tool_name.js +29 -0
  83. package/dist/tool_name.js.map +1 -0
  84. package/dist/tool_push.d.ts +28 -0
  85. package/dist/tool_push.d.ts.map +1 -0
  86. package/dist/tool_push.js +151 -0
  87. package/dist/tool_push.js.map +1 -0
  88. package/dist/tool_registry.d.ts +100 -0
  89. package/dist/tool_registry.d.ts.map +1 -0
  90. package/dist/tool_registry.js +440 -0
  91. package/dist/tool_registry.js.map +1 -0
  92. package/dist/tool_schema.d.ts +22 -0
  93. package/dist/tool_schema.d.ts.map +1 -0
  94. package/dist/tool_schema.js +163 -0
  95. package/dist/tool_schema.js.map +1 -0
  96. 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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://dev.armoriq.ai" target="_blank" rel="noreferrer">Dashboard</a>
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.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
207
- res.end('Invalid or missing state parameter.');
476
+ endResponse(res, 400, 'Invalid or missing state parameter.');
208
477
  return;
209
478
  }
210
- const callbackProduct = params.product || product;
211
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
212
- res.end(renderSuccessHtml({
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.writeHead(404);
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
- if (server.listening) {
241
- server.close();
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
- server.once('listening', () => server.close());
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 sleep(ms) {
257
- return new Promise((r) => setTimeout(r, ms));
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. Re-running `login` otherwise just re-opens the
265
- // browser for no reason.
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
- (0, util_1.out)('');
270
- (0, util_1.out)(` ${util_1.CHECK} Already logged in as \x1b[1m${existing.email || 'unknown'}\x1b[0m (org: ${existing.orgId || 'unknown'})`);
271
- (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');
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
- return 0;
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 deviceCodeRequest = product
286
- ? { callback_url: callbackUrl, product }
287
- : { callback_url: callbackUrl };
288
- const r = await axios_1.default.post(`${backend}/auth/device/code`, deviceCodeRequest, { timeout: 10000 });
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
- closeCallbackServer(server);
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)(' If the browser didn\'t open, visit:');
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
- // Race callback vs poll; whichever resolves first wins.
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
- sleep(interval * 1000).then(() => ({ kind: 'tick' })),
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
- const pr = await axios_1.default.post(`${backend}/auth/device/token`, { deviceCode: device_code }, { timeout: 10000, validateStatus: () => true });
327
- const data = pr.data ?? {};
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
- (0, credentials_1.saveCredentials)({
356
- apiKey: result.api_key,
357
- email: result.email ?? '',
358
- userId: result.user_id ?? '',
359
- orgId: result.org_id ?? '',
360
- savedAt: new Date().toISOString(),
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'})`);