@bluelamp/cli-token 1.2.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/README.md CHANGED
@@ -29,6 +29,7 @@ const maybe = tryReadCliToken();
29
29
  | shell(I/O あり) | `getTokenFilePath` / `readCliTokenSync` / `tryReadCliToken` / `loadPortalToken` / `savePortalToken` / `deletePortalToken` |
30
30
  | 艦隊の資格の門(1.1.0・n=2607) | `checkFleetGate` / `isAllowedForScope` / `getFleetEntitlementFilePath` / `getFleetGateLogPath` / `formatFleetGateLogLine` / `fleetGateRefusalMessage` / `parseFleetEntitlementCache` / `isFleetEntitlementFresh` |
31
31
  | 交換済み JWT の機体共有キャッシュ(1.2.0・n=2774) | `getExchangedJwt` / `evictExchangedJwt` / `exchangedJwtCacheKey` / `getExchangedJwtCacheDir` / `parseCachedExchangedJwt` / `isCachedExchangedJwtUsable` |
32
+ | 機体の鍵・登録・札の延長(1.3.0・n=3918) | `startDeviceRegistration` / `pollDeviceRegistration` / `awaitDeviceApproval` / `saveApprovedDevice` / `loadOrCreateDeviceKey` / `readDeviceKey` / `signWithDeviceKey` / `devicePollMessage` / `tokenRefreshMessage` / `readDeviceId` / `saveDeviceId` / `ensureFreshCliToken` / `readFreshCliToken` / `needsRefresh` / `parseExpiresAt` |
32
33
 
33
34
  ### 艦隊の資格の門(`checkFleetGate`)
34
35
 
@@ -53,6 +54,16 @@ const maybe = tryReadCliToken();
53
54
  - 受け手が 401 を返したら `evictExchangedJwt(key, 拒まれた JWT)` → もう一度 `getExchangedJwt`(中身が同じ JWT のときだけ消す)
54
55
  - キャッシュの不調(読めない・書けない・ロックが取れない)は exchange に倒す。exchange の失敗だけは throw のまま返す
55
56
 
57
+ ### 機体の鍵と札の延長(`ensureFreshCliToken`・1.3.0)
58
+
59
+ CLI の札を機体に結び、短い期限(1 時間)を裏で延ばす(箱 n=3918)。
60
+
61
+ - 機体の鍵: Ed25519。`~/.musuhi/device-key.pem`(PKCS8・0600・フォルダ 0700)。OS ユーザー 1 つに鍵 1 つ。⛔ OS の鍵束に置かない(SSH・launchd の便で開かない)。権限が開いた鍵は使わない(`DeviceKeyError`)
62
+ - 口座ごとの機体の id: `~/.musuhi/device.json`(0600)`{ "<user_id>": "<device_id>" }`
63
+ - 札の置き場(`portal-token.enc`)は 0600・一時ファイル→rename で書く。機体に結んだ札は `expiresAt`(epoch ms)と `deviceId` を持つ。持たない札は古い札(延ばさない)
64
+ - `ensureFreshCliToken()`: 残り 15 分を切った札だけ、`~/.musuhi/token-refresh.lock`(O_EXCL・2 分より古いロックは払う)を取れた 1 手が `POST {base}/cli/token/refresh {device_id, ts, nonce, signature}`(`X-CLI-Token`=今の札)を撃つ。署名の文は `bluelamp-token-refresh\n{device_id}\n{ts}\n{nonce}`(ts は epoch 秒・署名は base64)。他の手は最大 5 秒待って置き場を読む。throw しない。断られても置き場を消さない
65
+ - 読み手は 401 を受けたら `readFreshCliToken()` で読み直して 1 回だけ撃ち直す(普段の要求は今までどおり札だけ・1 回ごとに署名しない)
66
+
56
67
  ## 制約
57
68
 
58
69
  - **node 標準モジュール以外に依存しない**(VSCode 拡張が webpack で bundle するため)。
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.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;
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;
@@ -73,6 +73,25 @@ exports.parseCachedExchangedJwt = parseCachedExchangedJwt;
73
73
  exports.isCachedExchangedJwtUsable = isCachedExchangedJwtUsable;
