@bluelamp/cli-token 1.1.0 → 1.3.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cjs/index.js CHANGED
@@ -46,7 +46,7 @@ var __importStar = (this && this.__importStar) || (function () {
46
46
  };
47
47
  })();
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
- exports.FLEET_GATE_LOG_MAX_BYTES = exports.FLEET_ENTITLEMENT_MAX_TTL_SEC = exports.MEMBER_SCOPE_ALLOWED_REASONS = exports.CliTokenNotFoundError = void 0;
49
+ exports.TOKEN_REFRESH_LOCK_WAIT_MS = exports.TOKEN_REFRESH_LOCK_STALE_MS = exports.CLI_TOKEN_REFRESH_BEFORE_MS = exports.DeviceKeyError = exports.EXCHANGED_JWT_LOCK_STALE_MS = exports.EXCHANGED_JWT_LOCK_WAIT_MS = exports.EXCHANGED_JWT_REFRESH_BUFFER_MS = exports.FLEET_GATE_LOG_MAX_BYTES = exports.FLEET_ENTITLEMENT_MAX_TTL_SEC = exports.MEMBER_SCOPE_ALLOWED_REASONS = exports.CliTokenNotFoundError = void 0;
50
50
  exports.getTokenFilePath = getTokenFilePath;
51
51
  exports.getPinnedTenantFilePath = getPinnedTenantFilePath;
52
52
  exports.encryptPayload = encryptPayload;
@@ -67,6 +67,31 @@ exports.fleetGateRefusalMessage = fleetGateRefusalMessage;
67
67
  exports.parseFleetEntitlementCache = parseFleetEntitlementCache;
68
68
  exports.isFleetEntitlementFresh = isFleetEntitlementFresh;
69
69
  exports.checkFleetGate = checkFleetGate;
70
+ exports.getExchangedJwtCacheDir = getExchangedJwtCacheDir;
71
+ exports.exchangedJwtCacheKey = exchangedJwtCacheKey;
72
+ exports.parseCachedExchangedJwt = parseCachedExchangedJwt;
73
+ exports.isCachedExchangedJwtUsable = isCachedExchangedJwtUsable;
74
+ exports.getExchangedJwt = getExchangedJwt;
75
+ exports.evictExchangedJwt = evictExchangedJwt;
76
+ exports.getDeviceKeyPath = getDeviceKeyPath;
77
+ exports.getDeviceMapPath = getDeviceMapPath;
78
+ exports.getTokenRefreshLockPath = getTokenRefreshLockPath;
79
+ exports.readDeviceKey = readDeviceKey;
80
+ exports.loadOrCreateDeviceKey = loadOrCreateDeviceKey;
81
+ exports.devicePollMessage = devicePollMessage;
82
+ exports.tokenRefreshMessage = tokenRefreshMessage;
83
+ exports.signWithDeviceKey = signWithDeviceKey;
84
+ exports.readDeviceMap = readDeviceMap;
85
+ exports.readDeviceId = readDeviceId;
86
+ exports.saveDeviceId = saveDeviceId;
87
+ exports.parseExpiresAt = parseExpiresAt;
88
+ exports.needsRefresh = needsRefresh;
89
+ exports.ensureFreshCliToken = ensureFreshCliToken;
90
+ exports.readFreshCliToken = readFreshCliToken;
91
+ exports.startDeviceRegistration = startDeviceRegistration;
92
+ exports.pollDeviceRegistration = pollDeviceRegistration;
93
+ exports.saveApprovedDevice = saveApprovedDevice;
94
+ exports.awaitDeviceApproval = awaitDeviceApproval;
70
95
  const node_crypto_1 = require("node:crypto");
71
96
  const fs = __importStar(require("node:fs"));
72
97
  const os = __importStar(require("node:os"));
@@ -111,8 +136,12 @@ class CliTokenNotFoundError extends Error {
111
136
  }
112
137
  }
113
138
  exports.CliTokenNotFoundError = CliTokenNotFoundError;
139
+ // 入力が定数なので 1 回だけ導く(scrypt は 1 回数十 ms。札を読むたびに払わない)
140
+ let derivedKeyCache = null;
114
141
  function derivedKey() {
115
- return (0, node_crypto_1.scryptSync)(SECRET, KEY_SALT, KEY_LENGTH);
142
+ if (!derivedKeyCache)
143
+ derivedKeyCache = (0, node_crypto_1.scryptSync)(SECRET, KEY_SALT, KEY_LENGTH);
144
+ return derivedKeyCache;
116
145
  }
117
146
  // ── functional core(I/O を持たない。ここだけで判定と変換が閉じる)──────────────
118
147
  /** 平文を `<ivHex>:<payloadHex>` にする。IV は毎回ランダム。 */
@@ -198,8 +227,12 @@ function loadPortalToken() {
198
227
  }
