@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/esm/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * - 失敗の返し方は消費側で分かれていたので、throw 版(readCliTokenSync)と
12
12
  * null 版(tryReadCliToken)の両方を出す。片方に寄せると挙動が変わる。
13
13
  */
14
- import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
14
+ import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, scryptSync, sign, } from 'node:crypto';
15
15
  import * as fs from 'node:fs';
16
16
  import * as os from 'node:os';
17
17
  import * as path from 'node:path';
@@ -54,8 +54,12 @@ export class CliTokenNotFoundError extends Error {
54
54
  this.name = 'CliTokenNotFoundError';
55
55
  }
56
56
  }
57
+ // 入力が定数なので 1 回だけ導く(scrypt は 1 回数十 ms。札を読むたびに払わない)
58
+ let derivedKeyCache = null;
57
59
  function derivedKey() {
58
- return scryptSync(SECRET, KEY_SALT, KEY_LENGTH);
60
+ if (!derivedKeyCache)
61
+ derivedKeyCache = scryptSync(SECRET, KEY_SALT, KEY_LENGTH);
62
+ return derivedKeyCache;
59
63
  }
60
64
  // ── functional core(I/O を持たない。ここだけで判定と変換が閉じる)──────────────
61
65
  /** 平文を `<ivHex>:<payloadHex>` にする。IV は毎回ランダム。 */
@@ -141,8 +145,12 @@ export function loadPortalToken() {
141
145
  }
142
146
  return data;
143
147
  }