74
74
  exports.getExchangedJwt = getExchangedJwt;
75
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;
76
95
  const node_crypto_1 = require("node:crypto");
77
96
  const fs = __importStar(require("node:fs"));
78
97
  const os = __importStar(require("node:os"));
@@ -117,8 +136,12 @@ class CliTokenNotFoundError extends Error {
117
136
  }
118
137
  }
119
138
  exports.CliTokenNotFoundError = CliTokenNotFoundError;
139
+ // 入力が定数なので 1 回だけ導く(scrypt は 1 回数十 ms。札を読むたびに払わない)
140
+ let derivedKeyCache = null;
120
141
  function derivedKey() {
121
- 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;
122
145
  }
123
146
  // ── functional core(I/O を持たない。ここだけで判定と変換が閉じる)──────────────
124
147
  /** 平文を `<ivHex>:<payloadHex>` にする。IV は毎回ランダム。 */
@@ -204,8 +227,12 @@ function loadPortalToken() {
204
227
  }
205
228
  return data;
206
229
  }
207
- /** トークンを暗号化保存する。フォーマット不正・書込失敗は throw。 */
208
- function savePortalToken(token, userInfo = {}) {
230
+ /**
231
+ * トークンを暗号化保存する。フォーマット不正・書込失敗は throw。
232
+ * n=3918: 置き場は 0600(フォルダ 0700)。一時ファイルに書いてから rename する=読み手が書きかけを読まない。
233
+ * `binding` は機体に結んだ札だけが渡す(期限と機体の id)。
234
+ */
235
+ function savePortalToken(token, userInfo = {}, binding) {
209
236
  if (!validateTokenFormat(token)) {
210
237
  throw new Error('Invalid CLI token format');
211
238
  }
@@ -215,13 +242,33 @@ function savePortalToken(token, userInfo = {}) {
215
242
  userInfo,
216
243
  version: '1.0',
217
244
  source: 'portal',
245
+ ...(binding ? { expiresAt: binding.expiresAt, deviceId: binding.deviceId } : {}),
218
246
  };
219
- const filePath = getTokenFilePath();
220
- const dir = path.dirname(filePath);
221
- if (!fs.existsSync(dir)) {
222
- 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;
223
271
  }
224
- fs.writeFileSync(filePath, encryptPayload(JSON.stringify(tokenData)));
225
272
  }
226
273
  /** 保存済みトークンを削除する。ファイル無しは no-op。削除失敗は throw。 */
227
274
  function deletePortalToken() {
@@ -489,7 +536,7 @@ function writeCachedJwt(file, c) {
489
536
  }
490
537
  }
491
538
  /** ロックを取る。取れたら true。古いロック(持ち主が死んだ)は 1 回だけ払って取り直す */
492
- function tryLock(lockFile, nowMs) {
539
+ function tryLock(lockFile, nowMs, staleMs = exports.EXCHANGED_JWT_LOCK_STALE_MS) {
493
540
  for (let attempt = 0; attempt < 2; attempt++) {
494
541
  try {
495
542
  fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 });
@@ -500,7 +547,7 @@ function tryLock(lockFile, nowMs) {
500
547
  if (e.code !== 'EEXIST')
501
548
  return false;
502
549
  try {
503
- if (nowMs - fs.statSync(lockFile).mtimeMs <= exports.EXCHANGED_JWT_LOCK_STALE_MS)
550
+ if (nowMs - fs.statSync(lockFile).mtimeMs <= staleMs)
504
551
  return false;
505
552
  fs.unlinkSync(lockFile);
506
553
  }
@@ -576,4 +623,426 @@ function evictExchangedJwt(key, rejectedToken, cacheDir = getExchangedJwtCacheDi
576
623
  // 無ければ何もしない
577
624
  }
578
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
+ }
579
1048
  //# sourceMappingURL=index.js.map