@gamalan/pi-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.
Files changed (75) hide show
  1. package/README.md +366 -0
  2. package/config/config.default.json +51 -0
  3. package/dist/adapters/base.d.ts +85 -0
  4. package/dist/adapters/base.js +34 -0
  5. package/dist/adapters/base.js.map +1 -0
  6. package/dist/adapters/discord.d.ts +53 -0
  7. package/dist/adapters/discord.js +224 -0
  8. package/dist/adapters/discord.js.map +1 -0
  9. package/dist/adapters/index.d.ts +13 -0
  10. package/dist/adapters/index.js +8 -0
  11. package/dist/adapters/index.js.map +1 -0
  12. package/dist/adapters/slack.d.ts +49 -0
  13. package/dist/adapters/slack.js +231 -0
  14. package/dist/adapters/slack.js.map +1 -0
  15. package/dist/adapters/telegram.d.ts +64 -0
  16. package/dist/adapters/telegram.js +274 -0
  17. package/dist/adapters/telegram.js.map +1 -0
  18. package/dist/adapters/twitch.d.ts +75 -0
  19. package/dist/adapters/twitch.js +222 -0
  20. package/dist/adapters/twitch.js.map +1 -0
  21. package/dist/adapters/websocket.d.ts +30 -0
  22. package/dist/adapters/websocket.js +132 -0
  23. package/dist/adapters/websocket.js.map +1 -0
  24. package/dist/adapters/whatsapp.d.ts +49 -0
  25. package/dist/adapters/whatsapp.js +251 -0
  26. package/dist/adapters/whatsapp.js.map +1 -0
  27. package/dist/background/index.d.ts +1 -0
  28. package/dist/background/index.js +2 -0
  29. package/dist/background/index.js.map +1 -0
  30. package/dist/background/manager.d.ts +70 -0
  31. package/dist/background/manager.js +291 -0
  32. package/dist/background/manager.js.map +1 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.js +1275 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/logger.d.ts +12 -0
  37. package/dist/logger.js +39 -0
  38. package/dist/logger.js.map +1 -0
  39. package/dist/paths.d.ts +14 -0
  40. package/dist/paths.js +32 -0
  41. package/dist/paths.js.map +1 -0
  42. package/dist/security/auth.d.ts +91 -0
  43. package/dist/security/auth.js +316 -0
  44. package/dist/security/auth.js.map +1 -0
  45. package/dist/security/index.d.ts +1 -0
  46. package/dist/security/index.js +2 -0
  47. package/dist/security/index.js.map +1 -0
  48. package/dist/security/tool-policy.d.ts +66 -0
  49. package/dist/security/tool-policy.js +467 -0
  50. package/dist/security/tool-policy.js.map +1 -0
  51. package/dist/sessions/index.d.ts +1 -0
  52. package/dist/sessions/index.js +2 -0
  53. package/dist/sessions/index.js.map +1 -0
  54. package/dist/sessions/store.d.ts +64 -0
  55. package/dist/sessions/store.js +247 -0
  56. package/dist/sessions/store.js.map +1 -0
  57. package/package.json +57 -0
  58. package/src/adapters/base.ts +124 -0
  59. package/src/adapters/discord.ts +270 -0
  60. package/src/adapters/index.ts +13 -0
  61. package/src/adapters/slack.ts +313 -0
  62. package/src/adapters/telegram.ts +394 -0
  63. package/src/adapters/twitch.ts +316 -0
  64. package/src/adapters/websocket.ts +158 -0
  65. package/src/adapters/whatsapp.ts +296 -0
  66. package/src/background/index.ts +1 -0
  67. package/src/background/manager.ts +395 -0
  68. package/src/index.ts +1665 -0
  69. package/src/logger.ts +43 -0
  70. package/src/paths.ts +40 -0
  71. package/src/security/auth.ts +458 -0
  72. package/src/security/index.ts +1 -0
  73. package/src/security/tool-policy.ts +568 -0
  74. package/src/sessions/index.ts +1 -0
  75. package/src/sessions/store.ts +360 -0
