@horizon_works/banto 0.6.0 → 0.7.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 (4) hide show
  1. package/README.md +113 -109
  2. package/license/client.js +284 -214
  3. package/package.json +1 -1
  4. package/server.js +1798 -1719
package/license/client.js CHANGED
@@ -1,214 +1,284 @@
1
- /**
2
- * 鍵の確認(番頭ゲート用)
3
- *
4
- * ★設計(2026-08-20 決定)
5
- * ・お試し30日。切れたら「使う機能は止まる/既にあるものは読める」
6
- * ・送るのは **鍵のハッシュ・版番号** だけ。★検査した文章も顧客名も送らない
7
- * ・1日1回しか問い合わせない(起動のたびに叩かない)
8
- * ・★通信できないときは通す(猶予7日)。止めると仕事ができなくなるため
9
- * ・★エンドポイント未設定なら、確認そのものをしない(=いまの動作のまま)
10
- *
11
- * 設定(どちらか)
12
- * 環境変数 BANTOU_LICENSE_KEY / BANTOU_LICENSE_URL
13
- * または license/config.json { "key": "...", "url": "..." }
14
- */
15
- const fs = require('fs');
16
- const path = require('path');
17
- const crypto = require('crypto');
18
- const https = require('https');
19
-
20
- const DIR = __dirname;
21
- const CACHE = path.join(DIR, '.state.json');
22
- const GRACE_DAYS = 7; // 通信できないときに通す日数
23
- const CHECK_EVERY_H = 24; // 問い合わせの間隔
24
-
25
- function config() {
26
- const c = { key: process.env.BANTOU_LICENSE_KEY || '', url: process.env.BANTOU_LICENSE_URL || '',
27
- anonKey: process.env.BANTOU_LICENSE_ANON || '' };
28
- const f = path.join(DIR, 'config.json');
29
- if (fs.existsSync(f)) {
30
- try {
31
- const j = JSON.parse(fs.readFileSync(f, 'utf8'));
32
- c.key = c.key || j.key || '';
33
- c.url = c.url || j.url || '';
34
- c.anonKey = c.anonKey || j.anonKey || '';
35
- } catch (_) {}
36
- }
37
- return c;
38
- }
39
-
40
- const readCache = () => {
41
- try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch (_) { return null; }
42
- };
43
- const writeCache = (o) => { try { fs.writeFileSync(CACHE, JSON.stringify(o, null, 2)); } catch (_) {} };
44
-
45
- // ★Supabase の PostgREST は apikey ヘッダーが必須(記録あり/2026-08-20 にここで踏んだ)
46
- function post(url, body, apiKey, timeoutMs = 4000) {
47
- return new Promise((resolve, reject) => {
48
- const u = new URL(url);
49
- const data = JSON.stringify(body);
50
- const req = https.request(
51
- { hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
52
- headers: Object.assign(
53
- { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
54
- apiKey ? { apikey: apiKey, Authorization: 'Bearer ' + apiKey } : {}
55
- ) },
56
- (res) => {
57
- let b = '';
58
- res.on('data', (d) => (b += d));
59
- res.on('end', () => {
60
- try { resolve(JSON.parse(b)); } catch (e) { reject(new Error('応答を読めません')); }
61
- });
62
- }
63
- );
64
- req.setTimeout(timeoutMs, () => { req.destroy(new Error('時間切れ')); });
65
- req.on('error', reject);
66
- req.write(data);
67
- req.end();
68
- });
69
- }
70
-
71
- /**
72
- * @returns {Promise<{allowed:boolean, status:string, message:string, daysLeft:number|null}>}
73
- * status: unconfigured / active / expiring / expired / revoked / unknown / offline
74
- */
75
- async function check(version) {
76
- const { key, url, anonKey } = config();
77
-
78
- // ★未設定なら確認しない(=制限なし)。導入前・自社利用がこの状態
79
- if (!key || !url || !anonKey) {
80
- return { allowed: true, status: 'unconfigured', message: '', daysLeft: null };
81
- }
82
-
83
- const keyHash = crypto.createHash('sha256').update(key).digest('hex');
84
- const cache = readCache();
85
- const now = Date.now();
86
-
87
- // 1日以内に確認済みなら、その結果を使う
88
- if (cache && cache.keyHash === keyHash && now - (cache.checkedAt || 0) < CHECK_EVERY_H * 3600e3) {
89
- return decide(cache, now);
90
- }
91
-
92
- try {
93
- // ★送るのは鍵のハッシュと版だけ
94
- const r0 = await post(url, { p_key_hash: keyHash, p_version: version || null }, config().anonKey);
95
- const r = Array.isArray(r0) ? (r0[0] || {}) : r0;
96
- const next = {
97
- keyHash, checkedAt: now,
98
- status: r.status || 'unknown',
99
- daysLeft: typeof r.days_left === 'number' ? r.days_left : null,
100
- plan: r.plan || null, expiresAt: r.expires_at || null,
101
- lastOkAt: r.status === 'active' ? now : (cache && cache.lastOkAt) || null,
102
- };
103
- writeCache(next);
104
- return decide(next, now);
105
- } catch (e) {
106
- // ★通信できない=止めない(猶予のあいだは通す)
107
- const lastOk = (cache && cache.lastOkAt) || 0;
108
- const withinGrace = lastOk && now - lastOk < GRACE_DAYS * 86400e3;
109
- return {
110
- allowed: true, status: 'offline', daysLeft: null,
111
- message: withinGrace ? '' :
112
- '※ライセンスの確認ができていません(通信不可)。しばらく続くようならご連絡ください',
113
- };
114
- }
115
- }
116
-
117
- function decide(c, now) {
118
- const d = c.daysLeft;
119
- if (c.status === 'active') {
120
- return {
121
- allowed: true, status: d !== null && d <= 3 ? 'expiring' : 'active', daysLeft: d,
122
- message: d !== null && d <= 3
123
- ? `※お試し期間はあと ${d} 日です。続けてお使いになる場合はご連絡ください` : '',
124
- };
125
- }
126
- if (c.status === 'expired') {
127
- return { allowed: false, status: 'expired', daysLeft: 0,
128
- message: 'お試し期間が終了しました。★これまでの記録はお手元にそのまま残っています(テキストのまま読めます)。続けてお使いになる場合はご連絡ください' };
129
- }
130
- if (c.status === 'revoked') {
131
- return { allowed: false, status: 'revoked', daysLeft: 0,
132
- message: 'この鍵は停止されています。★記録はお手元に残っています。ご連絡ください' };
133
- }
134
- return { allowed: false, status: 'unknown', daysLeft: null,
135
- message: 'この鍵が確認できませんでした。お手数ですがご連絡ください' };
136
- }
137
-
138
- // ═══════════════════════════════════════════════════════════════
139
- // 型キーの統計(段1)★既定オフのオプトイン
140
- //
141
- // ★送るのは「型キーの名前」と「件数」だけ。記録の本文・顧客名・文章は一切送らない。
142
- // → 送る中身は buildStats() が作る。**ここ以外で組み立てない**(口を1つにする)。
143
- // ★1日1回だけ。相手が「はい」と言うまで、この関数は一度も呼ばれない。
144
- // ═══════════════════════════════════════════════════════════════
145
-
146
- // ★お手元のフォルダに置く。★パッケージの中に置くと、版が上がったとき npx が入れ替えて消える
147
- // (=同じ日に2回送ってしまう。DB側は潰すので実害は無いが、印は残るところに置く)
148
- const HOME = process.env.BANTO_HOME || path.join(require('os').homedir(), '.banto');
149
- const STATS_CACHE = path.join(HOME, '.stats.json');
150
-
151
- /** 送ることに同意しているか(★既定オフ。ファイルが無ければオフ) */
152
- function isSharing() {
153
- try { return JSON.parse(fs.readFileSync(path.join(HOME, 'share.json'), 'utf8')).stats === true; }
154
- catch (_) { return false; }
155
- }
156
- /** 同意を切り替える(★人が明示的に呼んだときだけ) */
157
- function setSharing(on) {
158
- try { fs.mkdirSync(HOME, { recursive: true }); } catch (_) {}
159
- const f = path.join(HOME, 'share.json');
160
- const rec = { stats: on === true, changed_at: new Date().toISOString() };
161
- fs.writeFileSync(f, JSON.stringify(rec, null, 2));
162
- return rec;
163
- }
164
-
165
- /**
166
- * review() の結果から、送る形だけを取り出す
167
- * ★型キーの名前と数以外は、ここで落ちる(本文が通る経路を作らない)
168
- */
169
- function buildStats(review) {
170
- if (!review) return [];
171
- const out = [];
172
- const push = (sig, total, afterRuled) => {
173
- if (!sig) return;
174
- out.push({ sig: String(sig).slice(0, 64), total: Number(total) || 0,
175
- after_ruled: afterRuled === undefined || afterRuled === null ? null : Number(afterRuled) });
176
- };
177
- for (const r of review['ルールにする番'] || review.to_rule || []) push(r.sig, r.count, null);
178
- for (const r of review['ルールが効いていない'] || review.not_working || []) push(r.sig, r.since_ruled, r.since_ruled);
179
- for (const r of review['溜まりかけ'] || review.growing || []) push(r.sig, r.count, null);
180
- return out;
181
- }
182
-
183
- /**
184
- * 送る(★1日1回)
185
- * @returns {Promise<{sent:boolean, reason:string, count:number}>}
186
- */
187
- async function reportStats(version, review) {
188
- const { key, url, anonKey } = config();
189
- if (!key || !url || !anonKey) return { sent: false, reason: 'unconfigured', count: 0 };
190
-
191
- if (!isSharing()) return { sent: false, reason: 'opted-out', count: 0 };
192
-
193
- const stats = buildStats(review);
194
- if (!stats.length) return { sent: false, reason: 'nothing', count: 0 };
195
-
196
- // ★1日1回(同じ日に何度起動しても増えない)
197
- const today = new Date().toISOString().slice(0, 10);
198
- let cache = null;
199
- try { cache = JSON.parse(fs.readFileSync(STATS_CACHE, 'utf8')); } catch (_) {}
200
- if (cache && cache.on === today) return { sent: false, reason: 'already-today', count: 0 };
201
-
202
- const keyHash = crypto.createHash('sha256').update(key).digest('hex');
203
- const rpc = url.replace(/verify_license\/?$/, 'report_stats');
204
- try {
205
- await post(rpc, { p_key_hash: keyHash, p_version: version || null, p_stats: stats }, anonKey);
206
- try { fs.writeFileSync(STATS_CACHE, JSON.stringify({ on: today, count: stats.length }, null, 2)); } catch (_) {}
207
- return { sent: true, reason: 'ok', count: stats.length };
208
- } catch (e) {
209
- // ★送れなくても止めない(相手の仕事が止まる理由にしない)
210
- return { sent: false, reason: 'offline', count: 0 };
211
- }
212
- }
213
-
214
- module.exports = { check, reportStats, buildStats, isSharing, setSharing };
1
+ /**
2
+ * 鍵の確認(番頭ゲート用)
3
+ *
4
+ * ★設計(2026-08-20 決定)
5
+ * ・お試し30日。切れたら「使う機能は止まる/既にあるものは読める」
6
+ * ・送るのは **鍵のハッシュ・版番号** だけ。★検査した文章も顧客名も送らない
7
+ * ・1日1回しか問い合わせない(起動のたびに叩かない)
8
+ * ・★通信できないときは通す(猶予7日)。止めると仕事ができなくなるため
9
+ * ・★エンドポイント未設定なら、確認そのものをしない(=いまの動作のまま)
10
+ *
11
+ * 設定(どちらか)
12
+ * 環境変数 BANTOU_LICENSE_KEY / BANTOU_LICENSE_URL
13
+ * または license/config.json { "key": "...", "url": "..." }
14
+ */
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const crypto = require('crypto');
18
+ const https = require('https');
19
+
20
+ const DIR = __dirname;
21
+ const CACHE = path.join(DIR, '.state.json');
22
+ const GRACE_DAYS = 7; // 通信できないときに通す日数
23
+ const CHECK_EVERY_H = 24; // 問い合わせの間隔
24
+
25
+ function config() {
26
+ const c = { key: process.env.BANTOU_LICENSE_KEY || '', url: process.env.BANTOU_LICENSE_URL || '',
27
+ anonKey: process.env.BANTOU_LICENSE_ANON || '',
28
+ sig: process.env.BANTOU_LICENSE_SIG || '' };
29
+ const f = path.join(DIR, 'config.json');
30
+ if (fs.existsSync(f)) {
31
+ try {
32
+ const j = JSON.parse(fs.readFileSync(f, 'utf8'));
33
+ c.key = c.key || j.key || '';
34
+ c.url = c.url || j.url || '';
35
+ c.anonKey = c.anonKey || j.anonKey || '';
36
+ c.sig = c.sig || j.sig || '';
37
+ } catch (_) {}
38
+ }
39
+ return c;
40
+ }
41
+
42
+ // 🚨★**鍵の署名を、★手元で確かめる**(2026-08-28)
43
+ // ★Ed25519 の★**公開鍵**。★これは配ってよい(★秘密鍵は配布物に入れない)。
44
+ // ★★**サーバーに聞く前に、★偽の鍵を弾ける。★通信できなくても効く。**
45
+ //
46
+ // 🚨★**これは「先に弾く」ためであって、★守りの本体ではない。**
47
+ // ★このファイルを書き替えれば外せる。★止めたいのは「うっかり使い続ける」であって、
48
+ // ★書き替える人ではない(→ pharmacy/law-parts-v0.1/ANONYMIZATION.md「金庫ではなく鍵」)。
49
+ const SIGN_PUBKEY_B64 = 'MCowBQYDK2VwAyEADprl/N1XFaWo8TFNyNCjKBk06iAmbCQQg/u4LwDpw/Q=';
50
+
51
+ function verifyKeySignature(key, sig) {
52
+ if (!key) return { ok: false, why: 'no-key' };
53
+ if (!sig) return { ok: false, why: 'no-sig' }; // ★署名が無い=旧い形の鍵
54
+ try {
55
+ const pub = crypto.createPublicKey({
56
+ key: Buffer.from(SIGN_PUBKEY_B64, 'base64'), format: 'der', type: 'spki',
57
+ });
58
+ const ok = crypto.verify(null, Buffer.from(key, 'utf8'), pub, Buffer.from(sig, 'base64'));
59
+ return { ok, why: ok ? '' : 'bad-sig' };
60
+ } catch (e) {
61
+ return { ok: false, why: 'verify-error' };
62
+ }
63
+ }
64
+
65
+ const readCache = () => {
66
+ try { return JSON.parse(fs.readFileSync(CACHE, 'utf8')); } catch (_) { return null; }
67
+ };
68
+ const writeCache = (o) => { try { fs.writeFileSync(CACHE, JSON.stringify(o, null, 2)); } catch (_) {} };
69
+
70
+ // ★Supabase の PostgREST は apikey ヘッダーが必須(記録あり/2026-08-20 にここで踏んだ)
71
+ function post(url, body, apiKey, timeoutMs = 4000) {
72
+ return new Promise((resolve, reject) => {
73
+ const u = new URL(url);
74
+ const data = JSON.stringify(body);
75
+ const req = https.request(
76
+ { hostname: u.hostname, path: u.pathname + u.search, method: 'POST',
77
+ headers: Object.assign(
78
+ { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
79
+ apiKey ? { apikey: apiKey, Authorization: 'Bearer ' + apiKey } : {}
80
+ ) },
81
+ (res) => {
82
+ let b = '';
83
+ res.on('data', (d) => (b += d));
84
+ res.on('end', () => {
85
+ try { resolve(JSON.parse(b)); } catch (e) { reject(new Error('応答を読めません')); }
86
+ });
87
+ }
88
+ );
89
+ req.setTimeout(timeoutMs, () => { req.destroy(new Error('時間切れ')); });
90
+ req.on('error', reject);
91
+ req.write(data);
92
+ req.end();
93
+ });
94
+ }
95
+
96
+ /**
97
+ * @returns {Promise<{allowed:boolean, status:string, message:string, daysLeft:number|null}>}
98
+ * status: unconfigured / active / expiring / expired / revoked / unknown / offline
99
+ */
100
+ // 🚨★**通信しないで、★最後に確かめた結果を返す**(2026-08-28)
101
+ // ★由来=「利用者が残り日数を見られるようにしたい」(2026-08-28)。
102
+ // ★`checkup` は同期の道具なので、★ここで待てない。
103
+ // ★★**1日1回の確認結果がキャッシュにあるので、★それを見せる。**
104
+ // ★署名は通信が要らないので、★ここでも確かめる。
105
+ function peek() {
106
+ const { key, url, anonKey, sig } = config();
107
+ if (!key || !url || !anonKey) {
108
+ return { allowed: true, status: 'unconfigured', daysLeft: null, message: '' };
109
+ }
110
+ const v = verifyKeySignature(key, sig);
111
+ if (!v.ok) {
112
+ return { allowed: false, status: 'bad-signature', daysLeft: null, message: '' };
113
+ }
114
+ const cache = readCache();
115
+ if (!cache) {
116
+ return { allowed: true, status: 'not-checked-yet', daysLeft: null, message: '' };
117
+ }
118
+ // 🚨★**キャッシュの持ち主を必ず確かめる**(2026-08-28 に踏んだ)。
119
+ // ★`check()` keyHash を突き合わせているのに、★`peek()` は見ていなかった。
120
+ // ★★試験で別の鍵を通したあと、★自社の鍵で peek したら★**その別の鍵の残日数**が返った。
121
+ // ★→ ★**別人の状態が見える**ところだった。
122
+ const keyHash = crypto.createHash('sha256').update(key).digest('hex');
123
+ if (cache.keyHash !== keyHash) {
124
+ return { allowed: true, status: 'not-checked-yet', daysLeft: null, message: '' };
125
+ }
126
+ return decide(cache, Date.now());
127
+ }
128
+
129
+ async function check(version) {
130
+ const { key, url, anonKey, sig } = config();
131
+
132
+ // 🚨★**サーバーへ聞く前に、★手元で署名を確かめる**(2026-08-28)
133
+ // ★鍵が設定されているのに署名が通らない=★偽物か、★渡し間違い。
134
+ // ★★**通信の前に止める**(★サーバーが落ちていても効く)。
135
+ if (key) {
136
+ const v = verifyKeySignature(key, sig);
137
+ if (!v.ok) {
138
+ return {
139
+ allowed: false, status: 'bad-signature', daysLeft: null,
140
+ message: (v.why === 'no-sig')
141
+ ? '★この鍵には署名が付いていません。★発行元へ新しい鍵をご請求ください'
142
+ : '★鍵の署名が確認できませんでした。★鍵と署名(sig)が対になっているかご確認ください',
143
+ };
144
+ }
145
+ }
146
+
147
+
148
+ // ★未設定なら確認しない(=制限なし)。導入前・自社利用がこの状態
149
+ if (!key || !url || !anonKey) {
150
+ return { allowed: true, status: 'unconfigured', message: '', daysLeft: null };
151
+ }
152
+
153
+ const keyHash = crypto.createHash('sha256').update(key).digest('hex');
154
+ const cache = readCache();
155
+ const now = Date.now();
156
+
157
+ // 1日以内に確認済みなら、その結果を使う
158
+ if (cache && cache.keyHash === keyHash && now - (cache.checkedAt || 0) < CHECK_EVERY_H * 3600e3) {
159
+ return decide(cache, now);
160
+ }
161
+
162
+ try {
163
+ // ★送るのは鍵のハッシュと版だけ
164
+ const r0 = await post(url, { p_key_hash: keyHash, p_version: version || null }, config().anonKey);
165
+ const r = Array.isArray(r0) ? (r0[0] || {}) : r0;
166
+ const next = {
167
+ keyHash, checkedAt: now,
168
+ status: r.status || 'unknown',
169
+ daysLeft: typeof r.days_left === 'number' ? r.days_left : null,
170
+ plan: r.plan || null, expiresAt: r.expires_at || null,
171
+ lastOkAt: r.status === 'active' ? now : (cache && cache.lastOkAt) || null,
172
+ };
173
+ writeCache(next);
174
+ return decide(next, now);
175
+ } catch (e) {
176
+ // ★通信できない=止めない(猶予のあいだは通す)
177
+ const lastOk = (cache && cache.lastOkAt) || 0;
178
+ const withinGrace = lastOk && now - lastOk < GRACE_DAYS * 86400e3;
179
+ return {
180
+ allowed: true, status: 'offline', daysLeft: null,
181
+ message: withinGrace ? '' :
182
+ '※ライセンスの確認ができていません(通信不可)。しばらく続くようならご連絡ください',
183
+ };
184
+ }
185
+ }
186
+
187
+ function decide(c, now) {
188
+ const d = c.daysLeft;
189
+ if (c.status === 'active') {
190
+ return {
191
+ allowed: true, status: d !== null && d <= 3 ? 'expiring' : 'active', daysLeft: d,
192
+ message: d !== null && d <= 3
193
+ ? `※お試し期間はあと ${d} 日です。続けてお使いになる場合はご連絡ください` : '',
194
+ };
195
+ }
196
+ if (c.status === 'expired') {
197
+ return { allowed: false, status: 'expired', daysLeft: 0,
198
+ message: 'お試し期間が終了しました。★これまでの記録はお手元にそのまま残っています(テキストのまま読めます)。続けてお使いになる場合はご連絡ください' };
199
+ }
200
+ if (c.status === 'revoked') {
201
+ return { allowed: false, status: 'revoked', daysLeft: 0,
202
+ message: 'この鍵は停止されています。★記録はお手元に残っています。ご連絡ください' };
203
+ }
204
+ return { allowed: false, status: 'unknown', daysLeft: null,
205
+ message: 'この鍵が確認できませんでした。お手数ですがご連絡ください' };
206
+ }
207
+
208
+ // ═══════════════════════════════════════════════════════════════
209
+ // 型キーの統計(段1)★既定オフのオプトイン
210
+ //
211
+ // ★送るのは「型キーの名前」と「件数」だけ。記録の本文・顧客名・文章は一切送らない。
212
+ // → 送る中身は buildStats() が作る。**ここ以外で組み立てない**(口を1つにする)。
213
+ // ★1日1回だけ。相手が「はい」と言うまで、この関数は一度も呼ばれない。
214
+ // ═══════════════════════════════════════════════════════════════
215
+
216
+ // ★お手元のフォルダに置く。★パッケージの中に置くと、版が上がったとき npx が入れ替えて消える
217
+ // (=同じ日に2回送ってしまう。DB側は潰すので実害は無いが、印は残るところに置く)
218
+ const HOME = process.env.BANTO_HOME || path.join(require('os').homedir(), '.banto');
219
+ const STATS_CACHE = path.join(HOME, '.stats.json');
220
+
221
+ /** 送ることに同意しているか(★既定オフ。ファイルが無ければオフ) */
222
+ function isSharing() {
223
+ try { return JSON.parse(fs.readFileSync(path.join(HOME, 'share.json'), 'utf8')).stats === true; }
224
+ catch (_) { return false; }
225
+ }
226
+ /** 同意を切り替える(★人が明示的に呼んだときだけ) */
227
+ function setSharing(on) {
228
+ try { fs.mkdirSync(HOME, { recursive: true }); } catch (_) {}
229
+ const f = path.join(HOME, 'share.json');
230
+ const rec = { stats: on === true, changed_at: new Date().toISOString() };
231
+ fs.writeFileSync(f, JSON.stringify(rec, null, 2));
232
+ return rec;
233
+ }
234
+
235
+ /**
236
+ * review() の結果から、送る形だけを取り出す
237
+ * ★型キーの名前と数以外は、ここで落ちる(本文が通る経路を作らない)
238
+ */
239
+ function buildStats(review) {
240
+ if (!review) return [];
241
+ const out = [];
242
+ const push = (sig, total, afterRuled) => {
243
+ if (!sig) return;
244
+ out.push({ sig: String(sig).slice(0, 64), total: Number(total) || 0,
245
+ after_ruled: afterRuled === undefined || afterRuled === null ? null : Number(afterRuled) });
246
+ };
247
+ for (const r of review['ルールにする番'] || review.to_rule || []) push(r.sig, r.count, null);
248
+ for (const r of review['ルールが効いていない'] || review.not_working || []) push(r.sig, r.since_ruled, r.since_ruled);
249
+ for (const r of review['溜まりかけ'] || review.growing || []) push(r.sig, r.count, null);
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * 送る(★1日1回)
255
+ * @returns {Promise<{sent:boolean, reason:string, count:number}>}
256
+ */
257
+ async function reportStats(version, review) {
258
+ const { key, url, anonKey } = config();
259
+ if (!key || !url || !anonKey) return { sent: false, reason: 'unconfigured', count: 0 };
260
+
261
+ if (!isSharing()) return { sent: false, reason: 'opted-out', count: 0 };
262
+
263
+ const stats = buildStats(review);
264
+ if (!stats.length) return { sent: false, reason: 'nothing', count: 0 };
265
+
266
+ // ★1日1回(同じ日に何度起動しても増えない)
267
+ const today = new Date().toISOString().slice(0, 10);
268
+ let cache = null;
269
+ try { cache = JSON.parse(fs.readFileSync(STATS_CACHE, 'utf8')); } catch (_) {}
270
+ if (cache && cache.on === today) return { sent: false, reason: 'already-today', count: 0 };
271
+
272
+ const keyHash = crypto.createHash('sha256').update(key).digest('hex');
273
+ const rpc = url.replace(/verify_license\/?$/, 'report_stats');
274
+ try {
275
+ await post(rpc, { p_key_hash: keyHash, p_version: version || null, p_stats: stats }, anonKey);
276
+ try { fs.writeFileSync(STATS_CACHE, JSON.stringify({ on: today, count: stats.length }, null, 2)); } catch (_) {}
277
+ return { sent: true, reason: 'ok', count: stats.length };
278
+ } catch (e) {
279
+ // ★送れなくても止めない(相手の仕事が止まる理由にしない)
280
+ return { sent: false, reason: 'offline', count: 0 };
281
+ }
282
+ }
283
+
284
+ module.exports = { check, reportStats, buildStats, isSharing, setSharing , peek};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@horizon_works/banto",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "AI番頭のOS。ルール・記憶・検査を1本のMCPで配ります。中身をクラウドへ渡さずに検査できます。",
5
5
  "keywords": [
6
6
  "mcp",