bom-pi 0.0.2 → 0.0.3

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.
@@ -97,7 +97,7 @@ function mapApproval(a) {
97
97
 
98
98
  const OWNER = { id: 'bom-owner', name: 'Owner', role: 'owner' };
99
99
 
100
- export function makePocketAdapter({ rpc, checkToken }) {
100
+ export function makePocketAdapter({ rpc, checkToken, push }) {
101
101
  // me-shape: mọi thứ UI cần biết về server (chụp từ pocket 0.27.0 thật)
102
102
  async function mePayload() {
103
103
  const hello = await rpc('hello');
@@ -186,7 +186,7 @@ export function makePocketAdapter({ rpc, checkToken }) {
186
186
  if (isPost && req.headers['x-pocket'] !== '1') return false; // POST pocket luôn có X-Pocket (api.ts l365)
187
187
  if (!['me', 'login', 'logout', 'sessions', 'c', 'providers', 'themes', 'theme', 'events', 'poll',
188
188
  'users', 'hooks', 'subagents', 'pip', 'extensions', 'extension-settings', 'fs', 'visibility', 'peeks',
189
- 'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals'].includes(first)) return false;
189
+ 'folder-favorites', 'spend', 'pi-sessions', 'x', 'repo-watches', 'approvals', 'push'].includes(first)) return false;
190
190
 
191
191
  try {
192
192
  // ---- sign-in/lifecycle ----
@@ -275,6 +275,36 @@ export function makePocketAdapter({ rpc, checkToken }) {
275
275
  return json(res, 200, true), true;
276
276
  }
277
277
 
278
+ // ---- push: phone notifications (Web Push RFC 8291/8292/8030 — push.mjs port pi-pocket) ----
279
+ if (first === 'push') {
280
+ if (!push) return json(res, 503, { error: 'Push notifications are not available on this server.' }), true;
281
+ const { store, userId } = push;
282
+ if (second === undefined && method === 'GET') {
283
+ return json(res, 200, { publicKey: store.publicKey, prefs: store.prefs(userId), devices: store.subscriptions(userId).length }), true;
284
+ }
285
+ if (second === 'subscribe' && isPost) {
286
+ const body = await readBody(req);
287
+ try { store.subscribe(userId, body.subscription, String(req.headers['user-agent'] ?? '').slice(0, 200)); }
288
+ catch (e) { return json(res, 400, { error: String(e?.message ?? e) }), true; }
289
+ return json(res, 200, { ok: true, devices: store.subscriptions(userId).length }), true;
290
+ }
291
+ if (second === 'unsubscribe' && isPost) {
292
+ const body = await readBody(req);
293
+ store.unsubscribe(String(body.endpoint ?? ''), userId);
294
+ return json(res, 200, { ok: true, devices: store.subscriptions(userId).length }), true;
295
+ }
296
+ if (second === 'prefs' && isPost) {
297
+ const body = await readBody(req);
298
+ return json(res, 200, { prefs: store.setPrefs(userId, body) }), true;
299
+ }
300
+ if (second === 'test' && isPost) {
301
+ await readBody(req);
302
+ const sent = await store.notify(userId, { title: 'bom', body: 'Notifications work on this device.', url: '/', tag: 'test' });
303
+ if (sent === 0) return json(res, 409, { error: 'No device took the test notification. Turn notifications on again on this device.' }), true;
304
+ return json(res, 200, { sent }), true;
305
+ }
306
+ }
307
+
278
308
  // ---- providers/themes ----
279
309
  if (first === 'providers' && method === 'GET') {
280
310
  const models = ((await rpc('models.list')).models ?? []).map(mapModel);
@@ -1,7 +1,7 @@
1
1
  {
2
- "name": "Pi Pocket",
3
- "short_name": "Pi Pocket",
4
- "description": "Durable, multiplayer Pi agents in your browser",
2
+ "name": "bom",
3
+ "short_name": "bom",
4
+ "description": "bom × pi — durable factories of pi agents in your browser",
5
5
  "start_url": "/",
6
6
  "scope": "/",
7
7
  "display": "standalone",
@@ -0,0 +1,211 @@
1
+ // push.mjs — Web Push không dependency (port từ pi-pocket src/server/push.ts, MIT):
2
+ // RFC 8291 aes128gcm + RFC 8292 VAPID (ES256) + RFC 8030 POST. PushStore giữ VAPID keys,
3
+ // subscription mỗi user, prefs — push.json ở dataDir (atomic write 0600).
4
+ import {
5
+ createCipheriv, createECDH, createHash, createHmac, createPrivateKey, randomBytes, sign,
6
+ } from 'node:crypto';
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+
10
+ export const DEFAULT_PREFS = Object.freeze({ done: true, approval: true, chat: true, mention: true, limit: true });
11
+ const DEFAULT_SUBJECT = 'mailto:bom-pi@localhost';
12
+ const DEFAULT_RECORD_SIZE = 4096;
13
+ const MAX_BODY = 1000;
14
+ const MAX_TITLE = 200;
15
+ const MAX_DEVICES = 10;
16
+
17
+ const hmac = (key, ...data) => {
18
+ const mac = createHmac('sha256', key);
19
+ for (const part of data) mac.update(part);
20
+ return mac.digest();
21
+ };
22
+ const decode = (value) => Buffer.from(String(value).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'), 'base64url');
23
+ const pad32 = (value) => (value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]));
24
+
25
+ export function generateVapidKeys() {
26
+ const ecdh = createECDH('prime256v1');
27
+ ecdh.generateKeys();
28
+ return { publicKey: ecdh.getPublicKey().toString('base64url'), privateKey: pad32(ecdh.getPrivateKey()).toString('base64url') };
29
+ }
30
+
31
+ /** RFC 8291 aes128gcm body cho MỘT record. */
32
+ export function encryptPayload(plaintext, keys, options = {}) {
33
+ const data = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext;
34
+ const uaPublic = decode(keys.p256dh);
35
+ const authSecret = decode(keys.auth);
36
+ if (uaPublic.length !== 65 || uaPublic[0] !== 0x04) throw new Error('p256dh must be an uncompressed P-256 point');
37
+ if (authSecret.length !== 16) throw new Error('auth must be 16 bytes');
38
+ const recordSize = options.recordSize ?? DEFAULT_RECORD_SIZE;
39
+ if (data.length + 1 + 16 > recordSize) throw new Error(`push payload too large: ${data.length} bytes`);
40
+ const salt = options.salt ?? randomBytes(16);
41
+ const ecdh = createECDH('prime256v1');
42
+ if (options.serverKeys === undefined) ecdh.generateKeys();
43
+ else ecdh.setPrivateKey(options.serverKeys.privateKey);
44
+ const asPublic = ecdh.getPublicKey();
45
+ const ecdhSecret = ecdh.computeSecret(uaPublic);
46
+ const prkKey = hmac(authSecret, ecdhSecret);
47
+ const keyInfo = Buffer.concat([Buffer.from('WebPush: info\0', 'latin1'), uaPublic, asPublic]);
48
+ const ikm = hmac(prkKey, keyInfo, Buffer.from([1]));
49
+ const prk = hmac(salt, ikm);
50
+ const cek = hmac(prk, Buffer.from('Content-Encoding: aes128gcm\0\x01', 'latin1')).subarray(0, 16);
51
+ const nonce = hmac(prk, Buffer.from('Content-Encoding: nonce\0\x01', 'latin1')).subarray(0, 12);
52
+ const cipher = createCipheriv('aes-128-gcm', cek, nonce);
53
+ const padded = Buffer.concat([data, Buffer.from([2])]);
54
+ const ciphertext = Buffer.concat([cipher.update(padded), cipher.final(), cipher.getAuthTag()]);
55
+ const header = Buffer.alloc(16 + 4 + 1);
56
+ salt.copy(header, 0);
57
+ header.writeUInt32BE(recordSize, 16);
58
+ header.writeUInt8(asPublic.length, 20);
59
+ return Buffer.concat([header, asPublic, ciphertext]);
60
+ }
61
+
62
+ /** Authorization `vapid t=<jwt>, k=<pub>` (ES256, ieee-p1363 raw r||s). */
63
+ export function vapidAuthorization(endpoint, vapid, subject, now = Date.now()) {
64
+ const publicKey = decode(vapid.publicKey);
65
+ if (publicKey.length !== 65 || publicKey[0] !== 0x04) throw new Error('VAPID public key must be an uncompressed P-256 point');
66
+ const key = createPrivateKey({
67
+ key: {
68
+ kty: 'EC', crv: 'P-256',
69
+ x: publicKey.subarray(1, 33).toString('base64url'),
70
+ y: publicKey.subarray(33, 65).toString('base64url'),
71
+ d: pad32(decode(vapid.privateKey)).toString('base64url'),
72
+ },
73
+ format: 'jwk',
74
+ });
75
+ const header = Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' })).toString('base64url');
76
+ const claims = { aud: new URL(endpoint).origin, exp: Math.floor(now / 1000) + 12 * 60 * 60, sub: subject };
77
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url');
78
+ const signingInput = `${header}.${payload}`;
79
+ const signature = sign('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' });
80
+ return `vapid t=${signingInput}.${signature.toString('base64url')}, k=${publicKey.toString('base64url')}`;
81
+ }
82
+
83
+ const topicFor = (tag) => (/^[A-Za-z0-9_-]{1,32}$/.test(tag) ? tag : createHash('sha256').update(tag).digest('base64url').slice(0, 32));
84
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
85
+
86
+ /** POST bản mã tới push service — trả HTTP status. */
87
+ export async function sendPush(subscription, message, vapid, options = {}) {
88
+ // JS không có type-check như TS gốc — mặc định an toàn khi trường thiếu
89
+ const payload = { title: clip(String(message.title ?? 'bom'), MAX_TITLE), body: clip(String(message.body ?? ''), MAX_BODY), url: message.url };
90
+ if (message.tag !== undefined) payload.tag = message.tag;
91
+ if (message.approval !== undefined) payload.approval = message.approval;
92
+ const body = encryptPayload(JSON.stringify(payload), subscription.keys);
93
+ const headers = {
94
+ 'Content-Encoding': 'aes128gcm',
95
+ 'Content-Type': 'application/octet-stream',
96
+ TTL: String(options.ttl ?? 24 * 60 * 60),
97
+ Urgency: options.urgency ?? 'normal',
98
+ Authorization: vapidAuthorization(subscription.endpoint, vapid, options.subject ?? DEFAULT_SUBJECT),
99
+ };
100
+ if (message.tag !== undefined && message.tag !== '') headers.Topic = topicFor(message.tag);
101
+ const doFetch = options.fetch ?? fetch;
102
+ const response = await doFetch(subscription.endpoint, { method: 'POST', headers, body: new Uint8Array(body) });
103
+ await response.arrayBuffer().catch(() => undefined);
104
+ return response.status;
105
+ }
106
+
107
+ // Chỉ nhận endpoint của các push service trình duyệt thật (chặn dùng server dò địa chỉ nội bộ)
108
+ const PUSH_HOSTS = [
109
+ /^fcm\.googleapis\.com$/,
110
+ /^android\.googleapis\.com$/,
111
+ /(^|\.)push\.services\.mozilla\.com$/,
112
+ /(^|\.)notify\.windows\.com$/,
113
+ /(^|\.)push\.apple\.com$/,
114
+ ];
115
+
116
+ export function validateSubscription(subscription) {
117
+ if (subscription === null || typeof subscription !== 'object') throw new Error('subscription must be an object');
118
+ if (typeof subscription.endpoint !== 'string') throw new Error('subscription endpoint is missing');
119
+ let url;
120
+ try { url = new URL(subscription.endpoint); } catch { throw new Error('subscription endpoint is not a valid URL'); }
121
+ if (url.protocol !== 'https:') throw new Error('subscription endpoint must be an https URL');
122
+ if (url.port !== '' || url.username !== '' || url.password !== '' || !PUSH_HOSTS.some((host) => host.test(url.hostname))) {
123
+ throw new Error(`${url.host} is not a browser push service`);
124
+ }
125
+ const keys = subscription.keys;
126
+ if (keys === null || typeof keys !== 'object' || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
127
+ throw new Error('subscription keys.p256dh and keys.auth are required');
128
+ }
129
+ const p256dh = decode(keys.p256dh);
130
+ if (p256dh.length !== 65 || p256dh[0] !== 0x04) throw new Error('subscription keys.p256dh must be a 65-byte uncompressed P-256 public key');
131
+ if (decode(keys.auth).length !== 16) throw new Error('subscription keys.auth must be 16 bytes');
132
+ return { endpoint: subscription.endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } };
133
+ }
134
+
135
+ /** push.json ở dataDir — VAPID sinh lần đầu, subscriptions + prefs mỗi user. */
136
+ export class PushStore {
137
+ constructor(directory) {
138
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
139
+ this.file = join(directory, 'push.json');
140
+ if (existsSync(this.file)) {
141
+ const loaded = JSON.parse(readFileSync(this.file, 'utf8'));
142
+ this.data = { version: 1, vapid: loaded.vapid ?? generateVapidKeys(), subscriptions: loaded.subscriptions ?? [], prefs: loaded.prefs ?? {} };
143
+ if (loaded.vapid === undefined) this.save();
144
+ } else {
145
+ this.data = { version: 1, vapid: generateVapidKeys(), subscriptions: [], prefs: {} };
146
+ this.save();
147
+ }
148
+ }
149
+ get publicKey() { return this.data.vapid.publicKey; }
150
+ get vapid() { return { ...this.data.vapid }; }
151
+ subscriptions(userId) {
152
+ return this.data.subscriptions.filter((s) => userId === undefined || s.userId === userId).map((s) => ({ ...s }));
153
+ }
154
+ subscribe(userId, subscription, userAgent) {
155
+ const clean = validateSubscription(subscription);
156
+ const stored = { ...clean, userId, createdAt: Date.now() };
157
+ if (userAgent !== undefined && userAgent !== '') stored.userAgent = String(userAgent).slice(0, 300);
158
+ const others = this.data.subscriptions.filter((s) => s.endpoint !== clean.endpoint);
159
+ const theirs = others.filter((s) => s.userId === userId);
160
+ const dropped = new Set(theirs.slice(0, Math.max(0, theirs.length - (MAX_DEVICES - 1))));
161
+ this.data.subscriptions = [...others.filter((s) => !dropped.has(s)), stored];
162
+ this.save();
163
+ }
164
+ unsubscribe(endpoint, userId) {
165
+ const before = this.data.subscriptions.length;
166
+ this.data.subscriptions = this.data.subscriptions.filter(
167
+ (s) => s.endpoint !== endpoint || (userId !== undefined && s.userId === userId),
168
+ );
169
+ if (this.data.subscriptions.length === before) return false;
170
+ this.save();
171
+ return true;
172
+ }
173
+ prefs(userId) { return { ...DEFAULT_PREFS, ...this.data.prefs[userId] }; }
174
+ setPrefs(userId, patch) {
175
+ const stored = { ...this.data.prefs[userId] };
176
+ for (const name of Object.keys(DEFAULT_PREFS)) {
177
+ if (typeof patch[name] === 'boolean') stored[name] = patch[name];
178
+ }
179
+ this.data.prefs[userId] = stored;
180
+ this.save();
181
+ return this.prefs(userId);
182
+ }
183
+ /** Gửi tới mọi subscription của user; bỏ subscription chết (404/410). Không throw; trả số 2xx. */
184
+ async notify(userId, message, options = {}) {
185
+ const targets = this.subscriptions(userId);
186
+ if (targets.length === 0) return 0;
187
+ const vapid = this.vapid;
188
+ const results = await Promise.allSettled(targets.map((sub) => sendPush(sub, message, vapid, options)));
189
+ let accepted = 0;
190
+ const gone = new Set();
191
+ results.forEach((r, index) => {
192
+ if (r.status !== 'fulfilled') return;
193
+ if (r.value >= 200 && r.value < 300) accepted++;
194
+ else if (r.value === 404 || r.value === 410) gone.add(targets[index]?.endpoint ?? '');
195
+ });
196
+ if (gone.size > 0) {
197
+ try {
198
+ this.data.subscriptions = this.data.subscriptions.filter((s) => !(s.userId === userId && gone.has(s.endpoint)));
199
+ this.save();
200
+ } catch { /* lưu fail — thử lại lần tới */ }
201
+ }
202
+ return accepted;
203
+ }
204
+ save() {
205
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
206
+ const temp = `${this.file}.${process.pid}.tmp`;
207
+ writeFileSync(temp, `${JSON.stringify(this.data, null, '\t')}\n`, { mode: 0o600 });
208
+ renameSync(temp, this.file);
209
+ chmodSync(this.file, 0o600);
210
+ }
211
+ }
@@ -15,6 +15,7 @@ import { readFile } from 'node:fs/promises';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import process from 'node:process';
17
17
  import { COOKIE, makePocketAdapter } from './pocket-adapter.mjs';
18
+ import { PushStore } from './push.mjs';
18
19
 
19
20
  // UI pocket (clone từ TannerMidd/pi-pocket, MIT) nằm ở public/ — serve tĩnh
20
21
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -122,7 +123,8 @@ const authorized = async (req, url) => {
122
123
  };
123
124
 
124
125
  // ---- Events fan-out cho pocket adapter: một socket dài, events.update của daemon
125
- // báo mọi client SSE pocket làm mới (pull-on-push). ----
126
+ // báo mọi client SSE pocket làm mới (pull-on-push). Socket sống độc lập SSE client —
127
+ // push notification phải nổ khi KHÔNG ai mở web, nên tạo ngay từ boot + tự nối lại. ----
126
128
  const eventListeners = new Set();
127
129
  const watchedConvs = new Set();
128
130
  let eventsSocket = null;
@@ -145,18 +147,26 @@ async function ensureEventsSocket(cid) {
145
147
  const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
146
148
  try {
147
149
  const msg = JSON.parse(line);
148
- if (msg.method === 'events.update') for (const fn of eventListeners) fn(msg.params);
150
+ if (msg.method === 'events.update') {
151
+ for (const fn of eventListeners) fn(msg.params);
152
+ pushApprovalWatch(); // push "needs approval" — kể cả khi tab đóng
153
+ }
149
154
  } catch { /* dòng chưa trọn */ }
150
155
  }
151
156
  });
152
- conn.on('close', () => { eventsSocket = null; watchedConvs.clear(); });
153
- conn.on('error', () => { eventsSocket = null; });
157
+ conn.on('close', () => { eventsSocket = null; watchedConvs.clear(); scheduleEventsReconnect(); });
158
+ conn.on('error', () => { eventsSocket = null; scheduleEventsReconnect(); });
154
159
  conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: {} }) + '\n');
