pi-antigravity-rotator 2.2.2 → 2.3.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/CHANGELOG.md +11 -0
- package/README.md +9 -1
- package/package.json +1 -1
- package/src/account-store.ts +109 -168
- package/src/admin-auth.ts +74 -83
- package/src/cli.ts +62 -46
- package/src/config-defaults.ts +64 -0
- package/src/config-storage.ts +21 -0
- package/src/dashboard.ts +310 -3193
- package/src/db-store.ts +155 -0
- package/src/doctor.ts +96 -78
- package/src/index.ts +200 -149
- package/src/onboarding.ts +352 -113
- package/src/paths.ts +34 -31
- package/src/proxy.ts +1751 -1270
- package/src/responses-store.ts +150 -178
- package/src/rotator.ts +3041 -2310
- package/src/settings-repository.ts +314 -0
- package/src/static/dashboard.css +1224 -0
- package/src/static/dashboard.js +1834 -0
- package/src/types.ts +501 -397
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Repository pattern for rotator settings persistence.
|
|
2
|
+
//
|
|
3
|
+
// Two implementations:
|
|
4
|
+
// PostgresSettingsRepository — backed by a `rotator_settings` table
|
|
5
|
+
// FileSettingsRepository — backed by JSON files on disk
|
|
6
|
+
|
|
7
|
+
import pg from "pg";
|
|
8
|
+
import { existsSync, readFileSync, chmodSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { getConfigDir } from "./paths.js";
|
|
11
|
+
import {
|
|
12
|
+
backupFile,
|
|
13
|
+
readTextFile,
|
|
14
|
+
writeJsonFileAtomic,
|
|
15
|
+
writeTextFileAtomic,
|
|
16
|
+
} from "./storage.js";
|
|
17
|
+
|
|
18
|
+
const { Pool } = pg;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generic key-value settings repository.
|
|
22
|
+
*
|
|
23
|
+
* Callers use string keys; the values are opaque strings (typically
|
|
24
|
+
* JSON-serialized objects). Both implementations guarantee that `get()`
|
|
25
|
+
* always returns the latest value set via `set()` (in-process consistency).
|
|
26
|
+
*/
|
|
27
|
+
export interface ISettingsRepository {
|
|
28
|
+
/** Initialize the repository (create table / load from disk) */
|
|
29
|
+
init(): Promise<void>;
|
|
30
|
+
|
|
31
|
+
/** Get a value by key */
|
|
32
|
+
get(key: string): string | null;
|
|
33
|
+
|
|
34
|
+
/** Persist a value by key */
|
|
35
|
+
set(key: string, value: string): void;
|
|
36
|
+
|
|
37
|
+
/** Close connections / flush pending writes */
|
|
38
|
+
close(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ----- Key → disk-file mapping -----
|
|
42
|
+
|
|
43
|
+
interface DiskFileSpec {
|
|
44
|
+
filename: string;
|
|
45
|
+
/** Whether to back up the file before overwriting */
|
|
46
|
+
backup: boolean;
|
|
47
|
+
/** Whether to trim whitespace on read (e.g. admin_token) */
|
|
48
|
+
trim: boolean;
|
|
49
|
+
/** File mode to set after writing (e.g. 0o600 for secrets) */
|
|
50
|
+
mode?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DISK_FILES: Record<string, DiskFileSpec> = {
|
|
54
|
+
accounts_json: { filename: "accounts.json", backup: true, trim: false },
|
|
55
|
+
admin_token: {
|
|
56
|
+
filename: ".admin-token",
|
|
57
|
+
backup: false,
|
|
58
|
+
trim: true,
|
|
59
|
+
mode: 0o600,
|
|
60
|
+
},
|
|
61
|
+
rotator_state: { filename: "state.json", backup: true, trim: false },
|
|
62
|
+
token_usage: { filename: "token-usage.json", backup: false, trim: false },
|
|
63
|
+
responses_store: { filename: "responses.json", backup: false, trim: false },
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function diskPath(key: string): string | null {
|
|
67
|
+
const spec = DISK_FILES[key];
|
|
68
|
+
if (!spec) return null;
|
|
69
|
+
return join(getConfigDir(), spec.filename);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ----- PostgresSettingsRepository -----
|
|
73
|
+
|
|
74
|
+
const RETRY_DELAY_MS = 5_000;
|
|
75
|
+
const MAX_RETRIES = 3;
|
|
76
|
+
|
|
77
|
+
interface PendingWrite {
|
|
78
|
+
value: string;
|
|
79
|
+
attempts: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class PostgresSettingsRepository implements ISettingsRepository {
|
|
83
|
+
private pool: pg.Pool | null = null;
|
|
84
|
+
private cache = new Map<string, string>();
|
|
85
|
+
private initialized = false;
|
|
86
|
+
private pendingRetries = new Map<string, PendingWrite>();
|
|
87
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
88
|
+
|
|
89
|
+
async init(): Promise<void> {
|
|
90
|
+
if (this.initialized) return;
|
|
91
|
+
|
|
92
|
+
const connectionString =
|
|
93
|
+
process.env.PI_ROTATOR_DATABASE_URL || process.env.DATABASE_URL;
|
|
94
|
+
this.pool = new Pool({
|
|
95
|
+
connectionString,
|
|
96
|
+
ssl:
|
|
97
|
+
connectionString?.includes("sslmode=require") ||
|
|
98
|
+
connectionString?.includes("ssl=")
|
|
99
|
+
? { rejectUnauthorized: false }
|
|
100
|
+
: undefined,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Create table if it doesn't exist
|
|
104
|
+
await this.pool.query(`
|
|
105
|
+
CREATE TABLE IF NOT EXISTS rotator_settings (
|
|
106
|
+
key VARCHAR(255) PRIMARY KEY,
|
|
107
|
+
value TEXT NOT NULL,
|
|
108
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
109
|
+
);
|
|
110
|
+
`);
|
|
111
|
+
|
|
112
|
+
// Load all rows into cache
|
|
113
|
+
const result = await this.pool.query(
|
|
114
|
+
"SELECT key, value FROM rotator_settings",
|
|
115
|
+
);
|
|
116
|
+
for (const row of result.rows) {
|
|
117
|
+
this.cache.set(row.key, row.value);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Migrate from disk for any keys not already in DB
|
|
121
|
+
for (const [key, spec] of Object.entries(DISK_FILES)) {
|
|
122
|
+
if (this.cache.has(key)) continue;
|
|
123
|
+
try {
|
|
124
|
+
const path = join(getConfigDir(), spec.filename);
|
|
125
|
+
if (!existsSync(path)) continue;
|
|
126
|
+
const content = readFileSync(path, "utf-8");
|
|
127
|
+
if (!content) continue;
|
|
128
|
+
const value = spec.trim ? content.trim() : content;
|
|
129
|
+
if (!value) continue;
|
|
130
|
+
this.cache.set(key, value);
|
|
131
|
+
// Persist migration to DB (blocking on init is intentional)
|
|
132
|
+
await this.pool.query(
|
|
133
|
+
`INSERT INTO rotator_settings (key, value, updated_at)
|
|
134
|
+
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
135
|
+
ON CONFLICT (key) DO UPDATE
|
|
136
|
+
SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP`,
|
|
137
|
+
[key, value],
|
|
138
|
+
);
|
|
139
|
+
console.log(`Migrated ${spec.filename} from disk to database`);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`Failed to migrate ${spec.filename} from disk: ${err}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.initialized = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get(key: string): string | null {
|
|
149
|
+
return this.cache.get(key) ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
set(key: string, value: string): void {
|
|
153
|
+
this.cache.set(key, value);
|
|
154
|
+
if (this.pool) {
|
|
155
|
+
// Non-blocking background save
|
|
156
|
+
this.pool
|
|
157
|
+
.query(
|
|
158
|
+
`INSERT INTO rotator_settings (key, value, updated_at)
|
|
159
|
+
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
160
|
+
ON CONFLICT (key) DO UPDATE
|
|
161
|
+
SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP`,
|
|
162
|
+
[key, value],
|
|
163
|
+
)
|
|
164
|
+
.catch((err) => {
|
|
165
|
+
console.error(
|
|
166
|
+
`Failed to save ${key} to database in background: ${err}`,
|
|
167
|
+
);
|
|
168
|
+
this.enqueueRetry(key, value);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private enqueueRetry(key: string, value: string): void {
|
|
174
|
+
const existing = this.pendingRetries.get(key);
|
|
175
|
+
// If the key is already pending, keep the higher attempt count
|
|
176
|
+
// but always use the latest value.
|
|
177
|
+
this.pendingRetries.set(key, {
|
|
178
|
+
value,
|
|
179
|
+
attempts: existing ? existing.attempts + 1 : 1,
|
|
180
|
+
});
|
|
181
|
+
this.scheduleRetryFlush();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private scheduleRetryFlush(): void {
|
|
185
|
+
if (this.retryTimer) return; // already scheduled
|
|
186
|
+
this.retryTimer = setTimeout(() => {
|
|
187
|
+
this.retryTimer = null;
|
|
188
|
+
void this.flushPendingRetries();
|
|
189
|
+
}, RETRY_DELAY_MS);
|
|
190
|
+
// Don't keep the process alive just for retries
|
|
191
|
+
this.retryTimer.unref();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async flushPendingRetries(): Promise<void> {
|
|
195
|
+
if (!this.pool || this.pendingRetries.size === 0) return;
|
|
196
|
+
|
|
197
|
+
const entries = [...this.pendingRetries.entries()];
|
|
198
|
+
this.pendingRetries.clear();
|
|
199
|
+
|
|
200
|
+
for (const [key, pending] of entries) {
|
|
201
|
+
try {
|
|
202
|
+
await this.pool.query(
|
|
203
|
+
`INSERT INTO rotator_settings (key, value, updated_at)
|
|
204
|
+
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
|
205
|
+
ON CONFLICT (key) DO UPDATE
|
|
206
|
+
SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP`,
|
|
207
|
+
[key, pending.value],
|
|
208
|
+
);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (pending.attempts >= MAX_RETRIES) {
|
|
211
|
+
console.error(
|
|
212
|
+
`Giving up on persisting ${key} after ${pending.attempts} failed attempts: ${err}`,
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
console.error(
|
|
216
|
+
`Retry ${pending.attempts}/${MAX_RETRIES} failed for ${key}: ${err}`,
|
|
217
|
+
);
|
|
218
|
+
this.pendingRetries.set(key, {
|
|
219
|
+
value: pending.value,
|
|
220
|
+
attempts: pending.attempts + 1,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// If there are still entries that failed, schedule another round
|
|
227
|
+
if (this.pendingRetries.size > 0) {
|
|
228
|
+
this.scheduleRetryFlush();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async close(): Promise<void> {
|
|
233
|
+
if (this.retryTimer) {
|
|
234
|
+
clearTimeout(this.retryTimer);
|
|
235
|
+
this.retryTimer = null;
|
|
236
|
+
}
|
|
237
|
+
if (this.pool) {
|
|
238
|
+
// Final attempt to drain any pending retries before shutdown
|
|
239
|
+
await this.flushPendingRetries();
|
|
240
|
+
await this.pool.end();
|
|
241
|
+
this.pool = null;
|
|
242
|
+
}
|
|
243
|
+
this.initialized = false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ----- FileSettingsRepository -----
|
|
248
|
+
//
|
|
249
|
+
// Reads and writes directly to disk files. Keeps an in-memory cache so
|
|
250
|
+
// repeated `get()` calls within the same process don't re-parse from disk.
|
|
251
|
+
|
|
252
|
+
export class FileSettingsRepository implements ISettingsRepository {
|
|
253
|
+
private cache = new Map<string, string>();
|
|
254
|
+
|
|
255
|
+
async init(): Promise<void> {
|
|
256
|
+
// Pre-load all known keys from their disk files into cache
|
|
257
|
+
for (const [key, spec] of Object.entries(DISK_FILES)) {
|
|
258
|
+
try {
|
|
259
|
+
const path = join(getConfigDir(), spec.filename);
|
|
260
|
+
if (!existsSync(path)) continue;
|
|
261
|
+
const content = readTextFile(path);
|
|
262
|
+
if (!content) continue;
|
|
263
|
+
this.cache.set(key, spec.trim ? content.trim() : content);
|
|
264
|
+
} catch {
|
|
265
|
+
// Ignore unreadable files — callers handle missing data
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
get(key: string): string | null {
|
|
271
|
+
return this.cache.get(key) ?? null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
set(key: string, value: string): void {
|
|
275
|
+
this.cache.set(key, value);
|
|
276
|
+
|
|
277
|
+
// Persist to disk
|
|
278
|
+
const path = diskPath(key);
|
|
279
|
+
if (!path) return;
|
|
280
|
+
|
|
281
|
+
const spec = DISK_FILES[key];
|
|
282
|
+
try {
|
|
283
|
+
if (spec?.backup) {
|
|
284
|
+
backupFile(path, spec.filename.replace(".json", ""));
|
|
285
|
+
}
|
|
286
|
+
if (spec?.trim) {
|
|
287
|
+
// Plain text file (e.g. admin_token)
|
|
288
|
+
writeTextFileAtomic(path, value);
|
|
289
|
+
} else {
|
|
290
|
+
// JSON files — try to pretty-print, fall back to raw string
|
|
291
|
+
try {
|
|
292
|
+
const parsed = JSON.parse(value);
|
|
293
|
+
writeJsonFileAtomic(path, parsed);
|
|
294
|
+
} catch {
|
|
295
|
+
writeTextFileAtomic(path, value);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Apply restricted permissions when specified (e.g. secrets)
|
|
299
|
+
if (spec?.mode) {
|
|
300
|
+
try {
|
|
301
|
+
chmodSync(path, spec.mode);
|
|
302
|
+
} catch {
|
|
303
|
+
// Best effort: some filesystems (e.g. Windows) don't support POSIX perms.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error(`Failed to save ${key} to disk (${path}): ${err}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async close(): Promise<void> {
|
|
312
|
+
// no-op — all writes are synchronous
|
|
313
|
+
}
|
|
314
|
+
}
|