pool-anything 0.1.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/LICENSE +202 -0
- package/README.md +280 -0
- package/bin/pool-anything.js +4 -0
- package/data/providers.json +38 -0
- package/dist/admin/branding.js +34 -0
- package/dist/admin/components.js +189 -0
- package/dist/admin/pages.js +1891 -0
- package/dist/admin/shell.js +129 -0
- package/dist/admin/tokens.js +32 -0
- package/dist/cli/nl.js +147 -0
- package/dist/cli/repl.js +470 -0
- package/dist/cli/ui.js +81 -0
- package/dist/cli.js +199 -0
- package/dist/common/http.js +55 -0
- package/dist/config/env.js +94 -0
- package/dist/config/paths.js +112 -0
- package/dist/data/providers.json +38 -0
- package/dist/db/index.js +20 -0
- package/dist/db/init.js +11 -0
- package/dist/logo.png +0 -0
- package/dist/media/capabilities.js +103 -0
- package/dist/middleware/auth.js +37 -0
- package/dist/observability/health.js +31 -0
- package/dist/pool/index.js +407 -0
- package/dist/pool/rotation.js +123 -0
- package/dist/proxy/dns.js +39 -0
- package/dist/proxy/forward.js +350 -0
- package/dist/public/logos/anthropic.svg +1 -0
- package/dist/public/logos/anyscale.svg +1 -0
- package/dist/public/logos/appwrite.svg +1 -0
- package/dist/public/logos/brave.svg +1 -0
- package/dist/public/logos/cartesia.svg +4 -0
- package/dist/public/logos/claude.svg +1 -0
- package/dist/public/logos/cloudflare.svg +1 -0
- package/dist/public/logos/cohere-command.svg +1 -0
- package/dist/public/logos/cohere.svg +1 -0
- package/dist/public/logos/deepgram.svg +1 -0
- package/dist/public/logos/elevenlabs.svg +1 -0
- package/dist/public/logos/finnhub.png +0 -0
- package/dist/public/logos/fireworks.svg +1 -0
- package/dist/public/logos/gemini.svg +1 -0
- package/dist/public/logos/google.svg +1 -0
- package/dist/public/logos/googlemaps.svg +1 -0
- package/dist/public/logos/groq.svg +1 -0
- package/dist/public/logos/huggingface.svg +1 -0
- package/dist/public/logos/mailgun.svg +1 -0
- package/dist/public/logos/manifest.json +197 -0
- package/dist/public/logos/mapbox.svg +1 -0
- package/dist/public/logos/mistral.svg +1 -0
- package/dist/public/logos/neon.svg +1 -0
- package/dist/public/logos/openai.svg +1 -0
- package/dist/public/logos/openrouter.svg +1 -0
- package/dist/public/logos/openweather.png +0 -0
- package/dist/public/logos/perplexity.svg +1 -0
- package/dist/public/logos/pool-anything.svg +111 -0
- package/dist/public/logos/postmark.svg +4 -0
- package/dist/public/logos/replicate.svg +1 -0
- package/dist/public/logos/resend.svg +1 -0
- package/dist/public/logos/scrapingbee.svg +72 -0
- package/dist/public/logos/sendgrid.svg +1 -0
- package/dist/public/logos/serper.png +0 -0
- package/dist/public/logos/supabase.svg +1 -0
- package/dist/public/logos/tavily.svg +1 -0
- package/dist/public/logos/together.svg +1 -0
- package/dist/public/logos/turso.svg +1 -0
- package/dist/public/logos/twilio.svg +1 -0
- package/dist/public/logos/upstash.svg +1 -0
- package/dist/router/api.js +20 -0
- package/dist/server.js +431 -0
- package/dist/upstream/index.js +137 -0
- package/package.json +68 -0
- package/public/logos/anthropic.svg +1 -0
- package/public/logos/anyscale.svg +1 -0
- package/public/logos/appwrite.svg +1 -0
- package/public/logos/brave.svg +1 -0
- package/public/logos/cartesia.svg +4 -0
- package/public/logos/claude.svg +1 -0
- package/public/logos/cloudflare.svg +1 -0
- package/public/logos/cohere-command.svg +1 -0
- package/public/logos/cohere.svg +1 -0
- package/public/logos/deepgram.svg +1 -0
- package/public/logos/elevenlabs.svg +1 -0
- package/public/logos/finnhub.png +0 -0
- package/public/logos/fireworks.svg +1 -0
- package/public/logos/gemini.svg +1 -0
- package/public/logos/google.svg +1 -0
- package/public/logos/googlemaps.svg +1 -0
- package/public/logos/groq.svg +1 -0
- package/public/logos/huggingface.svg +1 -0
- package/public/logos/mailgun.svg +1 -0
- package/public/logos/manifest.json +197 -0
- package/public/logos/mapbox.svg +1 -0
- package/public/logos/mistral.svg +1 -0
- package/public/logos/neon.svg +1 -0
- package/public/logos/openai.svg +1 -0
- package/public/logos/openrouter.svg +1 -0
- package/public/logos/openweather.png +0 -0
- package/public/logos/perplexity.svg +1 -0
- package/public/logos/pool-anything.svg +111 -0
- package/public/logos/postmark.svg +4 -0
- package/public/logos/replicate.svg +1 -0
- package/public/logos/resend.svg +1 -0
- package/public/logos/scrapingbee.svg +72 -0
- package/public/logos/sendgrid.svg +1 -0
- package/public/logos/serper.png +0 -0
- package/public/logos/supabase.svg +1 -0
- package/public/logos/tavily.svg +1 -0
- package/public/logos/together.svg +1 -0
- package/public/logos/turso.svg +1 -0
- package/public/logos/twilio.svg +1 -0
- package/public/logos/upstash.svg +1 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { HOST, PORT, isAllowedOrigin, startupWarnings } from "./config/env.js";
|
|
5
|
+
import { logoPngPath, publicDir } from "./config/paths.js";
|
|
6
|
+
import { readJson, send } from "./common/http.js";
|
|
7
|
+
import { MAX_PROXY_BODY_BYTES } from "./media/capabilities.js";
|
|
8
|
+
import { isAuthorized, requiresAuth } from "./middleware/auth.js";
|
|
9
|
+
import { sdb, PROVIDERS, mask, maskCredentials, normalizeCredentials, parseCredentials, providerKeyFields, getPool, poolSummary } from "./pool/index.js";
|
|
10
|
+
import { nextKey, recordUsage } from "./pool/rotation.js";
|
|
11
|
+
import { forward, MAX_TOKENS_PER_REQUEST, checkTarget } from "./proxy/forward.js";
|
|
12
|
+
import { DOCS_URL } from "./admin/branding.js";
|
|
13
|
+
import { page, keysPage, poolsPage, analyticsPage, playgroundPage, providerPage } from "./admin/pages.js";
|
|
14
|
+
import { MAX_POOLS, MAX_KEYS_PER_POOL, MAX_API_KEY_CHARS, matchPoolRoute, matchKeyRoute } from "./router/api.js";
|
|
15
|
+
import { health, readiness, analyticsSnapshot } from "./observability/health.js";
|
|
16
|
+
const server = http.createServer(async (req, res) => {
|
|
17
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
18
|
+
// CORS: only the hosted Pages tools UI (plus http loopback dev and
|
|
19
|
+
// ALLOWED_ORIGINS) may read API responses cross-origin. Arbitrary origins
|
|
20
|
+
// get no ACAO header so browsers block the read — reflecting any Origin
|
|
21
|
+
// would let any site the operator visits exfiltrate keys from a
|
|
22
|
+
// token-less loopback backend.
|
|
23
|
+
const origin = req.headers.origin;
|
|
24
|
+
const allowedOrigin = typeof origin === "string" && origin && isAllowedOrigin(origin) ? origin : null;
|
|
25
|
+
if (allowedOrigin) {
|
|
26
|
+
res.setHeader("access-control-allow-origin", allowedOrigin);
|
|
27
|
+
res.setHeader("vary", "Origin");
|
|
28
|
+
}
|
|
29
|
+
res.setHeader("access-control-allow-methods", "GET, POST, PATCH, DELETE, OPTIONS");
|
|
30
|
+
res.setHeader("access-control-allow-headers", "content-type, authorization");
|
|
31
|
+
if ((req.method ?? "GET") === "OPTIONS") {
|
|
32
|
+
// Preflight: succeed at the HTTP layer but only advertise CORS to
|
|
33
|
+
// allowlisted callers; others get no ACAO header and stay blocked.
|
|
34
|
+
res.writeHead(204);
|
|
35
|
+
res.end();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
if (requiresAuth(req.method ?? "GET", url.pathname) && !isAuthorized(req)) {
|
|
40
|
+
send(res, 401, { error: "unauthorized: missing or invalid bearer token" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
44
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
45
|
+
res.end(page);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (req.method === "GET" && (url.pathname === "/logo.png" || url.pathname === "/favicon.ico" || url.pathname === "/favicon.png")) {
|
|
49
|
+
try {
|
|
50
|
+
const p = logoPngPath();
|
|
51
|
+
const buf = fs.readFileSync(p);
|
|
52
|
+
res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=3600" });
|
|
53
|
+
res.end(buf);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
57
|
+
res.end("not found\n");
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (req.method === "GET" && url.pathname === "/api/providers") {
|
|
62
|
+
send(res, 200, PROVIDERS);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (req.method === "GET" && url.pathname === "/api/analytics") {
|
|
66
|
+
const r = analyticsSnapshot();
|
|
67
|
+
send(res, r.status, r.body);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (req.method === "GET" && url.pathname === "/api/pools") {
|
|
71
|
+
send(res, 200, sdb.prepare("SELECT * FROM pools ORDER BY id DESC").all());
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (req.method === "POST" && url.pathname === "/api/pools") {
|
|
75
|
+
const b = (await readJson(req));
|
|
76
|
+
const provider = typeof b.provider === "string" ? b.provider.trim() : "";
|
|
77
|
+
const name = typeof b.name === "string" ? b.name.trim() : "";
|
|
78
|
+
if (!provider || !name)
|
|
79
|
+
return send(res, 400, { error: "provider + name required" });
|
|
80
|
+
if (provider.length > 80 || name.length > 80)
|
|
81
|
+
return send(res, 400, { error: "provider + name must be <= 80 characters" });
|
|
82
|
+
const total = sdb.prepare("SELECT COUNT(*) AS n FROM pools").get().n;
|
|
83
|
+
if (total >= MAX_POOLS)
|
|
84
|
+
return send(res, 400, { error: `pool limit reached (${MAX_POOLS})` });
|
|
85
|
+
// Fail fast on auth config the proxy could never use (forward-time check stays as backstop).
|
|
86
|
+
const kh = typeof b.key_header === "string" ? b.key_header.trim() : "";
|
|
87
|
+
if (kh) {
|
|
88
|
+
const okHeader = /^[A-Za-z0-9-]+$/.test(kh) || (kh.startsWith("query:") && /^[A-Za-z0-9_-]+$/.test(kh.slice("query:".length)));
|
|
89
|
+
if (!okHeader)
|
|
90
|
+
return send(res, 400, { error: "key_header invalid" });
|
|
91
|
+
if (/[\r\n\x00]/.test(kh))
|
|
92
|
+
return send(res, 400, { error: "key_header invalid" });
|
|
93
|
+
}
|
|
94
|
+
const baseUrl = typeof b.base_url === "string" ? b.base_url.trim() : "";
|
|
95
|
+
const keyPrefix = typeof b.key_prefix === "string" ? b.key_prefix : "";
|
|
96
|
+
if (/[\r\n\x00]/.test(keyPrefix))
|
|
97
|
+
return send(res, 400, { error: "key_prefix invalid" });
|
|
98
|
+
if (baseUrl) {
|
|
99
|
+
const safe = checkTarget(baseUrl, kh || "X-API-Key");
|
|
100
|
+
if (!safe.ok)
|
|
101
|
+
return send(res, 400, safe);
|
|
102
|
+
}
|
|
103
|
+
const r = sdb
|
|
104
|
+
.prepare("INSERT INTO pools (provider, name, base_url, key_header, key_prefix) VALUES (?, ?, ?, ?, ?)")
|
|
105
|
+
.run(provider, name, baseUrl.slice(0, 500), kh.slice(0, 80), keyPrefix.slice(0, 40));
|
|
106
|
+
send(res, 200, sdb.prepare("SELECT * FROM pools WHERE id = ?").get(r.lastInsertRowid));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const poolR = matchPoolRoute(url.pathname);
|
|
110
|
+
if (poolR) {
|
|
111
|
+
const poolId = poolR.poolId;
|
|
112
|
+
const sub = poolR.sub;
|
|
113
|
+
if (req.method === "GET" && !sub) {
|
|
114
|
+
const s = poolSummary(poolId);
|
|
115
|
+
if (!s)
|
|
116
|
+
return send(res, 404, { error: "pool not found" });
|
|
117
|
+
send(res, 200, s);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (req.method === "DELETE" && !sub) {
|
|
121
|
+
sdb.exec("BEGIN IMMEDIATE");
|
|
122
|
+
try {
|
|
123
|
+
sdb.prepare("DELETE FROM usage WHERE pool_id = ?").run(poolId);
|
|
124
|
+
sdb.prepare("DELETE FROM pool_keys WHERE pool_id = ?").run(poolId);
|
|
125
|
+
const r = sdb.prepare("DELETE FROM pools WHERE id = ?").run(poolId);
|
|
126
|
+
if (r.changes === 0) {
|
|
127
|
+
sdb.exec("ROLLBACK");
|
|
128
|
+
return send(res, 404, { error: "pool not found" });
|
|
129
|
+
}
|
|
130
|
+
sdb.exec("COMMIT");
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
try {
|
|
134
|
+
sdb.exec("ROLLBACK");
|
|
135
|
+
}
|
|
136
|
+
catch { /* already rolled back */ }
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
send(res, 200, { ok: true });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (req.method === "GET" && sub === "/next") {
|
|
143
|
+
const sel = nextKey(poolId);
|
|
144
|
+
if ("error" in sel)
|
|
145
|
+
return send(res, sel.error === "pool not found" ? 404 : 400, sel);
|
|
146
|
+
send(res, 200, sel);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (req.method === "POST" && sub === "/consume") {
|
|
150
|
+
const b = (await readJson(req));
|
|
151
|
+
const tok = b.tokens;
|
|
152
|
+
if (!Number.isInteger(tok) || tok <= 0 || tok > MAX_TOKENS_PER_REQUEST)
|
|
153
|
+
return send(res, 400, { error: `tokens must be a positive integer <= ${MAX_TOKENS_PER_REQUEST}` });
|
|
154
|
+
if (b.key_id !== undefined && (!Number.isInteger(b.key_id) || b.key_id <= 0))
|
|
155
|
+
return send(res, 400, { error: "key_id must be a positive integer" });
|
|
156
|
+
const sel = recordUsage(poolId, tok, b.key_id);
|
|
157
|
+
if ("error" in sel) {
|
|
158
|
+
const code = sel.error === "pool not found" || sel.error === "key not found" ? 404 : 400;
|
|
159
|
+
return send(res, code, sel);
|
|
160
|
+
}
|
|
161
|
+
send(res, 200, sel);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (req.method === "GET" && sub === "/usage") {
|
|
165
|
+
const s = poolSummary(poolId);
|
|
166
|
+
if (!s)
|
|
167
|
+
return send(res, 404, { error: "pool not found" });
|
|
168
|
+
send(res, 200, { pool_id: s.id, used: s.used, usedInWindow: s.usedInWindow, quota: s.quota, quotaWindow: s.quotaWindow, remaining: s.remaining, perKey: s.perKey });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (req.method === "POST" && sub === "/proxy") {
|
|
172
|
+
// Media-aware limit: base64 image/audio/video payloads ride inside JSON.
|
|
173
|
+
const b = (await readJson(req, MAX_PROXY_BODY_BYTES));
|
|
174
|
+
if (b === null || typeof b !== "object" || Array.isArray(b))
|
|
175
|
+
return send(res, 400, { error: "proxy body must be an object" });
|
|
176
|
+
const r = await forward(poolId, b);
|
|
177
|
+
if ("error" in r)
|
|
178
|
+
return send(res, r.status === 200 ? 400 : r.status, r);
|
|
179
|
+
send(res, 200, { key_id: r.key_id, label: r.label, masked: r.masked, status: r.upstreamStatus, body: r.body, encoding: r.encoding ?? "text", contentType: r.contentType ?? "", truncated: r.truncated ?? false, tried: r.tried });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const keyR = matchKeyRoute(url.pathname);
|
|
184
|
+
if (keyR) {
|
|
185
|
+
const poolId = keyR.poolId;
|
|
186
|
+
const keyId = keyR.keyId === null ? undefined : String(keyR.keyId);
|
|
187
|
+
if (req.method === "GET" && keyId === undefined) {
|
|
188
|
+
const pool = getPool(poolId);
|
|
189
|
+
if (!pool)
|
|
190
|
+
return send(res, 404, { error: "pool not found" });
|
|
191
|
+
const fields = providerKeyFields(pool.provider);
|
|
192
|
+
const rows = sdb
|
|
193
|
+
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE pool_id = ? ORDER BY id")
|
|
194
|
+
.all(poolId);
|
|
195
|
+
send(res, 200, rows.map((k) => {
|
|
196
|
+
const creds = parseCredentials(k.credentials);
|
|
197
|
+
return {
|
|
198
|
+
id: k.id, label: k.label, masked: mask(k.api_key), info: k.info, created_at: k.created_at,
|
|
199
|
+
keyFields: fields,
|
|
200
|
+
credentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined,
|
|
201
|
+
};
|
|
202
|
+
}));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (req.method === "POST" && keyId === undefined) {
|
|
206
|
+
const pool = getPool(poolId);
|
|
207
|
+
if (!pool)
|
|
208
|
+
return send(res, 404, { error: "pool not found" });
|
|
209
|
+
const b = (await readJson(req));
|
|
210
|
+
const label = typeof b.label === "string" ? b.label.trim() : "";
|
|
211
|
+
if (!label)
|
|
212
|
+
return send(res, 400, { error: "label required" });
|
|
213
|
+
if (label.length > 80)
|
|
214
|
+
return send(res, 400, { error: "label must be <= 80 characters" });
|
|
215
|
+
if (/[\r\n\x00]/.test(label))
|
|
216
|
+
return send(res, 400, { error: "label must not contain CR/LF" });
|
|
217
|
+
if (b.info !== undefined && typeof b.info !== "string")
|
|
218
|
+
return send(res, 400, { error: "info must be a string" });
|
|
219
|
+
const fields = providerKeyFields(pool.provider);
|
|
220
|
+
const multi = !(fields.length === 1 && (fields[0] === "api_key" || fields[0] === "apiToken"));
|
|
221
|
+
let apiKey = "";
|
|
222
|
+
let credJson = "{}";
|
|
223
|
+
if (multi) {
|
|
224
|
+
// Multi-field providers require a credentials object; api_key alone is rejected
|
|
225
|
+
// so partial secrets are never stored.
|
|
226
|
+
if (b.credentials === undefined)
|
|
227
|
+
return send(res, 400, { error: `credentials required: ${fields.join(", ")}` });
|
|
228
|
+
const norm = normalizeCredentials(pool.provider, b.credentials);
|
|
229
|
+
if (!norm.ok)
|
|
230
|
+
return send(res, 400, { error: norm.error });
|
|
231
|
+
credJson = JSON.stringify(norm.value);
|
|
232
|
+
// api_key stays a non-empty dedup/display handle: prefer a conventional
|
|
233
|
+
// single-secret field, else the first field value.
|
|
234
|
+
const single = (norm.value["apiKey"] ?? norm.value["api_key"] ?? norm.value["apiToken"] ?? norm.value["platformToken"] ?? norm.value["anonKey"] ?? norm.value["authToken"] ?? fields.map((f) => norm.value[f]).find(Boolean) ?? "");
|
|
235
|
+
apiKey = single;
|
|
236
|
+
if (/[\r\n\x00]/.test(apiKey))
|
|
237
|
+
return send(res, 400, { error: "credentials must not contain CR/LF" });
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
apiKey = typeof b.api_key === "string" ? b.api_key.trim() : "";
|
|
241
|
+
if (b.credentials !== undefined) {
|
|
242
|
+
const norm = normalizeCredentials(pool.provider, b.credentials);
|
|
243
|
+
if (!norm.ok)
|
|
244
|
+
return send(res, 400, { error: norm.error });
|
|
245
|
+
credJson = JSON.stringify(norm.value);
|
|
246
|
+
const v = norm.value[fields[0]] ?? apiKey;
|
|
247
|
+
if (!apiKey)
|
|
248
|
+
apiKey = v;
|
|
249
|
+
}
|
|
250
|
+
if (!apiKey)
|
|
251
|
+
return send(res, 400, { error: "label + api_key required" });
|
|
252
|
+
if (/[\r\n\x00]/.test(apiKey))
|
|
253
|
+
return send(res, 400, { error: "label + api_key must not contain CR/LF" });
|
|
254
|
+
}
|
|
255
|
+
if (apiKey.length > MAX_API_KEY_CHARS)
|
|
256
|
+
return send(res, 400, { error: `api_key must be <= ${MAX_API_KEY_CHARS} characters` });
|
|
257
|
+
const kc = sdb.prepare("SELECT COUNT(*) AS n FROM pool_keys WHERE pool_id = ?").get(poolId).n;
|
|
258
|
+
if (kc >= MAX_KEYS_PER_POOL)
|
|
259
|
+
return send(res, 400, { error: `key limit reached (${MAX_KEYS_PER_POOL} per pool)` });
|
|
260
|
+
const dup = sdb.prepare("SELECT id FROM pool_keys WHERE pool_id = ? AND api_key = ?").get(poolId, apiKey);
|
|
261
|
+
if (dup)
|
|
262
|
+
return send(res, 400, { error: "duplicate api_key in this pool" });
|
|
263
|
+
const r = sdb
|
|
264
|
+
.prepare("INSERT INTO pool_keys (pool_id, label, api_key, credentials, info) VALUES (?, ?, ?, ?, ?)")
|
|
265
|
+
.run(poolId, label, apiKey, credJson, (b.info ?? "").slice(0, 200));
|
|
266
|
+
send(res, 200, { id: r.lastInsertRowid });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (req.method === "GET" && keyId !== undefined) {
|
|
270
|
+
const row = sdb
|
|
271
|
+
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE id = ? AND pool_id = ?")
|
|
272
|
+
.get(Number(keyId), poolId);
|
|
273
|
+
if (!row)
|
|
274
|
+
return send(res, 404, { error: "key not found" });
|
|
275
|
+
const creds = parseCredentials(row.credentials);
|
|
276
|
+
send(res, 200, { ...row, credentials: Object.keys(creds).length > 0 ? creds : undefined, masked: mask(row.api_key), maskedCredentials: Object.keys(creds).length > 0 ? maskCredentials(creds) : undefined });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (req.method === "PATCH" && keyId !== undefined) {
|
|
280
|
+
const b = (await readJson(req));
|
|
281
|
+
const cur = sdb
|
|
282
|
+
.prepare("SELECT id FROM pool_keys WHERE id = ? AND pool_id = ?")
|
|
283
|
+
.get(Number(keyId), poolId);
|
|
284
|
+
if (!cur)
|
|
285
|
+
return send(res, 404, { error: "key not found" });
|
|
286
|
+
if (b.label === undefined && b.api_key === undefined && b.credentials === undefined && b.info === undefined)
|
|
287
|
+
return send(res, 400, { error: "nothing to update" });
|
|
288
|
+
if (b.label !== undefined) {
|
|
289
|
+
if (typeof b.label !== "string" || !b.label.trim() || b.label.trim().length > 80)
|
|
290
|
+
return send(res, 400, { error: "label must be a non-empty string <= 80 characters" });
|
|
291
|
+
if (/[\r\n\x00]/.test(b.label))
|
|
292
|
+
return send(res, 400, { error: "label must not contain CR/LF" });
|
|
293
|
+
sdb.prepare("UPDATE pool_keys SET label = ? WHERE id = ?").run(b.label.trim(), Number(keyId));
|
|
294
|
+
}
|
|
295
|
+
if (b.api_key !== undefined) {
|
|
296
|
+
if (typeof b.api_key !== "string" || !b.api_key.trim())
|
|
297
|
+
return send(res, 400, { error: "api_key must not be empty" });
|
|
298
|
+
if (b.api_key.trim().length > MAX_API_KEY_CHARS)
|
|
299
|
+
return send(res, 400, { error: `api_key must be <= ${MAX_API_KEY_CHARS} characters` });
|
|
300
|
+
if (/[\r\n\x00]/.test(b.api_key))
|
|
301
|
+
return send(res, 400, { error: "api_key must not contain CR/LF" });
|
|
302
|
+
sdb.prepare("UPDATE pool_keys SET api_key = ?, consec_fail = 0, cooldown_until = 0 WHERE id = ?").run(b.api_key.trim(), Number(keyId));
|
|
303
|
+
}
|
|
304
|
+
if (b.credentials !== undefined) {
|
|
305
|
+
const pool = getPool(poolId);
|
|
306
|
+
if (!pool)
|
|
307
|
+
return send(res, 404, { error: "pool not found" });
|
|
308
|
+
const norm = normalizeCredentials(pool.provider, b.credentials);
|
|
309
|
+
if (!norm.ok)
|
|
310
|
+
return send(res, 400, { error: norm.error });
|
|
311
|
+
sdb.prepare("UPDATE pool_keys SET credentials = ?, consec_fail = 0, cooldown_until = 0 WHERE id = ?").run(JSON.stringify(norm.value), Number(keyId));
|
|
312
|
+
// Keep api_key in sync when the update carries a conventional single secret.
|
|
313
|
+
const single = norm.value["apiKey"] ?? norm.value["api_key"] ?? norm.value["apiToken"] ?? norm.value["platformToken"] ?? norm.value["anonKey"];
|
|
314
|
+
if (typeof single === "string" && single)
|
|
315
|
+
sdb.prepare("UPDATE pool_keys SET api_key = ? WHERE id = ?").run(single, Number(keyId));
|
|
316
|
+
}
|
|
317
|
+
if (b.info !== undefined) {
|
|
318
|
+
if (typeof b.info !== "string")
|
|
319
|
+
return send(res, 400, { error: "info must be a string" });
|
|
320
|
+
sdb.prepare("UPDATE pool_keys SET info = ? WHERE id = ?").run(b.info.slice(0, 200), Number(keyId));
|
|
321
|
+
}
|
|
322
|
+
const row = sdb
|
|
323
|
+
.prepare("SELECT id, label, api_key, COALESCE(credentials,'{}') AS credentials, info, created_at FROM pool_keys WHERE id = ? AND pool_id = ?")
|
|
324
|
+
.get(Number(keyId), poolId);
|
|
325
|
+
send(res, 200, { id: row.id, label: row.label, masked: mask(row.api_key), maskedCredentials: maskCredentials(parseCredentials(row.credentials)), info: row.info });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (req.method === "DELETE" && keyId !== undefined) {
|
|
329
|
+
sdb.prepare("DELETE FROM usage WHERE key_id = ?").run(Number(keyId));
|
|
330
|
+
const r = sdb.prepare("DELETE FROM pool_keys WHERE id = ? AND pool_id = ?").run(Number(keyId), poolId);
|
|
331
|
+
if (r.changes === 0)
|
|
332
|
+
return send(res, 404, { error: "key not found" });
|
|
333
|
+
send(res, 200, { ok: true });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (req.method === "GET" && url.pathname.startsWith("/logos/")) {
|
|
338
|
+
const name = url.pathname.slice("/logos/".length);
|
|
339
|
+
if (!/^[a-z0-9-]+\.(svg|png)$/.test(name)) {
|
|
340
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
341
|
+
res.end("not found\n");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const buf = fs.readFileSync(path.join(publicDir(), "logos", name));
|
|
346
|
+
res.writeHead(200, {
|
|
347
|
+
"content-type": name.endsWith(".png") ? "image/png" : "image/svg+xml",
|
|
348
|
+
"cache-control": "public, max-age=3600",
|
|
349
|
+
});
|
|
350
|
+
res.end(buf);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
354
|
+
res.end("not found\n");
|
|
355
|
+
}
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (req.method === "GET" && url.pathname === "/keys") {
|
|
359
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
360
|
+
res.end(keysPage());
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (req.method === "GET" && url.pathname === "/pools") {
|
|
364
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
365
|
+
res.end(poolsPage());
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (req.method === "GET" && url.pathname === "/analytics") {
|
|
369
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
370
|
+
res.end(analyticsPage());
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (req.method === "GET" && url.pathname.startsWith("/provider/")) {
|
|
374
|
+
const pid = url.pathname.slice("/provider/".length);
|
|
375
|
+
if (!/^[a-z0-9-]+$/.test(pid)) {
|
|
376
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
377
|
+
res.end("not found\n");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
381
|
+
res.end(providerPage());
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (req.method === "GET" && url.pathname === "/playground") {
|
|
385
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
386
|
+
res.end(playgroundPage());
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (req.method === "GET" && url.pathname === "/docs") {
|
|
390
|
+
res.writeHead(302, { location: DOCS_URL });
|
|
391
|
+
res.end();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (req.method === "GET" && url.pathname === "/api/db/ping") {
|
|
395
|
+
const r = readiness();
|
|
396
|
+
send(res, r.status, r.body);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
400
|
+
const h = health();
|
|
401
|
+
res.writeHead(h.status, { "content-type": h.contentType });
|
|
402
|
+
res.end(h.body);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
406
|
+
res.end("not found\n");
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
console.error(`[error] ${req.method} ${req.url}:`, e);
|
|
410
|
+
if (res.headersSent) {
|
|
411
|
+
try {
|
|
412
|
+
res.end();
|
|
413
|
+
}
|
|
414
|
+
catch { /* already closed */ }
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const msg = e.message;
|
|
418
|
+
// Validation errors stay 400; infrastructure failures are 500 without leaking internals.
|
|
419
|
+
if (msg === "bad json" || msg === "too large" || msg === "connection closed") {
|
|
420
|
+
send(res, 400, { error: msg });
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
send(res, 500, { error: "internal error" });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
server.listen(PORT, HOST, () => {
|
|
428
|
+
console.log(`pool-anything listening on http://${HOST}:${PORT}`);
|
|
429
|
+
for (const w of startupWarnings())
|
|
430
|
+
console.warn(`[warn] ${w}`);
|
|
431
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { PROVIDERS, parseCredentials } from "../pool/index.js";
|
|
2
|
+
export function resolveTarget(pool) {
|
|
3
|
+
const p = PROVIDERS.find((x) => x.id === pool.provider);
|
|
4
|
+
const baseUrl = (pool.base_url || p?.baseUrl || "").trim();
|
|
5
|
+
const keyHeader = (pool.key_header || p?.keyHeader || "X-API-Key").trim();
|
|
6
|
+
const keyPrefix = pool.key_prefix || p?.keyPrefix || "";
|
|
7
|
+
return {
|
|
8
|
+
baseUrl,
|
|
9
|
+
keyHeader: keyHeader || "X-API-Key",
|
|
10
|
+
keyPrefix,
|
|
11
|
+
extraHeaders: substitutePlaceholders(p?.extraHeaders ?? {}, {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Per-key target: multi-field credentials can override baseUrl and
|
|
16
|
+
* extra headers (supabase url, appwrite endpoint/project). Single-field
|
|
17
|
+
* keys behave exactly like resolveTarget().
|
|
18
|
+
*/
|
|
19
|
+
export function resolveTargetForKey(pool, key) {
|
|
20
|
+
const base = resolveTarget(pool);
|
|
21
|
+
const creds = parseCredentials(typeof key.credentials === "string" ? key.credentials : "{}");
|
|
22
|
+
if (Object.keys(creds).length === 0)
|
|
23
|
+
return base;
|
|
24
|
+
const extra = { ...base.extraHeaders };
|
|
25
|
+
let baseUrl = base.baseUrl;
|
|
26
|
+
// Supabase: per-project URL wins over the "<ref>" placeholder.
|
|
27
|
+
if (creds["url"] && (pool.provider === "supabase" || baseUrl.includes("<ref>") || baseUrl.includes("<"))) {
|
|
28
|
+
baseUrl = creds["url"].replace(/\/+$/, "");
|
|
29
|
+
}
|
|
30
|
+
// Appwrite: endpoint + project id.
|
|
31
|
+
if (creds["endpoint"])
|
|
32
|
+
baseUrl = creds["endpoint"].replace(/\/+$/, "");
|
|
33
|
+
if (creds["projectId"])
|
|
34
|
+
extra["X-Appwrite-Project"] = creds["projectId"];
|
|
35
|
+
// Supabase anon key fills the Bearer placeholder.
|
|
36
|
+
if (creds["anonKey"]) {
|
|
37
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
38
|
+
if (v.includes("<anonKey>"))
|
|
39
|
+
extra[k] = v.replace("<anonKey>", creds["anonKey"]);
|
|
40
|
+
}
|
|
41
|
+
if (base.keyHeader.toLowerCase() === "apikey" && !extra["Authorization"]) {
|
|
42
|
+
extra["Authorization"] = `Bearer ${creds["anonKey"]}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ...base, baseUrl, extraHeaders: substitutePlaceholders(extra, creds) };
|
|
46
|
+
}
|
|
47
|
+
/** Effective secret material for a key: synthesized for multi-field providers. */
|
|
48
|
+
export function effectiveApiKey(pool, key) {
|
|
49
|
+
const creds = parseCredentials(typeof key.credentials === "string" ? key.credentials : "{}");
|
|
50
|
+
// Mailgun expects Basic base64(api:KEY); keys are stored raw per the hint.
|
|
51
|
+
if (pool.provider === "mailgun") {
|
|
52
|
+
const raw = (typeof creds["apiKey"] === "string" && creds["apiKey"]) ||
|
|
53
|
+
(typeof creds["api_key"] === "string" && creds["api_key"]) ||
|
|
54
|
+
key.api_key;
|
|
55
|
+
if (raw)
|
|
56
|
+
return Buffer.from(`api:${raw}`).toString("base64");
|
|
57
|
+
return key.api_key;
|
|
58
|
+
}
|
|
59
|
+
if (Object.keys(creds).length === 0)
|
|
60
|
+
return key.api_key;
|
|
61
|
+
// Twilio: Basic base64(SID:token).
|
|
62
|
+
if (creds["accountSid"] && creds["authToken"]) {
|
|
63
|
+
return Buffer.from(`${creds["accountSid"]}:${creds["authToken"]}`).toString("base64");
|
|
64
|
+
}
|
|
65
|
+
// Appwrite / Turso / generic: prefer explicit api key fields.
|
|
66
|
+
for (const f of ["apiKey", "api_key", "apiToken", "platformToken", "anonKey", "token"]) {
|
|
67
|
+
if (typeof creds[f] === "string" && creds[f])
|
|
68
|
+
return creds[f];
|
|
69
|
+
}
|
|
70
|
+
return key.api_key;
|
|
71
|
+
}
|
|
72
|
+
function substitutePlaceholders(headers, creds) {
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
75
|
+
let s = v;
|
|
76
|
+
if (s.includes("<PROJECT_ID>") && creds["projectId"])
|
|
77
|
+
s = s.replace("<PROJECT_ID>", creds["projectId"]);
|
|
78
|
+
if (s.includes("<anonKey>") && creds["anonKey"])
|
|
79
|
+
s = s.replace("<anonKey>", creds["anonKey"]);
|
|
80
|
+
out[k] = s;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
const QUERY_KEY_RE = /^[A-Za-z0-9_.~-]+$/;
|
|
85
|
+
export function validateQueryParams(q) {
|
|
86
|
+
if (q === undefined)
|
|
87
|
+
return { ok: true, value: {} };
|
|
88
|
+
if (q === null || typeof q !== "object" || Array.isArray(q))
|
|
89
|
+
return { ok: false, error: "query must be an object" };
|
|
90
|
+
const entries = Object.entries(q);
|
|
91
|
+
if (entries.length > 50)
|
|
92
|
+
return { ok: false, error: "too many query params" };
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const [k, v] of entries) {
|
|
95
|
+
if (typeof k !== "string" || !QUERY_KEY_RE.test(k) || k.length > 256)
|
|
96
|
+
return { ok: false, error: `invalid query param: ${k}` };
|
|
97
|
+
if (typeof v !== "string" || v.length > 2000 || /[\r\n\0]/.test(v))
|
|
98
|
+
return { ok: false, error: `invalid query value for ${k}` };
|
|
99
|
+
out[k] = v;
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, value: out };
|
|
102
|
+
}
|
|
103
|
+
/** Pure builder: returns exact URL + headers the proxy will send. No network. */
|
|
104
|
+
export function buildUpstreamRequest(target, apiKey, opts) {
|
|
105
|
+
let p = opts.path || "/";
|
|
106
|
+
if (!p.startsWith("/"))
|
|
107
|
+
p = "/" + p;
|
|
108
|
+
// Provider defaults first, then caller headers — but the auth header is
|
|
109
|
+
// always re-injected afterwards so a caller cannot override or duplicate it.
|
|
110
|
+
const headers = { "content-type": "application/json", ...target.extraHeaders, ...(opts.headers ?? {}) };
|
|
111
|
+
const qv = validateQueryParams(opts.query);
|
|
112
|
+
if (!qv.ok)
|
|
113
|
+
throw new Error(qv.error);
|
|
114
|
+
for (const [k, v] of Object.entries(qv.value)) {
|
|
115
|
+
p += `${p.includes("?") ? "&" : "?"}${encodeURIComponent(k)}=${encodeURIComponent(v)}`;
|
|
116
|
+
}
|
|
117
|
+
if (target.keyHeader.startsWith("query:")) {
|
|
118
|
+
const name = target.keyHeader.slice("query:".length);
|
|
119
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name))
|
|
120
|
+
throw new Error("keyHeader invalid");
|
|
121
|
+
const sep = p.includes("?") ? "&" : "?";
|
|
122
|
+
p += `${sep}${name}=${encodeURIComponent(apiKey)}`;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Remove any caller-supplied header that case-insensitively matches the
|
|
126
|
+
// auth header: HTTP is case-insensitive but JS keys are not, so leaving
|
|
127
|
+
// both would send two auth headers and the upstream may prefer the attacker's.
|
|
128
|
+
const want = target.keyHeader.toLowerCase();
|
|
129
|
+
for (const k of Object.keys(headers)) {
|
|
130
|
+
if (k.toLowerCase() === want)
|
|
131
|
+
delete headers[k];
|
|
132
|
+
}
|
|
133
|
+
headers[target.keyHeader] = target.keyPrefix + apiKey;
|
|
134
|
+
}
|
|
135
|
+
const base = target.baseUrl.endsWith("/") ? target.baseUrl.slice(0, -1) : target.baseUrl;
|
|
136
|
+
return { url: base + p, headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body) };
|
|
137
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pool-anything",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gather free-tier API keys from any provider into one pool and rotate through them — round-robin key selection, usage/quota tracking, and a drop-in HTTP proxy with a built-in web UI. Zero dependencies, TypeScript + SQLite.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"api-key-pool",
|
|
7
|
+
"key-rotation",
|
|
8
|
+
"api-proxy",
|
|
9
|
+
"round-robin",
|
|
10
|
+
"free-tier",
|
|
11
|
+
"rate-limit",
|
|
12
|
+
"llm-gateway",
|
|
13
|
+
"api-gateway",
|
|
14
|
+
"usage-tracking",
|
|
15
|
+
"quota",
|
|
16
|
+
"sqlite",
|
|
17
|
+
"typescript",
|
|
18
|
+
"zero-dependency",
|
|
19
|
+
"groq",
|
|
20
|
+
"openrouter",
|
|
21
|
+
"gemini",
|
|
22
|
+
"anthropic",
|
|
23
|
+
"openai",
|
|
24
|
+
"self-hosted"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://github.com/Parithosh-Varma/pool-anything#readme",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Parithosh-Varma/pool-anything.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/Parithosh-Varma/pool-anything/issues"
|
|
33
|
+
},
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"author": "Parithosh Varma",
|
|
36
|
+
"type": "module",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22.5.0"
|
|
39
|
+
},
|
|
40
|
+
"bin": {
|
|
41
|
+
"pool-anything": "bin/pool-anything.js"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist/",
|
|
45
|
+
"bin/",
|
|
46
|
+
"data/providers.json",
|
|
47
|
+
"public/logos/",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENSE"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"dev": "tsx watch src/server.ts",
|
|
53
|
+
"start": "tsx src/server.ts",
|
|
54
|
+
"build": "tsc && node scripts/copy-assets.mjs",
|
|
55
|
+
"prepublishOnly": "npm run build",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"db:init": "tsx src/db/init.ts",
|
|
58
|
+
"export:tools": "tsx scripts/export-tools-ui.ts",
|
|
59
|
+
"tools:daemon": "tsx scripts/tools-watch.ts",
|
|
60
|
+
"test": "tsx --test \"tests/**/*.test.ts\"",
|
|
61
|
+
"start:dist": "node dist/server.js"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@types/node": "^22.7.4",
|
|
65
|
+
"tsx": "^4.19.1",
|
|
66
|
+
"typescript": "^5.6.2"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anyscale</title><path d="M17.583 12.344L14.606 17.5H20.6c.22 0 .424-.117.535-.308l2.799-4.848h-6.351zM23.934 11.656l-2.799-4.848A.616.616 0 0020.6 6.5h-5.994l2.977 5.156h6.35zM8.653 6.5h5.953l-2.997-5.191A.616.616 0 0011.074 1H5.476l3.176 5.5zM4.881 1.343L2.083 6.191a.618.618 0 000 .617l2.997 5.191 2.976-5.156-3.175-5.5zM8.057 17.155L5.081 12l-2.998 5.192a.618.618 0 000 .617l2.798 4.848 3.175-5.5h.001zM5.476 23h5.598c.22 0 .424-.117.535-.308l2.997-5.192H8.653L5.477 23z"></path></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="#FD366E" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Appwrite</title><path d="M24 17.291v5.29H10.557A10.58 10.58 0 0 1 0 12.715v-1.43c.048-.735.174-1.463.374-2.171C1.63 4.673 5.713 1.419 10.557 1.419c4.844 0 8.927 3.254 10.183 7.695h-5.749a5.283 5.283 0 0 0-4.434-2.404 5.282 5.282 0 0 0-4.434 2.404A5.23 5.23 0 0 0 5.267 12a5.27 5.27 0 0 0 1.66 3.848 5.27 5.27 0 0 0 3.63 1.443H24Zm0-6.734v5.291h-9.813A5.276 5.276 0 0 0 15.848 12c0-.5-.07-.984-.199-1.443H24Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Brave</title><path d="M17.544 2.375c.017-.005 1.844-.5 2.712.361.872.872 1.588 1.642 1.588 1.642l-.565 1.38v-.003.006-.003L22 7.8c-.014.05-2.112 7.983-2.357 8.954-.488 1.924-.819 2.663-2.202 3.638a212.634 212.634 0 01-4.305 2.917c-.41.252-.92.691-1.383.691-.463 0-.974-.439-1.383-.691a213.099 213.099 0 01-4.306-2.917c-1.383-.975-1.72-1.714-2.2-3.632-.246-.977-2.35-8.904-2.364-8.96l.722-2.045-.566-1.383s.722-.764 1.594-1.63c.866-.872 2.712-.36 2.712-.36L8.066 0h7.373l2.105 2.375zm-5.797 12.557c-.138 0-1.04.318-1.762.691l-.457.234c-.487.253-.823.428-.956.506-.168.108-.066.306.09.414.15.103 2.195 1.684 2.394 1.865l.09.078c.186.168.432.391.607.391.174 0 .415-.223.607-.391l.084-.078c.2-.169 2.244-1.756 2.394-1.865.15-.108.258-.3.09-.408-.133-.084-.475-.253-.956-.506h-.006l-.457-.24c-.722-.373-1.623-.691-1.762-.691zm.006-11.276c-.35.02-.694.092-1.023.211l-.378.126c-.493.169-.969.331-1.21.331-.312 0-2.554-.428-2.584-.433 0 0-2.706 3.26-2.706 3.957 0 .577.228.805.504 1.07l.174.175 2.033 2.152.06.067c.204.204.5.498.29.998l-.043.102c-.228.535-.511 1.203-.15 1.876.384.716 1.046 1.19 1.467 1.118.42-.084 1.419-.601 1.78-.841.367-.229 1.52-1.19 1.521-1.551 0-.307-.829-.812-1.238-1.053l-.18-.12-.199-.12c-.367-.229-1.035-.644-1.047-.825-.018-.228-.017-.294.283-.853l.21-.379c.289-.487.602-1.029.536-1.426-.085-.433-.777-.685-1.36-.901l-.21-.078-.613-.229c-.583-.222-1.232-.463-1.34-.511-.145-.073-.11-.132.335-.174l.223-.025c.553-.06 1.582-.168 2.08-.03l.32.09c.564.145 1.25.337 1.316.445l.03.048c.067.09.109.145.037.53l-.121.607c-.15.806-.391 2.069-.421 2.351l-.012.115c-.042.312-.066.529.301.613.438.119.884.206 1.335.259.216 0 .824-.144 1.24-.24l.095-.025c.367-.078.343-.289.3-.602l-.011-.12c-.03-.282-.27-1.54-.42-2.345l-.122-.614c-.072-.384-.024-.439.036-.529l.03-.048c.067-.108.753-.294 1.318-.444l.318-.091c.5-.138 1.528-.03 2.081.03l.216.018c.451.048.493.108.343.18-.11.049-.758.29-1.341.512-.273.108-.547.21-.823.307-.583.216-1.275.468-1.36.907-.066.391.247.939.535 1.42l.21.379c.301.56.308.625.284.854-.012.18-.68.595-1.053.824l-.192.126-.181.108c-.41.247-1.238.758-1.238 1.059 0 .367 1.16 1.316 1.521 1.55.367.235 1.36.758 1.78.836.421.078 1.082-.396 1.467-1.112.36-.673.078-1.335-.15-1.876l-.042-.102c-.21-.5.084-.794.289-1.004l.065-.06 2.02-2.147.181-.181c.271-.265.505-.493.505-1.07 0-.698-2.706-3.957-2.706-3.957-.03.006-2.275.44-2.586.44l.007-.007c-.252 0-.722-.156-1.215-.337l-.379-.12c-.612-.21-1.02-.21-1.022-.21z"></path></svg>
|