package/src/logger.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Gateway Logger - writes all logs to a file instead of console
3
+ *
4
+ * All gateway log output goes to ~/.pi/gateway/gateway.log so that
5
+ * console.log/console.error do not escape pi's TUI and break the layout.
6
+ */
7
+
8
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+
12
+ const GATEWAY_DIR = join(homedir(), ".pi", "gateway");
13
+ const LOG_FILE = join(GATEWAY_DIR, "gateway.log");
14
+
15
+ function ensureLogDir(): void {
16
+ if (!existsSync(GATEWAY_DIR)) {
17
+ mkdirSync(GATEWAY_DIR, { recursive: true });
18
+ }
19
+ }
20
+
21
+ function formatTimestamp(): string {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function writeLog(level: string, ...args: unknown[]): void {
26
+ ensureLogDir();
27
+ const message = args
28
+ .map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
29
+ .join(" ");
30
+ const line = `[${formatTimestamp()}] [${level}] ${message}\n`;
31
+ try {
32
+ appendFileSync(LOG_FILE, line, "utf-8");
33
+ } catch {
34
+ // Fail silently — we must not break the TUI
35
+ }
36
+ }
37
+
38
+ export const logger = {
39
+ info: (...args: unknown[]) => writeLog("INFO", ...args),
40
+ warn: (...args: unknown[]) => writeLog("WARN", ...args),
41
+ error: (...args: unknown[]) => writeLog("ERROR", ...args),
42
+ debug: (...args: unknown[]) => writeLog("DEBUG", ...args),
43
+ };
package/src/paths.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Path utilities — resolve the pi-gateway package root and share
3
+ * config file locations so every module reads the same files.
4
+ */
5
+
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+ import { existsSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+
11
+ // ── Runtime config paths (user's home directory) ─────────────────
12
+
13
+ /** Single source of truth: ~/.pi/gateway/config.json */
14
+ export const GATEWAY_CONFIG_DIR = join(homedir(), ".pi", "gateway");
15
+ export const GATEWAY_CONFIG_FILE = join(GATEWAY_CONFIG_DIR, "config.json");
16
+
17
+ // ── Package root resolution ──────────────────────────────────────
18
+
19
+ /**
20
+ * Walk up from the calling module's location until we find a
21
+ * package.json or config/ directory, then return that directory.
22
+ *
23
+ * Call with `import.meta.url` from the file that needs the root.
24
+ */
25
+ export function getPackageRoot(importMetaUrl: string): string {
26
+ let dir = dirname(fileURLToPath(importMetaUrl));
27
+
28
+ for (let i = 0; i < 6; i++) {
29
+ if (
30
+ existsSync(join(dir, "package.json")) ||
31
+ existsSync(join(dir, "config"))
32
+ ) {
33
+ return dir;
34
+ }
35
+ dir = dirname(dir);
36
+ }
37
+
38
+ // Last-resort fallback
39
+ return dirname(fileURLToPath(importMetaUrl));
40
+ }
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Security Layer - Hermes-style allowlists and DM pairing
3
+ *
4
+ * Features:
5
+ * - Per-platform user allowlists
6
+ * - DM pairing flow with one-time codes
7
+ * - Rate limiting
8
+ * - Token authentication for gateway access
9
+ */
10
+
11
+ import Database from "better-sqlite3";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+ import { existsSync, mkdirSync, readFileSync } from "fs";
15
+ import { randomBytes } from "node:crypto";
16
+ import { logger } from "../logger.js";
17
+ import { GATEWAY_CONFIG_FILE } from "../paths.js";
18
+
19
+ export type Platform =
20
+ | "discord"
21
+ | "telegram"
22
+ | "slack"
23
+ | "whatsapp"
24
+ | "signal"
25
+ | "sms"
26
+ | "email"
27
+ | "matrix"
28
+ | "web"
29
+ | "websocket";
30
+
31
+ interface AllowlistEntry {
32
+ platform: Platform;
33
+ userId: string;
34
+ addedAt: number;
35
+ note?: string;
36
+ }
37
+
38
+ interface AdminEntry {
39
+ platform: Platform | "*";
40
+ userId: string;
41
+ addedAt: number;
42
+ note?: string;
43
+ }
44
+
45
+ interface PairingCode {
46
+ code: string;
47
+ platform: Platform;
48
+ userId: string;
49
+ createdAt: number;
50
+ expiresAt: number;
51
+ used: boolean;
52
+ }
53
+
54
+ interface RateLimitEntry {
55
+ identifier: string;
56
+ count: number;
57
+ windowStart: number;
58
+ }
59
+
60
+ const GATEWAY_DIR = join(homedir(), ".pi");
61
+ const SECURITY_DB = join(GATEWAY_DIR, "gateway-security.db");
62
+
63
+ let db: Database.Database | null = null;
64
+
65
+ /**
66
+ * Initialize security database
67
+ */
68
+ export function initSecurityStore(): Database.Database {
69
+ if (db) return db;
70
+
71
+ if (!existsSync(GATEWAY_DIR)) {
72
+ mkdirSync(GATEWAY_DIR, { recursive: true });
73
+ }
74
+
75
+ db = new Database(SECURITY_DB);
76
+ db.exec("PRAGMA journal_mode = WAL;");
77
+
78
+ // Allowlist table
79
+ db.exec(`
80
+ CREATE TABLE IF NOT EXISTS allowlist (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ platform TEXT NOT NULL,
83
+ user_id TEXT NOT NULL,
84
+ added_at INTEGER NOT NULL,
85
+ note TEXT
86
+ )
87
+ `);
88
+ db.exec(
89
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_allowlist ON allowlist(platform, user_id)`,
90
+ );
91
+
92
+ // Pairing codes table
93
+ db.exec(`
94
+ CREATE TABLE IF NOT EXISTS pairing_codes (
95
+ code TEXT PRIMARY KEY,
96
+ platform TEXT NOT NULL,
97
+ user_id TEXT NOT NULL,
98
+ created_at INTEGER NOT NULL,
99
+ expires_at INTEGER NOT NULL,
100
+ used INTEGER NOT NULL DEFAULT 0
101
+ )
102
+ `);
103
+ db.exec(
104
+ `CREATE INDEX IF NOT EXISTS idx_pairing_expires ON pairing_codes(expires_at)`,
105
+ );
106
+
107
+ // Rate limiting table
108
+ db.exec(`
109
+ CREATE TABLE IF NOT EXISTS rate_limits (
110
+ identifier TEXT PRIMARY KEY,
111
+ count INTEGER NOT NULL DEFAULT 1,
112
+ window_start INTEGER NOT NULL
113
+ )
114
+ `);
115
+
116
+ // Admin table
117
+ db.exec(`
118
+ CREATE TABLE IF NOT EXISTS admins (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ platform TEXT NOT NULL DEFAULT '*',
121
+ user_id TEXT NOT NULL,
122
+ added_at INTEGER NOT NULL,
123
+ note TEXT
124
+ )
125
+ `);
126
+ db.exec(
127
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_admins ON admins(platform, user_id)`,
128
+ );
129
+
130
+ logger.info("[Security] Database initialized");
131
+ return db;
132
+ }
133
+
134
+ /**
135
+ * Generate pairing code
136
+ */
137
+ export function generatePairingCode(
138
+ platform: Platform,
139
+ userId: string,
140
+ ): string {
141
+ const database = initSecurityStore();
142
+
143
+ // Generate 8-character alphanumeric code
144
+ const code = randomBytes(6).toString("base64url").slice(0, 8).toUpperCase();
145
+ const now = Date.now();
146
+ const expiresAt = now + 60 * 60 * 1000; // 1 hour
147
+
148
+ database
149
+ .prepare(
150
+ `
151
+ INSERT INTO pairing_codes (code, platform, user_id, created_at, expires_at, used)
152
+ VALUES (?, ?, ?, ?, ?, 0)
153
+ `,
154
+ )
155
+ .run(code, platform, userId, now, expiresAt);
156
+
157
+ logger.info(
158
+ `[Security] Generated pairing code ${code} for ${platform}/${userId}`,
159
+ );
160
+ return code;
161
+ }
162
+
163
+ /**
164
+ * Approve pairing code
165
+ */
166
+ export function approvePairingCode(code: string): boolean {
167
+ const database = initSecurityStore();
168
+
169
+ const entry = database
170
+ .prepare(`
171
+ SELECT * FROM pairing_codes WHERE code = ? AND used = 0 AND expires_at > ?
172
+ `)
173
+ .get(code, Date.now()) as PairingCode | undefined;
174
+
175
+ if (!entry) {
176
+ logger.info(`[Security] Pairing code ${code} not found or expired`);
177
+ return false;
178
+ }
179
+
180
+ // Add to allowlist
181
+ database
182
+ .prepare(
183
+ `
184
+ INSERT OR IGNORE INTO allowlist (platform, user_id, added_at)
185
+ VALUES (?, ?, ?)
186
+ `,
187
+ )
188
+ .run(entry.platform, entry.userId, Date.now());
189
+
190
+ // Mark code as used
191
+ database
192
+ .prepare("UPDATE pairing_codes SET used = 1 WHERE code = ?")
193
+ .run(code);
194
+
195
+ logger.info(`[Security] Approved pairing: ${entry.platform}/${entry.userId}`);
196
+ return true;
197
+ }
198
+
199
+ /**
200
+ * List pending pairing codes
201
+ */
202
+ export function listPendingPairingCodes(): Array<{
203
+ code: string;
204
+ platform: Platform;
205
+ userId: string;
206
+ createdAt: number;
207
+ expiresIn: number;
208
+ }> {
209
+ const database = initSecurityStore();
210
+ const now = Date.now();
211
+
212
+ const rows = database
213
+ .prepare(`
214
+ SELECT * FROM pairing_codes WHERE used = 0 AND expires_at > ?
215
+ ORDER BY created_at ASC
216
+ `)
217
+ .all(now) as PairingCode[];
218
+
219
+ return rows.map((row) => ({
220
+ code: row.code,
221
+ platform: row.platform,
222
+ userId: row.userId,
223
+ createdAt: row.createdAt,
224
+ expiresIn: Math.max(0, row.expiresAt - now),
225
+ }));
226
+ }
227
+
228
+ /**
229
+ * Revoke user access
230
+ */
231
+ export function revokeUserAccess(platform: Platform, userId: string): boolean {
232
+ const database = initSecurityStore();
233
+ const result = database
234
+ .prepare("DELETE FROM allowlist WHERE platform = ? AND user_id = ?")
235
+ .run(platform, userId);
236
+ return result.changes > 0;
237
+ }
238
+
239
+ /**
240
+ * Check if user is allowed
241
+ */
242
+ export function isUserAllowed(platform: Platform, userId: string): boolean {
243
+ const database = initSecurityStore();
244
+
245
+ // Check global allow all first
246
+ const config = getSecurityConfig();
247
+ if (config.allowAll) return true;
248
+
249
+ // Check config-based allowedUids (cross-platform wildcard or platform-specific)
250
+ if (config.allowedUids) {
251
+ const platformUids = config.allowedUids[platform];
252
+ if (platformUids?.includes(userId)) return true;
253
+ const wildcardUids = config.allowedUids["*"];
254
+ if (wildcardUids?.includes(userId)) return true;
255
+ }
256
+
257
+ // Check specific allowlist
258
+ const entry = database
259
+ .prepare(`
260
+ SELECT 1 FROM allowlist WHERE platform = ? AND user_id = ?
261
+ `)
262
+ .get(platform, userId);
263
+
264
+ return !!entry;
265
+ }
266
+
267
+ /**
268
+ * Add user to allowlist
269
+ */
270
+ export function addToAllowlist(
271
+ platform: Platform,
272
+ userId: string,
273
+ note?: string,
274
+ ): void {
275
+ const database = initSecurityStore();
276
+ database
277
+ .prepare(
278
+ `
279
+ INSERT OR REPLACE INTO allowlist (platform, user_id, added_at, note)
280
+ VALUES (?, ?, ?, ?)
281
+ `,
282
+ )
283
+ .run(platform, userId, Date.now(), note ?? null);
284
+ }
285
+
286
+ /**
287
+ * List allowlisted users
288
+ */
289
+ export function listAllowlistedUsers(platform?: Platform): AllowlistEntry[] {
290
+ const database = initSecurityStore();
291
+
292
+ const query = platform
293
+ ? "SELECT * FROM allowlist WHERE platform = ? ORDER BY added_at DESC"
294
+ : "SELECT * FROM allowlist ORDER BY platform, added_at DESC";
295
+
296
+ const rows = platform
297
+ ? (database.prepare(query).all(platform) as AllowlistEntry[])
298
+ : (database.prepare(query).all() as AllowlistEntry[]);
299
+
300
+ return rows;
301
+ }
302
+
303
+ /**
304
+ * Rate limiting
305
+ */
306
+ export function checkRateLimit(
307
+ identifier: string,
308
+ maxRequests: number = 60,
309
+ windowMs: number = 60000,
310
+ ): boolean {
311
+ const database = initSecurityStore();
312
+ const now = Date.now();
313
+
314
+ const entry = database
315
+ .prepare(`
316
+ SELECT * FROM rate_limits WHERE identifier = ?
317
+ `)
318
+ .get(identifier) as RateLimitEntry | undefined;
319
+
320
+ if (!entry || now - entry.windowStart > windowMs) {
321
+ // New window
322
+ database
323
+ .prepare(
324
+ `
325
+ INSERT OR REPLACE INTO rate_limits (identifier, count, window_start)
326
+ VALUES (?, 1, ?)
327
+ `,
328
+ )
329
+ .run(identifier, now);
330
+ return true;
331
+ }
332
+
333
+ if (entry.count >= maxRequests) {
334
+ logger.warn(`[Security] Rate limit exceeded for ${identifier}`);
335
+ return false;
336
+ }
337
+
338
+ // Increment counter
339
+ database
340
+ .prepare(
341
+ `
342
+ UPDATE rate_limits SET count = count + 1 WHERE identifier = ?
343
+ `,
344
+ )
345
+ .run(identifier);
346
+
347
+ return true;
348
+ }
349
+
350
+ /**
351
+ * Clean up expired pairing codes
352
+ */
353
+ export function cleanupExpiredCodes(): number {
354
+ const database = initSecurityStore();
355
+ const result = database
356
+ .prepare("DELETE FROM pairing_codes WHERE expires_at < ?")
357
+ .run(Date.now());
358
+ return result.changes;
359
+ }
360
+
361
+ // Shared with index.ts — reads from the single gateway config file.
362
+ // Schema: the `security` block inside ~/.pi/gateway/config.json
363
+ export interface SecurityConfig {
364
+ allowAll: boolean;
365
+ requirePairing: boolean;
366
+ allowedUids: Record<string, string[]>;
367
+ adminUids: Record<string, string[]>;
368
+ rateLimit: {
369
+ maxRequests: number;
370
+ windowMs: number;
371
+ };
372
+ }
373
+
374
+ function getSecurityConfig(): SecurityConfig {
375
+ try {
376
+ if (existsSync(GATEWAY_CONFIG_FILE)) {
377
+ const raw = JSON.parse(readFileSync(GATEWAY_CONFIG_FILE, "utf-8"));
378
+ if (raw.security) return raw.security as SecurityConfig;
379
+ }
380
+ } catch (err) {
381
+ logger.error(
382
+ "[Security] Failed to parse config — using defaults. Error:",
383
+ err,
384
+ );
385
+ }
386
+ return {
387
+ allowAll: false,
388
+ requirePairing: false,
389
+ allowedUids: {},
390
+ adminUids: {},
391
+ rateLimit: { maxRequests: 60, windowMs: 60000 },
392
+ };
393
+ }
394
+
395
+ // ── Admin Role Management ─────────────────────────────────────────
396
+
397
+ /**
398
+ * Check if a user has admin privileges.
399
+ * Looks at both DB admins table and config adminUids.
400
+ */
401
+ export function isAdmin(platform: Platform | string, userId: string): boolean {
402
+ const database = initSecurityStore();
403
+ const config = getSecurityConfig();
404
+
405
+ // Check config-based adminUids (cross-platform wildcard or platform-specific)
406
+ if (config.adminUids) {
407
+ const platformUids = config.adminUids[platform];
408
+ if (platformUids?.includes(userId)) return true;
409
+ const wildcardUids = config.adminUids["*"];
410
+ if (wildcardUids?.includes(userId)) return true;
411
+ }
412
+
413
+ // Check DB admins (platform-specific or wildcard)
414
+ const entry = database
415
+ .prepare(
416
+ `SELECT 1 FROM admins WHERE (platform = ? OR platform = '*') AND user_id = ?`,
417
+ )
418
+ .get(platform, userId);
419
+
420
+ return !!entry;
421
+ }
422
+
423
+ /** Add a user as admin. Platform "*" means admin on all platforms. */
424
+ export function addAdmin(
425
+ platform: Platform | "*",
426
+ userId: string,
427
+ note?: string,
428
+ ): void {
429
+ const database = initSecurityStore();
430
+ database
431
+ .prepare(
432
+ `INSERT OR REPLACE INTO admins (platform, user_id, added_at, note)
433
+ VALUES (?, ?, ?, ?)`,
434
+ )
435
+ .run(platform, userId, Date.now(), note ?? null);
436
+ logger.info(`[Security] Admin added: ${platform}:${userId}`);
437
+ }
438
+
439
+ /** Remove admin privileges from a user. Returns true if removed. */
440
+ export function removeAdmin(platform: Platform | "*", userId: string): boolean {
441
+ const database = initSecurityStore();
442
+ const result = database
443
+ .prepare("DELETE FROM admins WHERE platform = ? AND user_id = ?")
444
+ .run(platform, userId);
445
+ if (result.changes > 0) {
446
+ logger.info(`[Security] Admin removed: ${platform}:${userId}`);
447
+ }
448
+ return result.changes > 0;
449
+ }
450
+
451
+ /** List all admin entries. */
452
+ export function listAdmins(): AdminEntry[] {
453
+ const database = initSecurityStore();
454
+ const rows = database
455
+ .prepare("SELECT * FROM admins ORDER BY platform, user_id")
456
+ .all() as AdminEntry[];
457
+ return rows;
458
+ }
@@ -0,0 +1 @@
1
+ export { initSecurityStore, isUserAllowed, approvePairingCode, generatePairingCode, listPendingPairingCodes, addToAllowlist, listAllowlistedUsers, revokeUserAccess, checkRateLimit, type Platform } from "./auth.js";