199
228
  return data;
200
229
  }
201
- /** トークンを暗号化保存する。フォーマット不正・書込失敗は throw。 */
202
- function savePortalToken(token, userInfo = {}) {
230
+ /**
231
+ * トークンを暗号化保存する。フォーマット不正・書込失敗は throw。
232
+ * n=3918: 置き場は 0600(フォルダ 0700)。一時ファイルに書いてから rename する=読み手が書きかけを読まない。
233
+ * `binding` は機体に結んだ札だけが渡す(期限と機体の id)。
234
+ */
235
+ function savePortalToken(token, userInfo = {}, binding) {
203
236
  if (!validateTokenFormat(token)) {
204
237
  throw new Error('Invalid CLI token format');
205
238
  }
@@ -209,13 +242,33 @@ function savePortalToken(token, userInfo = {}) {
209
242
  userInfo,
210
243
  version: '1.0',
211
244
  source: 'portal',
245
+ ...(binding ? { expiresAt: binding.expiresAt, deviceId: binding.deviceId } : {}),
212
246
  };
213
- const filePath = getTokenFilePath();
214
- const dir = path.dirname(filePath);
215
- if (!fs.existsSync(dir)) {
216
- fs.mkdirSync(dir, { recursive: true });
247
+ writePrivateFileAtomic(getTokenFilePath(), encryptPayload(JSON.stringify(tokenData)));
248
+ }
249
+ /** 自分だけが読めるフォルダ(0700)を用意する。既に在るフォルダも 0700 に揃える */
250
+ function ensurePrivateDir(dir) {
251
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
252
+ fs.chmodSync(dir, 0o700);
253
+ }
254
+ /** 一時ファイル(0600)に書いて rename する。umask に依らず 0600 */
255
+ function writePrivateFileAtomic(filePath, content) {
256
+ ensurePrivateDir(path.dirname(filePath));
257
+ const tmp = `${filePath}.${process.pid}.${(0, node_crypto_1.randomBytes)(4).toString('hex')}.tmp`;
258
+ try {
259
+ fs.writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' });
260
+ fs.chmodSync(tmp, 0o600);
261
+ fs.renameSync(tmp, filePath);
262
+ }
263
+ catch (e) {
264
+ try {
265
+ fs.unlinkSync(tmp);
266
+ }
267
+ catch {
268
+ // 書けていなければ無い
269
+ }
270
+ throw e;
217
271
  }
218
- fs.writeFileSync(filePath, encryptPayload(JSON.stringify(tokenData)));
219
272
  }
220
273
  /** 保存済みトークンを削除する。ファイル無しは no-op。削除失敗は throw。 */
221
274
  function deletePortalToken() {
@@ -413,4 +466,583 @@ async function decideFleetGate(options) {
413
466
  });
414
467
  return { allowed: isAllowedForScope(asked.status, asked.reason, scope), status: asked.status, reason: asked.reason, from: 'network' };
415
468
  }
469
+ /** 期限の何 ms 前から取り直すか(既定)。10 分の札で 9 分は使い回す */
470
+ exports.EXCHANGED_JWT_REFRESH_BUFFER_MS = 60_000;
471
+ /** 他のプロセスが取り直している間に待つ上限(既定)。過ぎたら自分で取る(fail-open) */
472
+ exports.EXCHANGED_JWT_LOCK_WAIT_MS = 5_000;
473
+ /** ロックがこれより古ければ持ち主は死んだと読む(exchange の打ち切りより長く取る) */
474
+ exports.EXCHANGED_JWT_LOCK_STALE_MS = 30_000;
475
+ const LOCK_POLL_MS = 100;
476
+ /**
477
+ * 置き場。`BLUELAMP_JWT_CACHE_DIR` が在ればそこ(消費側のテストが実機の置き場を触らないため)、無ければ
478
+ * `~/.bluelamp/jwt-cache`。テストで HOME を差し替えられるよう呼び出し時に解決する
479
+ */
480
+ function getExchangedJwtCacheDir() {
481
+ return process.env.BLUELAMP_JWT_CACHE_DIR || path.join(os.homedir(), '.bluelamp', 'jwt-cache');
482
+ }
483
+ /** 鍵(純関数)。sha256 の 16 進 64 文字=そのままファイル名に使える */
484
+ function exchangedJwtCacheKey(k) {
485
+ const fingerprint = (0, node_crypto_1.createHash)('sha256').update(k.cliToken).digest('hex');
486
+ const material = JSON.stringify([k.baseUrl.replace(/\/+$/, ''), k.mcpId, [...k.scopes].sort(), fingerprint]);
487
+ return (0, node_crypto_1.createHash)('sha256').update(material).digest('hex');
488
+ }
489
+ /** キャッシュの中身を検める(純関数)。形が違えば null=キャッシュ無しと同じ */
490
+ function parseCachedExchangedJwt(raw) {
491
+ let v;
492
+ try {
493
+ v = JSON.parse(raw);
494
+ }
495
+ catch {
496
+ return null;
497
+ }
498
+ const o = v;
499
+ if (!o || typeof o.token !== 'string' || o.token.length === 0)
500
+ return null;
501
+ if (typeof o.expiresAt !== 'number' || !Number.isFinite(o.expiresAt))
502
+ return null;
503
+ return { token: o.token, expiresAt: o.expiresAt };
504
+ }
505
+ /** まだ使ってよいか(純関数)。期限の bufferMs 前を過ぎたら使わない */
506
+ function isCachedExchangedJwtUsable(c, nowMs, bufferMs) {
507
+ return nowMs < c.expiresAt - bufferMs;
508
+ }
509
+ function cacheFilePath(dir, key) {
510
+ return path.join(dir, `${exchangedJwtCacheKey(key)}.json`);
511
+ }
512
+ /** 読む。自分だけが読める(group/other のビットが立っていない)ファイルしか信じない */
513
+ function readCachedJwt(file) {
514
+ try {
515
+ const st = fs.statSync(file);
516
+ if ((st.mode & 0o077) !== 0)
517
+ return null;
518
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid())
519
+ return null;
520
+ return parseCachedExchangedJwt(fs.readFileSync(file, 'utf8'));
521
+ }
522
+ catch {
523
+ return null;
524
+ }
525
+ }
526
+ function writeCachedJwt(file, c) {
527
+ try {
528
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
529
+ const tmp = `${file}.${process.pid}.${(0, node_crypto_1.randomBytes)(4).toString('hex')}.tmp`;
530
+ fs.writeFileSync(tmp, JSON.stringify(c) + '\n', { mode: 0o600 });
531
+ fs.chmodSync(tmp, 0o600); // umask に依らず 600
532
+ fs.renameSync(tmp, file);
533
+ }
534
+ catch {
535
+ // 書けなくても今回の JWT は返す(次のプロセスがもう 1 回取るだけ)
536
+ }
537
+ }
538
+ /** ロックを取る。取れたら true。古いロック(持ち主が死んだ)は 1 回だけ払って取り直す */
539
+ function tryLock(lockFile, nowMs, staleMs = exports.EXCHANGED_JWT_LOCK_STALE_MS) {
540
+ for (let attempt = 0; attempt < 2; attempt++) {
541
+ try {
542
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 });
543
+ fs.closeSync(fs.openSync(lockFile, 'wx', 0o600));
544
+ return true;
545
+ }
546
+ catch (e) {
547
+ if (e.code !== 'EEXIST')
548
+ return false;
549
+ try {
550
+ if (nowMs - fs.statSync(lockFile).mtimeMs <= staleMs)
551
+ return false;
552
+ fs.unlinkSync(lockFile);
553
+ }
554
+ catch {
555
+ return false;
556
+ }
557
+ }
558
+ }
559
+ return false;
560
+ }
561
+ function unlock(lockFile) {
562
+ try {
563
+ fs.unlinkSync(lockFile);
564
+ }
565
+ catch {
566
+ // 消えていても構わない
567
+ }
568
+ }
569
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
570
+ /**
571
+ * 機体で共有する JWT を返す。使える JWT がファイルに在れば口に出ない。
572
+ * 無い/切れかけなら、ロックを取った 1 プロセスだけが exchange して書く。他はその間ファイルを待つ
573
+ * (lockWaitMs を過ぎたら自分で取る)。exchange の失敗は throw のまま返す。
574
+ */
575
+ async function getExchangedJwt(options) {
576
+ const now = options.now ?? Date.now;
577
+ const bufferMs = options.refreshBufferMs ?? exports.EXCHANGED_JWT_REFRESH_BUFFER_MS;
578
+ const file = cacheFilePath(options.cacheDir ?? getExchangedJwtCacheDir(), options.key);
579
+ const usable = () => {
580
+ const c = readCachedJwt(file);
581
+ return c && isCachedExchangedJwtUsable(c, now(), bufferMs) ? c.token : null;
582
+ };
583
+ const hit = usable();
584
+ if (hit)
585
+ return hit;
586
+ const lockFile = `${file}.lock`;
587
+ const waitUntil = now() + Math.max(0, options.lockWaitMs ?? exports.EXCHANGED_JWT_LOCK_WAIT_MS);
588
+ let locked = tryLock(lockFile, now());
589
+ while (!locked && now() < waitUntil) {
590
+ await sleep(LOCK_POLL_MS);
591
+ const fresh = usable();
592
+ if (fresh)
593
+ return fresh;
594
+ locked = tryLock(lockFile, now());
595
+ }
596
+ try {
597
+ if (locked) {
598
+ // ロックを待っている間に他のプロセスが書いたかもしれない
599
+ const fresh = usable();
600
+ if (fresh)
601
+ return fresh;
602
+ }
603
+ const got = await options.exchange();
604
+ writeCachedJwt(file, { token: got.token, expiresAt: now() + got.expiresInSec * 1000 });
605
+ return got.token;
606
+ }
607
+ finally {
608
+ if (locked)
609
+ unlock(lockFile);
610
+ }
611
+ }
612
+ /**
613
+ * 受け手に拒まれた(401)JWT を捨てる。ファイルの中身が `rejectedToken` のときだけ消す
614
+ * (その間に他のプロセスが書いた新しい JWT は消さない)。throw しない。
615
+ */
616
+ function evictExchangedJwt(key, rejectedToken, cacheDir = getExchangedJwtCacheDir()) {
617
+ const file = cacheFilePath(cacheDir, key);
618
+ try {
619
+ if (parseCachedExchangedJwt(fs.readFileSync(file, 'utf8'))?.token === rejectedToken)
620
+ fs.unlinkSync(file);
621
+ }
622
+ catch {
623
+ // 無ければ何もしない
624
+ }
625
+ }
626
+ // ── 機体の鍵と札の延長(箱 n=3918)─────────────────────────────────────────────
627
+ //
628
+ // 🩸 なぜ: CLI の札は期限なしで、1 本写せばどの機体からでも金庫・連携の鍵・合言葉の変更まで届いた。
629
+ // 🔑 線(箱 n=3918 の設計が正本):
630
+ // - 機体の鍵は Ed25519。`~/.musuhi/device-key.pem`(PKCS8・0600・フォルダ 0700)。OS ユーザー 1 つに鍵 1 つ。
631
+ // ⛔ OS の鍵束に置かない(SSH・launchd の予約の便で開かない)
632
+ // - 口座ごとの機体の id は `~/.musuhi/device.json`(0600)に `{ "<user_id>": "<device_id>" }`
633
+ // - 機体に結んだ札は 1 時間。残り 15 分を切ったら、`~/.musuhi/token-refresh.lock`(O_EXCL)を取れた
634
+ // 1 手だけが延ばし、他は読む(機体ごとに 1 か所)。2 分より古いロックは持ち主が死んだと読んで払う
635
+ // - 延ばす口は機体の鍵で署名する。普段の要求は今までどおり札だけ(1 回ごとに署名しない)
636
+ // - ⛔ 札・署名・鍵をログ・例外文・返り値の detail に出さない。throw しない(延ばせなくても本線を落とさない)
637
+ /** 機体の鍵の置き場。テストで HOME を差し替えられるよう呼び出し時に解決する */
638
+ function getDeviceKeyPath() {
639
+ return path.join(os.homedir(), '.musuhi', 'device-key.pem');
640
+ }
641
+ /** 口座ごとの機体の id の置き場 */
642
+ function getDeviceMapPath() {
643
+ return path.join(os.homedir(), '.musuhi', 'device.json');
644
+ }
645
+ /** 札を延ばす 1 手を選ぶロック */
646
+ function getTokenRefreshLockPath() {
647
+ return path.join(os.homedir(), '.musuhi', 'token-refresh.lock');
648
+ }
649
+ /** 機体の鍵が読めない(権限が開いている・形が壊れている)。文面に鍵の中身は載せない */
650
+ class DeviceKeyError extends Error {
651
+ constructor(detail) {
652
+ super(`機体の鍵を使えません(${getDeviceKeyPath()}): ${detail}`);
653
+ this.name = 'DeviceKeyError';
654
+ }
655
+ }
656
+ exports.DeviceKeyError = DeviceKeyError;
657
+ function deviceKeyFromPem(pem) {
658
+ const privateKey = (0, node_crypto_1.createPrivateKey)({ key: pem, format: 'pem' });
659
+ if (privateKey.asymmetricKeyType !== 'ed25519') {
660
+ throw new DeviceKeyError(`鍵の種類が ed25519 でない(${String(privateKey.asymmetricKeyType)})`);
661
+ }
662
+ const publicKeyPem = (0, node_crypto_1.createPublicKey)(privateKey).export({ type: 'spki', format: 'pem' }).toString();
663
+ return { privateKey, publicKeyPem };
664
+ }
665
+ /**
666
+ * 機体の鍵を読む。無ければ null。
667
+ * 自分以外が読める(group/other のビットが立っている)・持ち主が違う鍵は使わない=DeviceKeyError
668
+ * (ssh が開いた秘密鍵を拒むのと同じ線。黙って直さない)。
669
+ */
670
+ function readDeviceKey() {
671
+ const keyPath = getDeviceKeyPath();
672
+ let st;
673
+ try {
674
+ st = fs.statSync(keyPath);
675
+ }
676
+ catch (e) {
677
+ if (e.code === 'ENOENT')
678
+ return null;
679
+ throw new DeviceKeyError(`読めない: ${e.message}`);
680
+ }
681
+ if ((st.mode & 0o077) !== 0) {
682
+ throw new DeviceKeyError(`権限が 600 でない(${(st.mode & 0o777).toString(8)})`);
683
+ }
684
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
685
+ throw new DeviceKeyError('持ち主がこの OS ユーザーでない');
686
+ }
687
+ try {
688
+ return deviceKeyFromPem(fs.readFileSync(keyPath, 'utf8'));
689
+ }
690
+ catch (e) {
691
+ if (e instanceof DeviceKeyError)
692
+ throw e;
693
+ throw new DeviceKeyError('鍵の形が読めない');
694
+ }
695
+ }
696
+ /**
697
+ * 機体の鍵を読む。無ければ作る(0600・フォルダ 0700)。
698
+ * 同時に 2 つが作ろうとしても 'wx' で 1 つだけが書き、負けた側は書かれた鍵を読む。
699
+ */
700
+ function loadOrCreateDeviceKey() {
701
+ const existing = readDeviceKey();
702
+ if (existing)
703
+ return existing;
704
+ const keyPath = getDeviceKeyPath();
705
+ ensurePrivateDir(path.dirname(keyPath));
706
+ const { privateKey } = (0, node_crypto_1.generateKeyPairSync)('ed25519');
707
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
708
+ try {
709
+ fs.writeFileSync(keyPath, pem, { mode: 0o600, flag: 'wx' });
710
+ fs.chmodSync(keyPath, 0o600);
711
+ }
712
+ catch (e) {
713
+ if (e.code !== 'EEXIST')
714
+ throw e;
715
+ const won = readDeviceKey();
716
+ if (won)
717
+ return won;
718
+ throw new DeviceKeyError('作ったはずの鍵が読めない');
719
+ }
720
+ return deviceKeyFromPem(pem);
721
+ }
722
+ /** 署名する文(純関数): 登録の待ちの問い合わせ */
723
+ function devicePollMessage(deviceCode, ts) {
724
+ return `bluelamp-device-poll\n${deviceCode}\n${ts}`;
725
+ }
726
+ /** 署名する文(純関数): 札の延長 */
727
+ function tokenRefreshMessage(deviceId, ts, nonce) {
728
+ return `bluelamp-token-refresh\n${deviceId}\n${ts}\n${nonce}`;
729
+ }
730
+ /** 機体の鍵で署名する。返りは Ed25519 の署名の base64(標準・パディングあり) */
731
+ function signWithDeviceKey(key, message) {
732
+ return (0, node_crypto_1.sign)(null, Buffer.from(message, 'utf8'), key.privateKey).toString('base64');
733
+ }
734
+ /** 口座ごとの機体の id を読む。無い・壊れている=空 */
735
+ function readDeviceMap() {
736
+ try {
737
+ const v = JSON.parse(fs.readFileSync(getDeviceMapPath(), 'utf8'));
738
+ if (!v || typeof v !== 'object' || Array.isArray(v))
739
+ return {};
740
+ const out = {};
741
+ for (const [k, val] of Object.entries(v)) {
742
+ if (typeof val === 'string' && val.length > 0)
743
+ out[k] = val;
744
+ }
745
+ return out;
746
+ }
747
+ catch {
748
+ return {};
749
+ }
750
+ }
751
+ /** この口座の機体の id。無ければ null */
752
+ function readDeviceId(userId) {
753
+ return readDeviceMap()[userId] ?? null;
754
+ }
755
+ /** この口座の機体の id を書く(他の口座の行は残す・0600・rename) */
756
+ function saveDeviceId(userId, deviceId) {
757
+ const map = readDeviceMap();
758
+ map[userId] = deviceId;
759
+ writePrivateFileAtomic(getDeviceMapPath(), JSON.stringify(map, null, 2) + '\n');
760
+ }
761
+ /** 残りがこれを切ったら延ばす */
762
+ exports.CLI_TOKEN_REFRESH_BEFORE_MS = 15 * 60_000;
763
+ /** 延ばすロックがこれより古ければ持ち主は死んだと読む */
764
+ exports.TOKEN_REFRESH_LOCK_STALE_MS = 2 * 60_000;
765
+ /** 他の手が延ばしている間に待つ上限(既定)。過ぎたら今の札のまま返す */
766
+ exports.TOKEN_REFRESH_LOCK_WAIT_MS = 5_000;
767
+ const TOKEN_REFRESH_TIMEOUT_MS = 10_000;
768
+ /** サーバの期限(ISO 文字列・epoch 秒・epoch ms のどれか)を epoch ms に(純関数)。読めなければ null */
769
+ function parseExpiresAt(v) {
770
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
771
+ return v < 1e12 ? v * 1000 : v;
772
+ if (typeof v === 'string' && v.length > 0) {
773
+ const ms = Date.parse(v);
774
+ return Number.isFinite(ms) ? ms : null;
775
+ }
776
+ return null;
777
+ }
778
+ /** 延ばすべきか(純関数)。古い札(期限・機体の id が無い)は延ばさない */
779
+ function needsRefresh(data, nowMs, beforeMs) {
780
+ if (typeof data.expiresAt !== 'number' || !data.deviceId)
781
+ return false;
782
+ return data.expiresAt - nowMs <= beforeMs;
783
+ }
784
+ function loadPortalTokenQuiet() {
785
+ try {
786
+ return loadPortalToken();
787
+ }
788
+ catch {
789
+ return null;
790
+ }
791
+ }
792
+ function portalBase(explicit) {
793
+ return (explicit ?? (process.env.BLUELAMP_PORTAL_API_BASE_URL || process.env.PORTAL_URL || FLEET_DEFAULT_BASE)).replace(/\/+$/, '');
794
+ }
795
+ async function postRefresh(url, current, key, nowMs, fetchFn, timeoutMs) {
796
+ const deviceId = current.deviceId;
797
+ const ts = Math.floor(nowMs / 1000);
798
+ const nonce = (0, node_crypto_1.randomBytes)(16).toString('hex');
799
+ const signature = signWithDeviceKey(key, tokenRefreshMessage(deviceId, ts, nonce));
800
+ const ac = new AbortController();
801
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
802
+ let res;
803
+ try {
804
+ res = await fetchFn(url, {
805
+ method: 'POST',
806
+ headers: { 'Content-Type': 'application/json', 'X-CLI-Token': current.token },
807
+ body: JSON.stringify({ device_id: deviceId, ts, nonce, signature }),
808
+ signal: ac.signal,
809
+ });
810
+ }
811
+ catch (e) {
812
+ return { status: 'error', detail: ac.signal.aborted ? `timeout ${timeoutMs}ms` : `network: ${e.message}` };
813
+ }
814
+ finally {
815
+ clearTimeout(timer);
816
+ }
817
+ let body = null;
818
+ try {
819
+ body = (await res.json());
820
+ }
821
+ catch {
822
+ body = null;
823
+ }
824
+ if (!res.ok) {
825
+ const code = typeof body?.code === 'string' ? body.code : null;
826
+ if (res.status === 409 && code === 'device_over_quota')
827
+ return { status: 'device_over_quota', detail: 'http 409' };
828
+ if (res.status >= 400 && res.status < 500)
829
+ return { status: 'rejected', detail: `http ${res.status}${code ? ` ${code}` : ''}` };
830
+ return { status: 'error', detail: `http ${res.status}` };
831
+ }
832
+ const data = (body && typeof body.data === 'object' && body.data !== null ? body.data : body);
833
+ const token = data?.token;
834
+ const expiresAt = parseExpiresAt(data?.expires_at);
835
+ if (!validateTokenFormat(token) || expiresAt === null)
836
+ return { status: 'error', detail: 'invalid response' };
837
+ const newDeviceId = typeof data?.device_id === 'string' && data.device_id ? data.device_id : deviceId;
838
+ try {
839
+ savePortalToken(token, current.userInfo ?? {}, { expiresAt, deviceId: newDeviceId });
840
+ }
841
+ catch (e) {
842
+ return { status: 'error', detail: `save failed: ${e.message}` };
843
+ }
844
+ return { status: 'refreshed', expiresAt };
845
+ }
846
+ /**
847
+ * 札を延ばす(機体ごとに 1 か所)。**throw しない**。
848
+ * 残りが 15 分を切った機体に結んだ札だけ、ロックを取れた 1 手が `POST {base}/cli/token/refresh` を撃ち、
849
+ * 新しい札を一時ファイル→rename(0600)で置き場に書く。他の手はその間待ってから今の置き場を読めばよい。
850
+ * 期限が過ぎていても撃つ(サーバは期限切れ 30 日までの同じ機体の札を受ける)。
851
+ */
852
+ async function ensureFreshCliToken(options = {}) {
853
+ const now = options.now ?? Date.now;
854
+ const beforeMs = options.refreshBeforeMs ?? exports.CLI_TOKEN_REFRESH_BEFORE_MS;
855
+ const initial = loadPortalTokenQuiet();
856
+ if (!initial || !initial.token)
857
+ return { status: 'no_token' };
858
+ if (typeof initial.expiresAt !== 'number' || !initial.deviceId)
859
+ return { status: 'legacy' };
860
+ if (!needsRefresh(initial, now(), beforeMs))
861
+ return { status: 'fresh', expiresAt: initial.expiresAt };
862
+ const lockFile = getTokenRefreshLockPath();
863
+ if (!tryLock(lockFile, Date.now(), exports.TOKEN_REFRESH_LOCK_STALE_MS)) {
864
+ // 他の手が延ばしている。札が替わるまで少し待つ(替わらなくても今の札のまま返す)
865
+ // 待ちは実時間で測る(now は期限の判定のための時計で、試験では止まっている)
866
+ const waitUntil = Date.now() + Math.max(0, options.lockWaitMs ?? exports.TOKEN_REFRESH_LOCK_WAIT_MS);
867
+ while (Date.now() < waitUntil) {
868
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
869
+ const cur = loadPortalTokenQuiet();
870
+ if (cur && cur.token !== initial.token)
871
+ return { status: 'locked', expiresAt: cur.expiresAt };
872
+ }
873
+ return { status: 'locked', detail: 'another refresher holds the lock' };
874
+ }
875
+ try {
876
+ // ロックを取る間に他の手が延ばしたかもしれない
877
+ const current = loadPortalTokenQuiet();
878
+ if (!current || !current.token)
879
+ return { status: 'no_token' };
880
+ if (typeof current.expiresAt !== 'number' || !current.deviceId)
881
+ return { status: 'legacy' };
882
+ if (!needsRefresh(current, now(), beforeMs))
883
+ return { status: 'fresh', expiresAt: current.expiresAt };
884
+ let key;
885
+ try {
886
+ key = readDeviceKey();
887
+ }
888
+ catch (e) {
889
+ return { status: 'error', detail: e.message };
890
+ }
891
+ if (!key)
892
+ return { status: 'error', detail: 'device key missing' };
893
+ const fetchFn = options.fetch ?? globalThis.fetch;
894
+ if (typeof fetchFn !== 'function')
895
+ return { status: 'error', detail: 'fetch unavailable' };
896
+ return await postRefresh(`${portalBase(options.baseUrl)}/cli/token/refresh`, current, key, now(), fetchFn, options.timeoutMs ?? TOKEN_REFRESH_TIMEOUT_MS);
897
+ }
898
+ finally {
899
+ unlock(lockFile);
900
+ }
901
+ }
902
+ /**
903
+ * 延ばしてから札を読む(読めなければ null・throw しない)。
904
+ * 401 を受けた読み手が「置き場を読み直して 1 回だけ撃ち直す」ときの読み直しに使う。
905
+ */
906
+ async function readFreshCliToken(options = {}) {
907
+ await ensureFreshCliToken(options);
908
+ return tryReadCliToken();
909
+ }
910
+ async function postJson(url, body, opts) {
911
+ const fetchFn = opts.fetch ?? globalThis.fetch;
912
+ if (typeof fetchFn !== 'function')
913
+ return { ok: false, detail: 'fetch unavailable' };
914
+ const timeoutMs = opts.timeoutMs ?? TOKEN_REFRESH_TIMEOUT_MS;
915
+ const ac = new AbortController();
916
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
917
+ try {
918
+ const res = await fetchFn(url, {
919
+ method: 'POST',
920
+ headers: { 'Content-Type': 'application/json' },
921
+ body: JSON.stringify(body),
922
+ signal: ac.signal,
923
+ });
924
+ let json = null;
925
+ try {
926
+ json = (await res.json());
927
+ }
928
+ catch {
929
+ json = null;
930
+ }
931
+ return { ok: true, status: res.status, json };
932
+ }
933
+ catch (e) {
934
+ return { ok: false, detail: ac.signal.aborted ? `timeout ${timeoutMs}ms` : `network: ${e.message}` };
935
+ }
936
+ finally {
937
+ clearTimeout(timer);
938
+ }
939
+ }
940
+ /** `{success, data}` で包まれていても素でも中身を取る(純関数) */
941
+ function unwrapData(json) {
942
+ if (json && typeof json.data === 'object' && json.data !== null && !Array.isArray(json.data)) {
943
+ return json.data;
944
+ }
945
+ return json;
946
+ }
947
+ /** 登録を始める(番号をもらう)。公開鍵を送る=秘密鍵は機体から出ない */
948
+ async function startDeviceRegistration(key, req, opts = {}) {
949
+ const r = await postJson(`${portalBase(opts.baseUrl)}/cli/device/start`, {
950
+ public_key: key.publicKeyPem,
951
+ device_name: req.deviceName,
952
+ platform: req.platform,
953
+ arch: req.arch,
954
+ os_user: req.osUser,
955
+ client: req.client,
956
+ ...(req.pinTenantId ? { pin_tenant_id: req.pinTenantId } : {}),
957
+ }, opts);
958
+ if (!r.ok)
959
+ return { ok: false, httpStatus: null, code: null, detail: r.detail };
960
+ const d = unwrapData(r.json);
961
+ const code = typeof r.json?.code === 'string' ? r.json.code : null;
962
+ if (r.status < 200 || r.status >= 300) {
963
+ const message = typeof r.json?.message === 'string' ? r.json.message : `http ${r.status}`;
964
+ return { ok: false, httpStatus: r.status, code, detail: message };
965
+ }
966
+ const deviceCode = d?.device_code;
967
+ const userCode = d?.user_code;
968
+ const verificationUri = d?.verification_uri;
969
+ if (typeof deviceCode !== 'string' || !deviceCode || typeof userCode !== 'string' || !userCode || typeof verificationUri !== 'string') {
970
+ return { ok: false, httpStatus: r.status, code, detail: 'invalid response' };
971
+ }
972
+ const num = (v, dflt) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : dflt);
973
+ return {
974
+ ok: true,
975
+ start: {
976
+ deviceCode,
977
+ userCode,
978
+ verificationUri,
979
+ expiresInSec: num(d?.expires_in, 600),
980
+ intervalSec: num(d?.interval, 5),
981
+ },
982
+ };
983
+ }
984
+ /** 承認を 1 回だけ問い合わせる(機体の鍵で署名する) */
985
+ async function pollDeviceRegistration(key, deviceCode, opts = {}) {
986
+ const ts = Math.floor((opts.now ?? Date.now)() / 1000);
987
+ const signature = signWithDeviceKey(key, devicePollMessage(deviceCode, ts));
988
+ const r = await postJson(`${portalBase(opts.baseUrl)}/cli/device/poll`, { device_code: deviceCode, ts, signature }, opts);
989
+ if (!r.ok)
990
+ return { status: 'error', detail: r.detail };
991
+ if (r.status < 200 || r.status >= 300)
992
+ return { status: 'error', detail: `http ${r.status}` };
993
+ const d = unwrapData(r.json);
994
+ const status = d?.status;
995
+ if (status === 'pending' || status === 'denied' || status === 'expired')
996
+ return { status };
997
+ if (status !== 'approved')
998
+ return { status: 'error', detail: 'invalid response' };
999
+ const token = d?.token;
1000
+ const expiresAt = parseExpiresAt(d?.expires_at);
1001
+ const deviceId = d?.device_id;
1002
+ if (!validateTokenFormat(token) || expiresAt === null || typeof deviceId !== 'string' || !deviceId) {
1003
+ return { status: 'error', detail: 'invalid response' };
1004
+ }
1005
+ const user = (d?.user && typeof d.user === 'object' ? d.user : {});
1006
+ return { status: 'approved', token, expiresAt, deviceId, user };
1007
+ }
1008
+ /** 承認された札を置き場に書く(札+期限+機体の id、口座ごとの機体の id)。書けなければ throw */
1009
+ function saveApprovedDevice(approved) {
1010
+ const u = approved.user;
1011
+ const userInfo = {
1012
+ id: u.id,
1013
+ name: u.name,
1014
+ email: u.email,
1015
+ role: u.rank ?? u.role,
1016
+ language: u.language ?? 'ja',
1017
+ active_tenant_id: u.active_tenant_id ?? null,
1018
+ };
1019
+ savePortalToken(approved.token, userInfo, { expiresAt: approved.expiresAt, deviceId: approved.deviceId });
1020
+ if (u.id)
1021
+ saveDeviceId(u.id, approved.deviceId);
1022
+ return userInfo;
1023
+ }
1024
+ /**
1025
+ * 承認・拒否・期限切れまで interval ごとに問い合わせる(網の一時の不調は待ちを続ける)。
1026
+ * 返りは最後の答え。approved のときは置き場にまだ書いていない(saveApprovedDevice は呼び出し側)。
1027
+ */
1028
+ async function awaitDeviceApproval(key, start, opts = {}) {
1029
+ const now = opts.now ?? Date.now;
1030
+ const wait = opts.sleep ?? sleep;
1031
+ const deadline = now() + start.expiresInSec * 1000;
1032
+ let lastError = null;
1033
+ while (now() < deadline) {
1034
+ await wait(start.intervalSec * 1000);
1035
+ const r = await pollDeviceRegistration(key, start.deviceCode, opts);
1036
+ if (r.status === 'pending') {
1037
+ opts.onPending?.();
1038
+ continue;
1039
+ }
1040
+ if (r.status === 'error') {
1041
+ lastError = r.detail;
1042
+ continue;
1043
+ }
1044
+ return r;
1045
+ }
1046
+ return lastError ? { status: 'error', detail: lastError } : { status: 'expired' };
1047
+ }
416
1048
  //# sourceMappingURL=index.js.map