@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.
- package/README.md +366 -0
- package/config/config.default.json +51 -0
- package/dist/adapters/base.d.ts +85 -0
- package/dist/adapters/base.js +34 -0
- package/dist/adapters/base.js.map +1 -0
- package/dist/adapters/discord.d.ts +53 -0
- package/dist/adapters/discord.js +224 -0
- package/dist/adapters/discord.js.map +1 -0
- package/dist/adapters/index.d.ts +13 -0
- package/dist/adapters/index.js +8 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/slack.d.ts +49 -0
- package/dist/adapters/slack.js +231 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/adapters/telegram.d.ts +64 -0
- package/dist/adapters/telegram.js +274 -0
- package/dist/adapters/telegram.js.map +1 -0
- package/dist/adapters/twitch.d.ts +75 -0
- package/dist/adapters/twitch.js +222 -0
- package/dist/adapters/twitch.js.map +1 -0
- package/dist/adapters/websocket.d.ts +30 -0
- package/dist/adapters/websocket.js +132 -0
- package/dist/adapters/websocket.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +49 -0
- package/dist/adapters/whatsapp.js +251 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/background/index.d.ts +1 -0
- package/dist/background/index.js +2 -0
- package/dist/background/index.js.map +1 -0
- package/dist/background/manager.d.ts +70 -0
- package/dist/background/manager.js +291 -0
- package/dist/background/manager.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +1275 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +39 -0
- package/dist/logger.js.map +1 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +32 -0
- package/dist/paths.js.map +1 -0
- package/dist/security/auth.d.ts +91 -0
- package/dist/security/auth.js +316 -0
- package/dist/security/auth.js.map +1 -0
- package/dist/security/index.d.ts +1 -0
- package/dist/security/index.js +2 -0
- package/dist/security/index.js.map +1 -0
- package/dist/security/tool-policy.d.ts +66 -0
- package/dist/security/tool-policy.js +467 -0
- package/dist/security/tool-policy.js.map +1 -0
- package/dist/sessions/index.d.ts +1 -0
- package/dist/sessions/index.js +2 -0
- package/dist/sessions/index.js.map +1 -0
- package/dist/sessions/store.d.ts +64 -0
- package/dist/sessions/store.js +247 -0
- package/dist/sessions/store.js.map +1 -0
- package/package.json +57 -0
- package/src/adapters/base.ts +124 -0
- package/src/adapters/discord.ts +270 -0
- package/src/adapters/index.ts +13 -0
- package/src/adapters/slack.ts +313 -0
- package/src/adapters/telegram.ts +394 -0
- package/src/adapters/twitch.ts +316 -0
- package/src/adapters/websocket.ts +158 -0
- package/src/adapters/whatsapp.ts +296 -0
- package/src/background/index.ts +1 -0
- package/src/background/manager.ts +395 -0
- package/src/index.ts +1665 -0
- package/src/logger.ts +43 -0
- package/src/paths.ts +40 -0
- package/src/security/auth.ts +458 -0
- package/src/security/index.ts +1 -0
- package/src/security/tool-policy.ts +568 -0
- package/src/sessions/index.ts +1 -0
- 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";
|