zyro-gateway 1.0.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.
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const { getRoom, listDevices, buildDashboardPayload } = require('./rooms');
6
+ const { resolvePairing } = require('../config/pairing');
7
+ const { touchHttpPhone } = require('./devices');
8
+
9
+ function registerRoutes(app, ctx) {
10
+ const {
11
+ zyroConfig,
12
+ configPath,
13
+ port,
14
+ configPairing,
15
+ publicIp,
16
+ io,
17
+ broadcaster,
18
+ } = ctx;
19
+
20
+ function serverConfigPayload() {
21
+ return {
22
+ ip: publicIp(),
23
+ port,
24
+ pairingCode: configPairing || null,
25
+ configFile: path.basename(configPath),
26
+ configPath,
27
+ configLoaded: zyroConfig.loaded,
28
+ configureInZyroConfigJs: ['ip', 'port', 'pairingCode', 'deviceName'],
29
+ };
30
+ }
31
+
32
+ function serverPublicInfo() {
33
+ const ip = publicIp();
34
+ return {
35
+ name: 'Zyro Gateway',
36
+ mode: 'terminal',
37
+ ...serverConfigPayload(),
38
+ httpUrl: `http://${ip}:${port}`,
39
+ wsUrl: `ws://${ip}:${port}`,
40
+ hostname: os.hostname(),
41
+ pairingCode: configPairing || null,
42
+ pairingNote:
43
+ 'Edit ip, port, pairingCode in zyro.config.js then restart the gateway.',
44
+ };
45
+ }
46
+
47
+ function requirePairing(req, res) {
48
+ const pairing = resolvePairing(req, configPairing);
49
+ if (!pairing) {
50
+ res.status(400).json({
51
+ ok: false,
52
+ error: 'pairing required — set pairingCode in zyro.config.js',
53
+ });
54
+ return null;
55
+ }
56
+ return pairing;
57
+ }
58
+
59
+ app.get('/', (_req, res) => {
60
+ res.json({
61
+ ...serverPublicInfo(),
62
+ endpoints: [
63
+ '/api/config',
64
+ '/api/info',
65
+ '/api/register',
66
+ '/api/transactions',
67
+ '/api/notifications',
68
+ '/api/dashboard',
69
+ '/api/devices',
70
+ ],
71
+ clientScript: '/zyro/zyro.js',
72
+ });
73
+ });
74
+
75
+ app.get('/api/config', (_req, res) => {
76
+ res.json(serverConfigPayload());
77
+ });
78
+
79
+ app.get('/api/info', (_req, res) => {
80
+ res.json({
81
+ ...serverPublicInfo(),
82
+ features: ['transactions', 'notifications', 'dashboard', 'devices'],
83
+ reachable: true,
84
+ });
85
+ });
86
+
87
+ app.get('/api/dashboard', (req, res) => {
88
+ const pairing = requirePairing(req, res);
89
+ if (!pairing) return;
90
+ const room = getRoom(pairing);
91
+ res.json(buildDashboardPayload(room, pairing));
92
+ });
93
+
94
+ app.get('/api/devices', (req, res) => {
95
+ const pairing = requirePairing(req, res);
96
+ if (!pairing) return;
97
+ const room = getRoom(pairing);
98
+ res.json({
99
+ pairingCode: pairing,
100
+ devices: listDevices(room),
101
+ serverTime: new Date().toISOString(),
102
+ });
103
+ });
104
+
105
+ app.get('/api/transactions', (req, res) => {
106
+ const pairing = requirePairing(req, res);
107
+ if (!pairing) return;
108
+ const room = getRoom(pairing);
109
+ const after = String(req.query.after || '').trim();
110
+ let list = room.transactions;
111
+ if (after) {
112
+ list = list.filter((tx) => {
113
+ const t = String(tx.receivedAt || tx.timestamp || '');
114
+ return t > after;
115
+ });
116
+ } else {
117
+ list = list.slice(0, 50);
118
+ }
119
+ res.json({
120
+ pairingCode: pairing,
121
+ transactions: list,
122
+ serverTime: new Date().toISOString(),
123
+ });
124
+ });
125
+
126
+ app.get('/api/notifications', (req, res) => {
127
+ const pairing = requirePairing(req, res);
128
+ if (!pairing) return;
129
+ const room = getRoom(pairing);
130
+ const after = String(req.query.after || '').trim();
131
+ let list = room.notifications;
132
+ if (after) {
133
+ list = list.filter(
134
+ (n) => String(n.receivedAt || n.timestamp || '') > after,
135
+ );
136
+ } else {
137
+ list = list.slice(0, 50);
138
+ }
139
+ res.json({
140
+ pairingCode: pairing,
141
+ notifications: list,
142
+ serverTime: new Date().toISOString(),
143
+ });
144
+ });
145
+
146
+ app.post('/api/register', (req, res) => {
147
+ const pairing = requirePairing(req, res);
148
+ if (!pairing) return;
149
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
150
+ const device = touchHttpPhone(io, pairing, {
151
+ deviceName: body.deviceName || body.name,
152
+ platform: body.platform,
153
+ deviceId: body.deviceId,
154
+ });
155
+ res.json({ ok: true, pairing, device });
156
+ });
157
+
158
+ app.post('/api/income', (req, res) => {
159
+ const pairing = requirePairing(req, res);
160
+ if (!pairing) return;
161
+ const payload = req.body;
162
+ if (!payload || typeof payload !== 'object') {
163
+ return res.status(400).json({ ok: false, error: 'Invalid body' });
164
+ }
165
+ broadcaster.broadcastIncome(pairing, payload);
166
+ broadcaster.bumpPhonePresence(pairing, {
167
+ deviceName: payload.deviceName || payload.sender,
168
+ platform: 'android',
169
+ });
170
+ res.json({ ok: true, pairing });
171
+ });
172
+
173
+ app.post('/api/notification', (req, res) => {
174
+ const pairing = requirePairing(req, res);
175
+ if (!pairing) return;
176
+ const payload = req.body;
177
+ if (!payload || typeof payload !== 'object') {
178
+ return res.status(400).json({ ok: false, error: 'Invalid body' });
179
+ }
180
+ broadcaster.broadcastNotification(pairing, payload);
181
+ broadcaster.bumpPhonePresence(pairing, {
182
+ deviceName: payload.title || payload.appName,
183
+ platform: 'android',
184
+ });
185
+ res.json({ ok: true, pairing });
186
+ });
187
+
188
+ ctx.serverPublicInfo = serverPublicInfo;
189
+ }
190
+
191
+ module.exports = { registerRoutes };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ getRoom,
5
+ presencePayload,
6
+ buildDashboardPayload,
7
+ listDevices,
8
+ } = require('./rooms');
9
+ const { normalizePairing } = require('../config/pairing');
10
+ const { upsertDevice } = require('./devices');
11
+ const { printConnectedDevices } = require('./terminal');
12
+
13
+ function attachSocketHandlers(io, ctx) {
14
+ const { broadcaster } = ctx;
15
+
16
+ io.on('connection', (socket) => {
17
+ const pairing = normalizePairing(socket.handshake.query.pairing);
18
+ const role = String(socket.handshake.query.role || 'phone').toLowerCase();
19
+ const deviceName = String(socket.handshake.query.deviceName || '').trim();
20
+
21
+ if (!pairing) {
22
+ socket.emit('sync_error', {
23
+ message: 'Pairing code is required (4–12 letters/numbers)',
24
+ });
25
+ socket.disconnect(true);
26
+ return;
27
+ }
28
+
29
+ const roomKey = pairing;
30
+ const room = getRoom(roomKey);
31
+ const roomName = `pairing:${roomKey}`;
32
+ socket.join(roomName);
33
+
34
+ if (role === 'desktop') {
35
+ room.desktops += 1;
36
+ } else {
37
+ room.phones += 1;
38
+ }
39
+
40
+ socket.data.pairing = roomKey;
41
+ socket.data.role = role;
42
+ socket.data.roomKey = roomKey;
43
+
44
+ const device = upsertDevice(room, socket, {
45
+ deviceName: deviceName || undefined,
46
+ platform: role === 'desktop' ? 'web' : 'android',
47
+ });
48
+
49
+ socket.emit('sync_ready', {
50
+ pairing: roomKey,
51
+ role,
52
+ serverTime: new Date().toISOString(),
53
+ });
54
+ socket.emit('history', room.transactions.slice(0, 50));
55
+ socket.emit('notification_history', room.notifications.slice(0, 50));
56
+ socket.emit('dashboard_update', buildDashboardPayload(room, roomKey));
57
+ io.to(roomName).emit('presence', presencePayload(room));
58
+ io.to(roomName).emit('device_joined', device);
59
+ printConnectedDevices(roomKey, listDevices(room));
60
+
61
+ socket.on('register', (payload) => {
62
+ if (!payload || typeof payload !== 'object') return;
63
+ const updated = upsertDevice(room, socket, payload);
64
+ updated.lastSeen = new Date().toISOString();
65
+ room.deviceMap.set(socket.id, updated);
66
+ io.to(roomName).emit('presence', presencePayload(room));
67
+ io.to(roomName).emit('device_updated', updated);
68
+ printConnectedDevices(roomKey, listDevices(room));
69
+ });
70
+
71
+ socket.on('income_transaction', (payload) => {
72
+ if (!payload || typeof payload !== 'object') return;
73
+ broadcaster.broadcastIncome(roomKey, payload);
74
+ const dev = room.deviceMap.get(socket.id);
75
+ if (dev) {
76
+ dev.lastSeen = new Date().toISOString();
77
+ room.deviceMap.set(socket.id, dev);
78
+ }
79
+ });
80
+
81
+ socket.on('notification_event', (payload) => {
82
+ if (!payload || typeof payload !== 'object') return;
83
+ broadcaster.broadcastNotification(roomKey, payload);
84
+ const dev = room.deviceMap.get(socket.id);
85
+ if (dev) {
86
+ dev.lastSeen = new Date().toISOString();
87
+ room.deviceMap.set(socket.id, dev);
88
+ }
89
+ });
90
+
91
+ socket.on('disconnect', () => {
92
+ if (socket.data.role === 'desktop') {
93
+ room.desktops = Math.max(0, room.desktops - 1);
94
+ } else {
95
+ room.phones = Math.max(0, room.phones - 1);
96
+ }
97
+ const removed = room.deviceMap.get(socket.id);
98
+ room.deviceMap.delete(socket.id);
99
+ io.to(roomName).emit('presence', presencePayload(room));
100
+ if (removed) {
101
+ io.to(roomName).emit('device_left', {
102
+ id: removed.id,
103
+ role: removed.role,
104
+ });
105
+ }
106
+ io.to(roomName).emit(
107
+ 'dashboard_update',
108
+ buildDashboardPayload(room, roomKey),
109
+ );
110
+ printConnectedDevices(roomKey, listDevices(room));
111
+ });
112
+ });
113
+ }
114
+
115
+ module.exports = { attachSocketHandlers };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ const { incomeDisplayFields } = require('../utils/format');
4
+
5
+ const _connectedPrintKey = new Map();
6
+
7
+ function printStartup({ configPath, configLoaded, pairing, ip, port }) {
8
+ console.log('');
9
+ console.log(' Zyro Gateway');
10
+ console.log(' ───────────────────────');
11
+ console.log(
12
+ ` Config ${configPath}${configLoaded ? '' : ' (missing — run: npx zyro-gateway config)'}`,
13
+ );
14
+ console.log(` Pairing ${pairing || '— set pairingCode in zyro.config.js'}`);
15
+ console.log(` App IP ${ip} port ${port}`);
16
+ console.log('');
17
+ console.log(' Connected: (waiting…)');
18
+ console.log(' Income: name · amount · sender · ref');
19
+ console.log('');
20
+ }
21
+
22
+ function printConnectedDevices(roomKey, devices) {
23
+ const fingerprint =
24
+ devices.map((d) => `${d.role}:${d.deviceName}`).join('|') || '(none)';
25
+ if (_connectedPrintKey.get(roomKey) === fingerprint) return;
26
+ _connectedPrintKey.set(roomKey, fingerprint);
27
+
28
+ console.log(` Connected [${roomKey}]:`);
29
+ if (devices.length === 0) {
30
+ console.log(' (none)');
31
+ } else {
32
+ for (const d of devices) {
33
+ const icon = d.role === 'phone' ? '📱' : '🖥️';
34
+ const via = d.via === 'http' ? ' · HTTP' : '';
35
+ console.log(` ${icon} ${d.deviceName}${via}`);
36
+ }
37
+ }
38
+ console.log('');
39
+ }
40
+
41
+ function printIncome(roomKey, payload) {
42
+ const { name, amount, sender, txn } = incomeDisplayFields(payload);
43
+ console.log(` Income [${roomKey}]`);
44
+ console.log(` ${amount} · ${name}`);
45
+ console.log(` Sender ${sender} · Ref ${txn}`);
46
+ console.log('');
47
+ }
48
+
49
+ module.exports = { printStartup, printConnectedDevices, printIncome };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ function formatEtb(amount) {
4
+ const n = Number(amount);
5
+ if (!Number.isFinite(n) || n <= 0) return '— ETB';
6
+ return `${n.toLocaleString('en-US', {
7
+ minimumFractionDigits: 2,
8
+ maximumFractionDigits: 2,
9
+ })} ETB`;
10
+ }
11
+
12
+ function incomeDisplayFields(payload) {
13
+ const p = payload && typeof payload === 'object' ? payload : {};
14
+ const name =
15
+ String(p.name || p.payerName || p.sender || 'Unknown').trim() || 'Unknown';
16
+ const amount = formatEtb(p.amount);
17
+ const sender =
18
+ String(p.smsAddress || p.accountSource || p.sender || '—').trim() || '—';
19
+ const txn =
20
+ String(
21
+ p.transactionNumber || p.referenceNumber || p.transactionId || '—',
22
+ ).trim() || '—';
23
+ return { name, amount, sender, txn };
24
+ }
25
+
26
+ module.exports = { formatEtb, incomeDisplayFields };
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ const os = require('os');
4
+
5
+ function getLocalIp() {
6
+ const nets = os.networkInterfaces();
7
+ for (const name of Object.keys(nets)) {
8
+ for (const net of nets[name] || []) {
9
+ if (net.family === 'IPv4' && !net.internal) {
10
+ return net.address;
11
+ }
12
+ }
13
+ }
14
+ return '127.0.0.1';
15
+ }
16
+
17
+ module.exports = { getLocalIp };
@@ -0,0 +1 @@
1
+ export { DEFAULTS, EVENTS, ZyroConnection, connect } from './zyro.js';