144
- /** トークンを暗号化保存する。フォーマット不正・書込失敗は throw。 */
145
- export function savePortalToken(token, userInfo = {}) {
148
+ /**
149
+ * トークンを暗号化保存する。フォーマット不正・書込失敗は throw。
150
+ * n=3918: 置き場は 0600(フォルダ 0700)。一時ファイルに書いてから rename する=読み手が書きかけを読まない。
151
+ * `binding` は機体に結んだ札だけが渡す(期限と機体の id)。
152
+ */
153
+ export function savePortalToken(token, userInfo = {}, binding) {
146
154
  if (!validateTokenFormat(token)) {
147
155
  throw new Error('Invalid CLI token format');
148
156
  }
@@ -152,13 +160,33 @@ export function savePortalToken(token, userInfo = {}) {
152
160
  userInfo,
153
161
  version: '1.0',
154
162
  source: 'portal',
163
+ ...(binding ? { expiresAt: binding.expiresAt, deviceId: binding.deviceId } : {}),
155
164
  };
156
- const filePath = getTokenFilePath();
157
- const dir = path.dirname(filePath);
158
- if (!fs.existsSync(dir)) {
159
- fs.mkdirSync(dir, { recursive: true });
165
+ writePrivateFileAtomic(getTokenFilePath(), encryptPayload(JSON.stringify(tokenData)));
166
+ }
167
+ /** 自分だけが読めるフォルダ(0700)を用意する。既に在るフォルダも 0700 に揃える */
168
+ function ensurePrivateDir(dir) {
169
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
170
+ fs.chmodSync(dir, 0o700);
171
+ }
172
+ /** 一時ファイル(0600)に書いて rename する。umask に依らず 0600 */
173
+ function writePrivateFileAtomic(filePath, content) {
174
+ ensurePrivateDir(path.dirname(filePath));
175
+ const tmp = `${filePath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
176
+ try {
177
+ fs.writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' });
178
+ fs.chmodSync(tmp, 0o600);
179
+ fs.renameSync(tmp, filePath);
180
+ }
181
+ catch (e) {
182
+ try {
183
+ fs.unlinkSync(tmp);
184
+ }
185
+ catch {
186
+ // 書けていなければ無い
187
+ }
188
+ throw e;
160
189
  }
161
- fs.writeFileSync(filePath, encryptPayload(JSON.stringify(tokenData)));
162
190
  }
163
191
  /** 保存済みトークンを削除する。ファイル無しは no-op。削除失敗は throw。 */
164
192
  export function deletePortalToken() {
@@ -356,4 +384,582 @@ async function decideFleetGate(options) {
356
384
  });
357
385
  return { allowed: isAllowedForScope(asked.status, asked.reason, scope), status: asked.status, reason: asked.reason, from: 'network' };
358
386
  }
387
+ /** 期限の何 ms 前から取り直すか(既定)。10 分の札で 9 分は使い回す */
388
+ export const EXCHANGED_JWT_REFRESH_BUFFER_MS = 60_000;
389
+ /** 他のプロセスが取り直している間に待つ上限(既定)。過ぎたら自分で取る(fail-open) */
390
+ export const EXCHANGED_JWT_LOCK_WAIT_MS = 5_000;
391
+ /** ロックがこれより古ければ持ち主は死んだと読む(exchange の打ち切りより長く取る) */
392
+ export const EXCHANGED_JWT_LOCK_STALE_MS = 30_000;
393
+ const LOCK_POLL_MS = 100;
394
+ /**
395
+ * 置き場。`BLUELAMP_JWT_CACHE_DIR` が在ればそこ(消費側のテストが実機の置き場を触らないため)、無ければ
396
+ * `~/.bluelamp/jwt-cache`。テストで HOME を差し替えられるよう呼び出し時に解決する
397
+ */
398
+ export function getExchangedJwtCacheDir() {
399
+ return process.env.BLUELAMP_JWT_CACHE_DIR || path.join(os.homedir(), '.bluelamp', 'jwt-cache');
400
+ }
401
+ /** 鍵(純関数)。sha256 の 16 進 64 文字=そのままファイル名に使える */
402
+ export function exchangedJwtCacheKey(k) {
403
+ const fingerprint = createHash('sha256').update(k.cliToken).digest('hex');
404
+ const material = JSON.stringify([k.baseUrl.replace(/\/+$/, ''), k.mcpId, [...k.scopes].sort(), fingerprint]);
405
+ return createHash('sha256').update(material).digest('hex');
406
+ }
407
+ /** キャッシュの中身を検める(純関数)。形が違えば null=キャッシュ無しと同じ */
408
+ export function parseCachedExchangedJwt(raw) {
409
+ let v;
410
+ try {
411
+ v = JSON.parse(raw);
412
+ }
413
+ catch {
414
+ return null;
415
+ }
416
+ const o = v;
417
+ if (!o || typeof o.token !== 'string' || o.token.length === 0)
418
+ return null;
419
+ if (typeof o.expiresAt !== 'number' || !Number.isFinite(o.expiresAt))
420
+ return null;
421
+ return { token: o.token, expiresAt: o.expiresAt };
422
+ }
423
+ /** まだ使ってよいか(純関数)。期限の bufferMs 前を過ぎたら使わない */
424
+ export function isCachedExchangedJwtUsable(c, nowMs, bufferMs) {
425
+ return nowMs < c.expiresAt - bufferMs;
426
+ }
427
+ function cacheFilePath(dir, key) {
428
+ return path.join(dir, `${exchangedJwtCacheKey(key)}.json`);
429
+ }
430
+ /** 読む。自分だけが読める(group/other のビットが立っていない)ファイルしか信じない */
431
+ function readCachedJwt(file) {
432
+ try {
433
+ const st = fs.statSync(file);
434
+ if ((st.mode & 0o077) !== 0)
435
+ return null;
436
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid())
437
+ return null;
438
+ return parseCachedExchangedJwt(fs.readFileSync(file, 'utf8'));
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ }
444
+ function writeCachedJwt(file, c) {
445
+ try {
446
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
447
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
448
+ fs.writeFileSync(tmp, JSON.stringify(c) + '\n', { mode: 0o600 });
449
+ fs.chmodSync(tmp, 0o600); // umask に依らず 600
450
+ fs.renameSync(tmp, file);
451
+ }
452
+ catch {
453
+ // 書けなくても今回の JWT は返す(次のプロセスがもう 1 回取るだけ)
454
+ }
455
+ }
456
+ /** ロックを取る。取れたら true。古いロック(持ち主が死んだ)は 1 回だけ払って取り直す */
457
+ function tryLock(lockFile, nowMs, staleMs = EXCHANGED_JWT_LOCK_STALE_MS) {
458
+ for (let attempt = 0; attempt < 2; attempt++) {
459
+ try {
460
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 });
461
+ fs.closeSync(fs.openSync(lockFile, 'wx', 0o600));
462
+ return true;
463
+ }
464
+ catch (e) {
465
+ if (e.code !== 'EEXIST')
466
+ return false;
467
+ try {
468
+ if (nowMs - fs.statSync(lockFile).mtimeMs <= staleMs)
469
+ return false;
470
+ fs.unlinkSync(lockFile);
471
+ }
472
+ catch {
473
+ return false;
474
+ }
475
+ }
476
+ }
477
+ return false;
478
+ }
479
+ function unlock(lockFile) {
480
+ try {
481
+ fs.unlinkSync(lockFile);
482
+ }
483
+ catch {
484
+ // 消えていても構わない
485
+ }
486
+ }
487
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
488
+ /**
489
+ * 機体で共有する JWT を返す。使える JWT がファイルに在れば口に出ない。
490
+ * 無い/切れかけなら、ロックを取った 1 プロセスだけが exchange して書く。他はその間ファイルを待つ
491
+ * (lockWaitMs を過ぎたら自分で取る)。exchange の失敗は throw のまま返す。
492
+ */
493
+ export async function getExchangedJwt(options) {
494
+ const now = options.now ?? Date.now;
495
+ const bufferMs = options.refreshBufferMs ?? EXCHANGED_JWT_REFRESH_BUFFER_MS;
496
+ const file = cacheFilePath(options.cacheDir ?? getExchangedJwtCacheDir(), options.key);
497
+ const usable = () => {
498
+ const c = readCachedJwt(file);
499
+ return c && isCachedExchangedJwtUsable(c, now(), bufferMs) ? c.token : null;
500
+ };
501
+ const hit = usable();
502
+ if (hit)
503
+ return hit;
504
+ const lockFile = `${file}.lock`;
505
+ const waitUntil = now() + Math.max(0, options.lockWaitMs ?? EXCHANGED_JWT_LOCK_WAIT_MS);
506
+ let locked = tryLock(lockFile, now());
507
+ while (!locked && now() < waitUntil) {
508
+ await sleep(LOCK_POLL_MS);
509
+ const fresh = usable();
510
+ if (fresh)
511
+ return fresh;
512
+ locked = tryLock(lockFile, now());
513
+ }
514
+ try {
515
+ if (locked) {
516
+ // ロックを待っている間に他のプロセスが書いたかもしれない
517
+ const fresh = usable();
518
+ if (fresh)
519
+ return fresh;
520
+ }
521
+ const got = await options.exchange();
522
+ writeCachedJwt(file, { token: got.token, expiresAt: now() + got.expiresInSec * 1000 });
523
+ return got.token;
524
+ }
525
+ finally {
526
+ if (locked)
527
+ unlock(lockFile);
528
+ }
529
+ }
530
+ /**
531
+ * 受け手に拒まれた(401)JWT を捨てる。ファイルの中身が `rejectedToken` のときだけ消す
532
+ * (その間に他のプロセスが書いた新しい JWT は消さない)。throw しない。
533
+ */
534
+ export function evictExchangedJwt(key, rejectedToken, cacheDir = getExchangedJwtCacheDir()) {
535
+ const file = cacheFilePath(cacheDir, key);
536
+ try {
537
+ if (parseCachedExchangedJwt(fs.readFileSync(file, 'utf8'))?.token === rejectedToken)
538
+ fs.unlinkSync(file);
539
+ }
540
+ catch {
541
+ // 無ければ何もしない
542
+ }
543
+ }
544
+ // ── 機体の鍵と札の延長(箱 n=3918)─────────────────────────────────────────────
545
+ //
546
+ // 🩸 なぜ: CLI の札は期限なしで、1 本写せばどの機体からでも金庫・連携の鍵・合言葉の変更まで届いた。
547
+ // 🔑 線(箱 n=3918 の設計が正本):
548
+ // - 機体の鍵は Ed25519。`~/.musuhi/device-key.pem`(PKCS8・0600・フォルダ 0700)。OS ユーザー 1 つに鍵 1 つ。
549
+ // ⛔ OS の鍵束に置かない(SSH・launchd の予約の便で開かない)
550
+ // - 口座ごとの機体の id は `~/.musuhi/device.json`(0600)に `{ "<user_id>": "<device_id>" }`
551
+ // - 機体に結んだ札は 1 時間。残り 15 分を切ったら、`~/.musuhi/token-refresh.lock`(O_EXCL)を取れた
552
+ // 1 手だけが延ばし、他は読む(機体ごとに 1 か所)。2 分より古いロックは持ち主が死んだと読んで払う
553
+ // - 延ばす口は機体の鍵で署名する。普段の要求は今までどおり札だけ(1 回ごとに署名しない)
554
+ // - ⛔ 札・署名・鍵をログ・例外文・返り値の detail に出さない。throw しない(延ばせなくても本線を落とさない)
555
+ /** 機体の鍵の置き場。テストで HOME を差し替えられるよう呼び出し時に解決する */
556
+ export function getDeviceKeyPath() {
557
+ return path.join(os.homedir(), '.musuhi', 'device-key.pem');
558
+ }
559
+ /** 口座ごとの機体の id の置き場 */
560
+ export function getDeviceMapPath() {
561
+ return path.join(os.homedir(), '.musuhi', 'device.json');
562
+ }
563
+ /** 札を延ばす 1 手を選ぶロック */
564
+ export function getTokenRefreshLockPath() {
565
+ return path.join(os.homedir(), '.musuhi', 'token-refresh.lock');
566
+ }
567
+ /** 機体の鍵が読めない(権限が開いている・形が壊れている)。文面に鍵の中身は載せない */
568
+ export class DeviceKeyError extends Error {
569
+ constructor(detail) {
570
+ super(`機体の鍵を使えません(${getDeviceKeyPath()}): ${detail}`);
571
+ this.name = 'DeviceKeyError';
572
+ }
573
+ }
574
+ function deviceKeyFromPem(pem) {
575
+ const privateKey = createPrivateKey({ key: pem, format: 'pem' });
576
+ if (privateKey.asymmetricKeyType !== 'ed25519') {
577
+ throw new DeviceKeyError(`鍵の種類が ed25519 でない(${String(privateKey.asymmetricKeyType)})`);
578
+ }
579
+ const publicKeyPem = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString();
580
+ return { privateKey, publicKeyPem };
581
+ }
582
+ /**
583
+ * 機体の鍵を読む。無ければ null。
584
+ * 自分以外が読める(group/other のビットが立っている)・持ち主が違う鍵は使わない=DeviceKeyError
585
+ * (ssh が開いた秘密鍵を拒むのと同じ線。黙って直さない)。
586
+ */
587
+ export function readDeviceKey() {
588
+ const keyPath = getDeviceKeyPath();
589
+ let st;
590
+ try {
591
+ st = fs.statSync(keyPath);
592
+ }
593
+ catch (e) {
594
+ if (e.code === 'ENOENT')
595
+ return null;
596
+ throw new DeviceKeyError(`読めない: ${e.message}`);
597
+ }
598
+ if ((st.mode & 0o077) !== 0) {
599
+ throw new DeviceKeyError(`権限が 600 でない(${(st.mode & 0o777).toString(8)})`);
600
+ }
601
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
602
+ throw new DeviceKeyError('持ち主がこの OS ユーザーでない');
603
+ }
604
+ try {
605
+ return deviceKeyFromPem(fs.readFileSync(keyPath, 'utf8'));
606
+ }
607
+ catch (e) {
608
+ if (e instanceof DeviceKeyError)
609
+ throw e;
610
+ throw new DeviceKeyError('鍵の形が読めない');
611
+ }
612
+ }
613
+ /**
614
+ * 機体の鍵を読む。無ければ作る(0600・フォルダ 0700)。
615
+ * 同時に 2 つが作ろうとしても 'wx' で 1 つだけが書き、負けた側は書かれた鍵を読む。
616
+ */
617
+ export function loadOrCreateDeviceKey() {
618
+ const existing = readDeviceKey();
619
+ if (existing)
620
+ return existing;
621
+ const keyPath = getDeviceKeyPath();
622
+ ensurePrivateDir(path.dirname(keyPath));
623
+ const { privateKey } = generateKeyPairSync('ed25519');
624
+ const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
625
+ try {
626
+ fs.writeFileSync(keyPath, pem, { mode: 0o600, flag: 'wx' });
627
+ fs.chmodSync(keyPath, 0o600);
628
+ }
629
+ catch (e) {
630
+ if (e.code !== 'EEXIST')
631
+ throw e;
632
+ const won = readDeviceKey();
633
+ if (won)
634
+ return won;
635
+ throw new DeviceKeyError('作ったはずの鍵が読めない');
636
+ }
637
+ return deviceKeyFromPem(pem);
638
+ }
639
+ /** 署名する文(純関数): 登録の待ちの問い合わせ */
640
+ export function devicePollMessage(deviceCode, ts) {
641
+ return `bluelamp-device-poll\n${deviceCode}\n${ts}`;
642
+ }
643
+ /** 署名する文(純関数): 札の延長 */
644
+ export function tokenRefreshMessage(deviceId, ts, nonce) {
645
+ return `bluelamp-token-refresh\n${deviceId}\n${ts}\n${nonce}`;
646
+ }
647
+ /** 機体の鍵で署名する。返りは Ed25519 の署名の base64(標準・パディングあり) */
648
+ export function signWithDeviceKey(key, message) {
649
+ return sign(null, Buffer.from(message, 'utf8'), key.privateKey).toString('base64');
650
+ }
651
+ /** 口座ごとの機体の id を読む。無い・壊れている=空 */
652
+ export function readDeviceMap() {
653
+ try {
654
+ const v = JSON.parse(fs.readFileSync(getDeviceMapPath(), 'utf8'));
655
+ if (!v || typeof v !== 'object' || Array.isArray(v))
656
+ return {};
657
+ const out = {};
658
+ for (const [k, val] of Object.entries(v)) {
659
+ if (typeof val === 'string' && val.length > 0)
660
+ out[k] = val;
661
+ }
662
+ return out;
663
+ }
664
+ catch {
665
+ return {};
666
+ }
667
+ }
668
+ /** この口座の機体の id。無ければ null */
669
+ export function readDeviceId(userId) {
670
+ return readDeviceMap()[userId] ?? null;
671
+ }
672
+ /** この口座の機体の id を書く(他の口座の行は残す・0600・rename) */
673
+ export function saveDeviceId(userId, deviceId) {
674
+ const map = readDeviceMap();
675
+ map[userId] = deviceId;
676
+ writePrivateFileAtomic(getDeviceMapPath(), JSON.stringify(map, null, 2) + '\n');
677
+ }
678
+ /** 残りがこれを切ったら延ばす */
679
+ export const CLI_TOKEN_REFRESH_BEFORE_MS = 15 * 60_000;
680
+ /** 延ばすロックがこれより古ければ持ち主は死んだと読む */
681
+ export const TOKEN_REFRESH_LOCK_STALE_MS = 2 * 60_000;
682
+ /** 他の手が延ばしている間に待つ上限(既定)。過ぎたら今の札のまま返す */
683
+ export const TOKEN_REFRESH_LOCK_WAIT_MS = 5_000;
684
+ const TOKEN_REFRESH_TIMEOUT_MS = 10_000;
685
+ /** サーバの期限(ISO 文字列・epoch 秒・epoch ms のどれか)を epoch ms に(純関数)。読めなければ null */
686
+ export function parseExpiresAt(v) {
687
+ if (typeof v === 'number' && Number.isFinite(v) && v > 0)
688
+ return v < 1e12 ? v * 1000 : v;
689
+ if (typeof v === 'string' && v.length > 0) {
690
+ const ms = Date.parse(v);
691
+ return Number.isFinite(ms) ? ms : null;
692
+ }
693
+ return null;
694
+ }
695
+ /** 延ばすべきか(純関数)。古い札(期限・機体の id が無い)は延ばさない */
696
+ export function needsRefresh(data, nowMs, beforeMs) {
697
+ if (typeof data.expiresAt !== 'number' || !data.deviceId)
698
+ return false;
699
+ return data.expiresAt - nowMs <= beforeMs;
700
+ }
701
+ function loadPortalTokenQuiet() {
702
+ try {
703
+ return loadPortalToken();
704
+ }
705
+ catch {
706
+ return null;
707
+ }
708
+ }
709
+ function portalBase(explicit) {
710
+ return (explicit ?? (process.env.BLUELAMP_PORTAL_API_BASE_URL || process.env.PORTAL_URL || FLEET_DEFAULT_BASE)).replace(/\/+$/, '');
711
+ }
712
+ async function postRefresh(url, current, key, nowMs, fetchFn, timeoutMs) {
713
+ const deviceId = current.deviceId;
714
+ const ts = Math.floor(nowMs / 1000);
715
+ const nonce = randomBytes(16).toString('hex');
716
+ const signature = signWithDeviceKey(key, tokenRefreshMessage(deviceId, ts, nonce));
717
+ const ac = new AbortController();
718
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
719
+ let res;
720
+ try {
721
+ res = await fetchFn(url, {
722
+ method: 'POST',
723
+ headers: { 'Content-Type': 'application/json', 'X-CLI-Token': current.token },
724
+ body: JSON.stringify({ device_id: deviceId, ts, nonce, signature }),
725
+ signal: ac.signal,
726
+ });
727
+ }
728
+ catch (e) {
729
+ return { status: 'error', detail: ac.signal.aborted ? `timeout ${timeoutMs}ms` : `network: ${e.message}` };
730
+ }
731
+ finally {
732
+ clearTimeout(timer);
733
+ }
734
+ let body = null;
735
+ try {
736
+ body = (await res.json());
737
+ }
738
+ catch {
739
+ body = null;
740
+ }
741
+ if (!res.ok) {
742
+ const code = typeof body?.code === 'string' ? body.code : null;
743
+ if (res.status === 409 && code === 'device_over_quota')
744
+ return { status: 'device_over_quota', detail: 'http 409' };
745
+ if (res.status >= 400 && res.status < 500)
746
+ return { status: 'rejected', detail: `http ${res.status}${code ? ` ${code}` : ''}` };
747
+ return { status: 'error', detail: `http ${res.status}` };
748
+ }
749
+ const data = (body && typeof body.data === 'object' && body.data !== null ? body.data : body);
750
+ const token = data?.token;
751
+ const expiresAt = parseExpiresAt(data?.expires_at);
752
+ if (!validateTokenFormat(token) || expiresAt === null)
753
+ return { status: 'error', detail: 'invalid response' };
754
+ const newDeviceId = typeof data?.device_id === 'string' && data.device_id ? data.device_id : deviceId;
755
+ try {
756
+ savePortalToken(token, current.userInfo ?? {}, { expiresAt, deviceId: newDeviceId });
757
+ }
758
+ catch (e) {
759
+ return { status: 'error', detail: `save failed: ${e.message}` };
760
+ }
761
+ return { status: 'refreshed', expiresAt };
762
+ }
763
+ /**
764
+ * 札を延ばす(機体ごとに 1 か所)。**throw しない**。
765
+ * 残りが 15 分を切った機体に結んだ札だけ、ロックを取れた 1 手が `POST {base}/cli/token/refresh` を撃ち、
766
+ * 新しい札を一時ファイル→rename(0600)で置き場に書く。他の手はその間待ってから今の置き場を読めばよい。
767
+ * 期限が過ぎていても撃つ(サーバは期限切れ 30 日までの同じ機体の札を受ける)。
768
+ */
769
+ export async function ensureFreshCliToken(options = {}) {
770
+ const now = options.now ?? Date.now;
771
+ const beforeMs = options.refreshBeforeMs ?? CLI_TOKEN_REFRESH_BEFORE_MS;
772
+ const initial = loadPortalTokenQuiet();
773
+ if (!initial || !initial.token)
774
+ return { status: 'no_token' };
775
+ if (typeof initial.expiresAt !== 'number' || !initial.deviceId)
776
+ return { status: 'legacy' };
777
+ if (!needsRefresh(initial, now(), beforeMs))
778
+ return { status: 'fresh', expiresAt: initial.expiresAt };
779
+ const lockFile = getTokenRefreshLockPath();
780
+ if (!tryLock(lockFile, Date.now(), TOKEN_REFRESH_LOCK_STALE_MS)) {
781
+ // 他の手が延ばしている。札が替わるまで少し待つ(替わらなくても今の札のまま返す)
782
+ // 待ちは実時間で測る(now は期限の判定のための時計で、試験では止まっている)
783
+ const waitUntil = Date.now() + Math.max(0, options.lockWaitMs ?? TOKEN_REFRESH_LOCK_WAIT_MS);
784
+ while (Date.now() < waitUntil) {
785
+ await new Promise((r) => setTimeout(r, LOCK_POLL_MS));
786
+ const cur = loadPortalTokenQuiet();
787
+ if (cur && cur.token !== initial.token)
788
+ return { status: 'locked', expiresAt: cur.expiresAt };
789
+ }
790
+ return { status: 'locked', detail: 'another refresher holds the lock' };
791
+ }
792
+ try {
793
+ // ロックを取る間に他の手が延ばしたかもしれない
794
+ const current = loadPortalTokenQuiet();
795
+ if (!current || !current.token)
796
+ return { status: 'no_token' };
797
+ if (typeof current.expiresAt !== 'number' || !current.deviceId)
798
+ return { status: 'legacy' };
799
+ if (!needsRefresh(current, now(), beforeMs))
800
+ return { status: 'fresh', expiresAt: current.expiresAt };
801
+ let key;
802
+ try {
803
+ key = readDeviceKey();
804
+ }
805
+ catch (e) {
806
+ return { status: 'error', detail: e.message };
807
+ }
808
+ if (!key)
809
+ return { status: 'error', detail: 'device key missing' };
810
+ const fetchFn = options.fetch ?? globalThis.fetch;
811
+ if (typeof fetchFn !== 'function')
812
+ return { status: 'error', detail: 'fetch unavailable' };
813
+ return await postRefresh(`${portalBase(options.baseUrl)}/cli/token/refresh`, current, key, now(), fetchFn, options.timeoutMs ?? TOKEN_REFRESH_TIMEOUT_MS);
814
+ }
815
+ finally {
816
+ unlock(lockFile);
817
+ }
818
+ }
819
+ /**
820
+ * 延ばしてから札を読む(読めなければ null・throw しない)。
821
+ * 401 を受けた読み手が「置き場を読み直して 1 回だけ撃ち直す」ときの読み直しに使う。
822
+ */
823
+ export async function readFreshCliToken(options = {}) {
824
+ await ensureFreshCliToken(options);
825
+ return tryReadCliToken();
826
+ }
827
+ async function postJson(url, body, opts) {
828
+ const fetchFn = opts.fetch ?? globalThis.fetch;
829
+ if (typeof fetchFn !== 'function')
830
+ return { ok: false, detail: 'fetch unavailable' };
831
+ const timeoutMs = opts.timeoutMs ?? TOKEN_REFRESH_TIMEOUT_MS;
832
+ const ac = new AbortController();
833
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
834
+ try {
835
+ const res = await fetchFn(url, {
836
+ method: 'POST',
837
+ headers: { 'Content-Type': 'application/json' },
838
+ body: JSON.stringify(body),
839
+ signal: ac.signal,
840
+ });
841
+ let json = null;
842
+ try {
843
+ json = (await res.json());
844
+ }
845
+ catch {
846
+ json = null;
847
+ }
848
+ return { ok: true, status: res.status, json };
849
+ }
850
+ catch (e) {
851
+ return { ok: false, detail: ac.signal.aborted ? `timeout ${timeoutMs}ms` : `network: ${e.message}` };
852
+ }
853
+ finally {
854
+ clearTimeout(timer);
855
+ }
856
+ }
857
+ /** `{success, data}` で包まれていても素でも中身を取る(純関数) */
858
+ function unwrapData(json) {
859
+ if (json && typeof json.data === 'object' && json.data !== null && !Array.isArray(json.data)) {
860
+ return json.data;
861
+ }
862
+ return json;
863
+ }
864
+ /** 登録を始める(番号をもらう)。公開鍵を送る=秘密鍵は機体から出ない */
865
+ export async function startDeviceRegistration(key, req, opts = {}) {
866
+ const r = await postJson(`${portalBase(opts.baseUrl)}/cli/device/start`, {
867
+ public_key: key.publicKeyPem,
868
+ device_name: req.deviceName,
869
+ platform: req.platform,
870
+ arch: req.arch,
871
+ os_user: req.osUser,
872
+ client: req.client,
873
+ ...(req.pinTenantId ? { pin_tenant_id: req.pinTenantId } : {}),
874
+ }, opts);
875
+ if (!r.ok)
876
+ return { ok: false, httpStatus: null, code: null, detail: r.detail };
877
+ const d = unwrapData(r.json);
878
+ const code = typeof r.json?.code === 'string' ? r.json.code : null;
879
+ if (r.status < 200 || r.status >= 300) {
880
+ const message = typeof r.json?.message === 'string' ? r.json.message : `http ${r.status}`;
881
+ return { ok: false, httpStatus: r.status, code, detail: message };
882
+ }
883
+ const deviceCode = d?.device_code;
884
+ const userCode = d?.user_code;
885
+ const verificationUri = d?.verification_uri;
886
+ if (typeof deviceCode !== 'string' || !deviceCode || typeof userCode !== 'string' || !userCode || typeof verificationUri !== 'string') {
887
+ return { ok: false, httpStatus: r.status, code, detail: 'invalid response' };
888
+ }
889
+ const num = (v, dflt) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : dflt);
890
+ return {
891
+ ok: true,
892
+ start: {
893
+ deviceCode,
894
+ userCode,
895
+ verificationUri,
896
+ expiresInSec: num(d?.expires_in, 600),
897
+ intervalSec: num(d?.interval, 5),
898
+ },
899
+ };
900
+ }
901
+ /** 承認を 1 回だけ問い合わせる(機体の鍵で署名する) */
902
+ export async function pollDeviceRegistration(key, deviceCode, opts = {}) {
903
+ const ts = Math.floor((opts.now ?? Date.now)() / 1000);
904
+ const signature = signWithDeviceKey(key, devicePollMessage(deviceCode, ts));
905
+ const r = await postJson(`${portalBase(opts.baseUrl)}/cli/device/poll`, { device_code: deviceCode, ts, signature }, opts);
906
+ if (!r.ok)
907
+ return { status: 'error', detail: r.detail };
908
+ if (r.status < 200 || r.status >= 300)
909
+ return { status: 'error', detail: `http ${r.status}` };
910
+ const d = unwrapData(r.json);
911
+ const status = d?.status;
912
+ if (status === 'pending' || status === 'denied' || status === 'expired')
913
+ return { status };
914
+ if (status !== 'approved')
915
+ return { status: 'error', detail: 'invalid response' };
916
+ const token = d?.token;
917
+ const expiresAt = parseExpiresAt(d?.expires_at);
918
+ const deviceId = d?.device_id;
919
+ if (!validateTokenFormat(token) || expiresAt === null || typeof deviceId !== 'string' || !deviceId) {
920
+ return { status: 'error', detail: 'invalid response' };
921
+ }
922
+ const user = (d?.user && typeof d.user === 'object' ? d.user : {});
923
+ return { status: 'approved', token, expiresAt, deviceId, user };
924
+ }
925
+ /** 承認された札を置き場に書く(札+期限+機体の id、口座ごとの機体の id)。書けなければ throw */
926
+ export function saveApprovedDevice(approved) {
927
+ const u = approved.user;
928
+ const userInfo = {
929
+ id: u.id,
930
+ name: u.name,
931
+ email: u.email,
932
+ role: u.rank ?? u.role,
933
+ language: u.language ?? 'ja',
934
+ active_tenant_id: u.active_tenant_id ?? null,
935
+ };
936
+ savePortalToken(approved.token, userInfo, { expiresAt: approved.expiresAt, deviceId: approved.deviceId });
937
+ if (u.id)
938
+ saveDeviceId(u.id, approved.deviceId);
939
+ return userInfo;
940
+ }
941
+ /**
942
+ * 承認・拒否・期限切れまで interval ごとに問い合わせる(網の一時の不調は待ちを続ける)。
943
+ * 返りは最後の答え。approved のときは置き場にまだ書いていない(saveApprovedDevice は呼び出し側)。
944
+ */
945
+ export async function awaitDeviceApproval(key, start, opts = {}) {
946
+ const now = opts.now ?? Date.now;
947
+ const wait = opts.sleep ?? sleep;
948
+ const deadline = now() + start.expiresInSec * 1000;
949
+ let lastError = null;
950
+ while (now() < deadline) {
951
+ await wait(start.intervalSec * 1000);
952
+ const r = await pollDeviceRegistration(key, start.deviceCode, opts);
953
+ if (r.status === 'pending') {
954
+ opts.onPending?.();
955
+ continue;
956
+ }
957
+ if (r.status === 'error') {
958
+ lastError = r.detail;
959
+ continue;
960
+ }
961
+ return r;
962
+ }
963
+ return lastError ? { status: 'error', detail: lastError } : { status: 'expired' };
964
+ }
359
965
  //# sourceMappingURL=index.js.map