155
160
  if (Number.isInteger(cid) && cid > 0) {
156
161
  watchedConvs.add(cid);
157
162
  conn.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'events.subscribe', params: { conversationId: cid } }) + '\n');
158
163
  }
159
164
  }
165
+ let reconnectTimer = null;
166
+ function scheduleEventsReconnect() {
167
+ if (reconnectTimer) return;
168
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; ensureEventsSocket().catch(() => scheduleEventsReconnect()); }, 2000);
169
+ }
160
170
  rpc.__subscribeEvents = async (cid, onChange) => {
161
171
  await ensureEventsSocket(cid);
162
172
  eventListeners.add(onChange);
@@ -166,7 +176,51 @@ rpc.__subscribeEvents = async (cid, onChange) => {
166
176
  // ---- pocket wire (cookie pocket_auth — sign-in qua /login?token= sau khi pair) ----
167
177
  const cookieToken = (req) => (req.headers.cookie ?? '').match(new RegExp(`${COOKIE}=([^;]+)`))?.[1] ?? null;
168
178
  const pocketCookieOk = async (req) => { const t = cookieToken(req); return t && (TOKEN ? t === TOKEN || await verifyDeviceToken(t) : await verifyDeviceToken(t)); };
169
- const pocketRoute = makePocketAdapter({ rpc, checkToken: async (t) => (TOKEN ? t === TOKEN : false) || (await verifyDeviceToken(t)) });
179
+
180
+ // ---- Web Push (port pi-pocket: RFC 8291/8292/8030, không dependency — push.mjs) ----
181
+ // push.json ở dataDir daemon (~/.local/state/bom-pi) — VAPID keys ổn định qua restart,
182
+ // subscriptions + prefs mỗi user. pushApprovalWatch() theo dõi approval mới qua events.update
183
+ // và bắn notification "bom needs approval" tới mọi thiết bị đã subscribe (kể cả tab đóng). ----
184
+ const DATA_DIR = resolve(process.env.BOM_PI_STATE_DIR || process.env.XDG_STATE_HOME || join(process.env.HOME ?? '/home', '.local', 'state'), 'bom-pi');
185
+ let pushStore = null;
186
+ try { pushStore = new PushStore(DATA_DIR); } catch (e) { console.error(`push disabled: ${e.message}`); }
187
+ const PUSH_USER = 'bom-owner';
188
+ let knownApprovalIds = null; // null = chưa nạp mốc — lần đầu không bắn loạt approval cũ
189
+ let approvalWatchTimer = null;
190
+ function pushApprovalWatch() {
191
+ if (!pushStore || approvalWatchTimer) return;
192
+ approvalWatchTimer = setTimeout(() => {
193
+ approvalWatchTimer = null;
194
+ void (async () => {
195
+ try {
196
+ if (pushStore.subscriptions(PUSH_USER).length === 0) { knownApprovalIds = null; return; } // chưa ai subscribe — bỏ qua
197
+ const pending = (await rpc('approvals.list')).pending ?? [];
198
+ const ids = new Set(pending.map((a) => String(a.id)));
199
+ if (knownApprovalIds === null) { knownApprovalIds = ids; return; }
200
+ const fresh = pending.filter((a) => !knownApprovalIds.has(String(a.id)));
201
+ knownApprovalIds = ids;
202
+ if (fresh.length === 0 || !pushStore.prefs(PUSH_USER).approval) return;
203
+ for (const a of fresh) {
204
+ // Khuôn alerts.ts của pocket: Allow từ notification chỉ khi cả call ngắn + không ký tự ngụy trang
205
+ const subject = String(a.command ?? a.summary ?? '');
206
+ const call = `${a.tool ?? 'call'}: ${subject}`.slice(0, 120);
207
+ const shown = call.length <= 120 && !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(subject);
208
+ void pushStore.notify(PUSH_USER, {
209
+ title: 'bom needs approval',
210
+ body: call || 'A tool call is waiting for you.',
211
+ url: `/s/${Number(a.conversationId ?? 0)}`,
212
+ tag: `approval-${a.id}`,
213
+ approval: { id: String(a.id), allow: shown },
214
+ }, { urgency: 'high' })
215
+ .then((sent) => console.error(`push approval ${String(a.id).slice(0, 24)} → ${sent} thiết bị`))
216
+ .catch((e) => console.error(`push approval ${String(a.id).slice(0, 24)} lỗi: ${e.message}`));
217
+ }
218
+ } catch { /* daemon bận — events.update kế tiếp thử lại */ }
219
+ })();
220
+ }, 500);
221
+ }
222
+
223
+ const pocketRoute = makePocketAdapter({ rpc, checkToken: async (t) => (TOKEN ? t === TOKEN : false) || (await verifyDeviceToken(t)), push: pushStore ? { store: pushStore, userId: PUSH_USER } : null });
170
224
 
171
225
  // ---- HTML (mobile-first, không framework) ----
172
226
  const HTML = `<!doctype html>
@@ -593,18 +647,6 @@ async function go(){
593
647
  }
594
648
  </script></body></html>`;
595
649
 
596
- // ---- PWA (M8): install màn hình chính + notification approvals ----
597
- const MANIFEST = {
598
- name: 'bom', short_name: 'bom', start_url: '/', display: 'standalone',
599
- background_color: '#101418', theme_color: '#101418',
600
- icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
601
- };
602
- const ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#101418"/><text x="50" y="68" font-size="56" text-anchor="middle" fill="#58a6ff" font-family="monospace" font-weight="bold">b</text></svg>`;
603
- const SW_JS = `// bom service worker — network-first, không cache (app luôn tươi; SW chỉ để installable PWA)
604
- self.addEventListener('install', () => self.skipWaiting());
605
- self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
606
- self.addEventListener('fetch', () => {});`;
607
-
608
650
  const http = createHttpServer(async (req, res) => {
609
651
  const url = new URL(req.url, 'http://x');
610
652
  if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
@@ -626,19 +668,9 @@ const http = createHttpServer(async (req, res) => {
626
668
  try { return json2(res, 200, await rpc('pair.poll', { enrollId: p.enrollId })); }
627
669
  catch (e) { return json2(res, 404, { error: e.message.replace(/^\d+: /, '') }); }
628
670
  }
629
- if (req.method === 'GET' && url.pathname === '/manifest.webmanifest') {
630
- res.writeHead(200, { 'content-type': 'application/manifest+json', ...CORS });
631
- return res.end(JSON.stringify(MANIFEST));
632
- }
633
- if (req.method === 'GET' && url.pathname === '/icon.svg') {
634
- res.writeHead(200, { 'content-type': 'image/svg+xml', ...CORS });
635
- return res.end(ICON_SVG);
636
- }
637
- if (req.method === 'GET' && url.pathname === '/sw.js') {
638
- res.writeHead(200, { 'content-type': 'application/javascript', ...CORS });
639
- return res.end(SW_JS);
640
- }
641
671
  // ---- UI pocket (public/): shell + module + vendor tĩnh, KHÔNG cần token ----
672
+ // serveStatic phục vụ cả sw.js/manifest/icon của pocket (public/) — không còn hằng
673
+ // SW cũ: sw.js pocket có push handler (notification từ push service).
642
674
  if (req.method === 'GET' && await serveStatic(res, url)) return;
643
675
  // ---- /login?token= : sign-in pocket UI bằng device token (sau pair) hoặc master ----
644
676
  if (req.method === 'GET' && url.pathname === '/login') {
@@ -770,5 +802,7 @@ const http = createHttpServer(async (req, res) => {
770
802
  });
771
803
 
772
804
  http.listen(PORT, LISTEN, () => {
773
- console.error(`bom-web listening http://${LISTEN}:${PORT} — bom-core at ${socketPath}${TOKEN ? ' — token required' : ' — NO TOKEN (loopback only)'}`);
805
+ console.error(`bom-web listening http://${LISTEN}:${PORT} — bom-core at ${socketPath}${TOKEN ? ' — token required' : ' — NO TOKEN (loopback only)'}${pushStore ? ` — push ${pushStore.publicKey.slice(0, 12)}…` : ' — push OFF'}`);
806
+ // Socket events sống từ boot: push approval phải nổ khi không ai mở web
807
+ ensureEventsSocket().catch(() => scheduleEventsReconnect());
774
808
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "bom × pi — hệ sinh thái durable cho pi trên nền pi-durable: core daemon + web UI pocket + CLI",
6
6
  "engines": {
@@ -17,6 +17,7 @@
17
17
  "packages/cli/templates",
18
18
  "apps/web/server.mjs",
19
19
  "apps/web/pocket-adapter.mjs",
20
+ "apps/web/push.mjs",
20
21
  "apps/web/public",
21
22
  "systemd",
22
23
  "README.md"
@@ -27,6 +27,25 @@ const cid = arg('cid', null); // conversation id cho submit/transcript/reset/wat
27
27
  const cidParams = () => (cid ? { conversationId: Number(cid) } : {});
28
28
  const cmd = process.argv[2] ?? 'status';
29
29
 
30
+ // Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
31
+ // thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
32
+ function unreachable(e) {
33
+ const head = `cannot reach bom-core at ${socketPath}: ${e.message}`;
34
+ if (process.platform === 'win32') {
35
+ return `${head}
36
+
37
+ bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
38
+ Từ thiết bị khác, dùng WEB thay CLI: mở http://<ip-máy-chạy-daemon>:8791 (hoặc URL tunnel
39
+ https://… nếu có) rồi pair bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.`;
40
+ }
41
+ return `${head}
42
+
43
+ daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
44
+ chưa cài daemon?: bom-pi setup
45
+ ở THIẾT BỊ KHÁC?: CLI nói chuyện daemon trên MÁY NÀY — từ phone/laptop hãy mở
46
+ http://<ip-máy-chạy-daemon>:8791 và pair bằng code từ 'bom-pi pair'.`;
47
+ }
48
+
30
49
  function rpc(method, params) {
31
50
  return new Promise((done, fail) => {
32
51
  const conn = connect(socketPath);
@@ -57,7 +76,7 @@ function rpc(method, params) {
57
76
  });
58
77
  // Daemon chết giữa chừng / đóng connection không trả lời
59
78
  conn.on('close', () => settle(fail, new Error('bom-core closed the connection without a reply')));
60
- conn.on('error', (e) => settle(fail, new Error(`cannot reach bom-core at ${socketPath}: ${e.message}`)));
79
+ conn.on('error', (e) => settle(fail, new Error(unreachable(e))));
61
80
  });
62
81
  }
63
82
 
@@ -99,7 +118,7 @@ function watch() {
99
118
  }
100
119
  }
101
120
  });
102
- conn.on('error', (e) => { console.error(`cannot reach bom-core at ${socketPath}: ${e.message}`); process.exit(1); });
121
+ conn.on('error', (e) => { console.error(unreachable(e)); process.exit(1); });
103
122
  conn.on('close', () => { if (!gotReply) { console.error('bom-core closed the connection without a reply'); process.exit(1); } process.exit(0); });
104
123
  }
105
124
  const printEntry = (e) => console.log(`[${e.kind}]${e.role ? ` ${e.role}:` : ''} ${e.text ?? ''}`);
@@ -71,6 +71,12 @@ async function shutdown(reason) {
71
71
  if (closing) return;
72
72
  closing = true;
73
73
  log('đóng:', reason);
74
+ // Turn đang chờ approval không bao giờ settle ⇒ Session.close kẹt mãi.
75
+ // Từ chối hết mọi approval treo để turn kết thúc, rồi mới đóng storage.
76
+ for (const [, p] of pendingApprovals) {
77
+ try { p.resolve({ allow: false, reason: 'daemon shutdown' }); } catch { /* resolve rồi */ }
78
+ }
79
+ pendingApprovals.clear();
74
80
  let failed = false;
75
81
  if (heartbeatTimer) clearInterval(heartbeatTimer);
76
82
  if (scheduleTimer) clearInterval(scheduleTimer);