ldrouter 1.5.1
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 +49 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/cli.js +13 -0
- package/dist/server/app.js +138 -0
- package/dist/server/auth/api-key.js +75 -0
- package/dist/server/auth/crypto.js +94 -0
- package/dist/server/auth/ids.js +40 -0
- package/dist/server/auth/middleware.js +36 -0
- package/dist/server/auth/recovery.js +11 -0
- package/dist/server/caching/store.js +119 -0
- package/dist/server/config/index.js +96 -0
- package/dist/server/db/index.js +64 -0
- package/dist/server/db/migrate.js +408 -0
- package/dist/server/db/repositories/audit.js +75 -0
- package/dist/server/db/repositories/settings.js +63 -0
- package/dist/server/db/schema.js +396 -0
- package/dist/server/errors.js +65 -0
- package/dist/server/gateway/runner.js +745 -0
- package/dist/server/logging/logger.js +35 -0
- package/dist/server/maintenance/retention.js +48 -0
- package/dist/server/metrics/registry.js +169 -0
- package/dist/server/protocols/anthropic.js +154 -0
- package/dist/server/protocols/canonical.js +201 -0
- package/dist/server/providers/index.js +89 -0
- package/dist/server/routes/admin/aliases.js +98 -0
- package/dist/server/routes/admin/api-keys.js +194 -0
- package/dist/server/routes/admin/audit.js +19 -0
- package/dist/server/routes/admin/auth.js +124 -0
- package/dist/server/routes/admin/backup.js +113 -0
- package/dist/server/routes/admin/combos.js +198 -0
- package/dist/server/routes/admin/dashboard.js +55 -0
- package/dist/server/routes/admin/models.js +178 -0
- package/dist/server/routes/admin/providers.js +212 -0
- package/dist/server/routes/admin/requests.js +156 -0
- package/dist/server/routes/admin/settings.js +197 -0
- package/dist/server/routes/admin/setup.js +80 -0
- package/dist/server/routes/admin/stats.js +180 -0
- package/dist/server/routes/admin.js +39 -0
- package/dist/server/routes/gateway/anthropic.js +112 -0
- package/dist/server/routes/gateway/openai.js +257 -0
- package/dist/server/routes/gateway.js +7 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routing/capabilities.js +52 -0
- package/dist/server/routing/circuit.js +37 -0
- package/dist/server/routing/combo.js +100 -0
- package/dist/server/routing/quota.js +51 -0
- package/dist/server/routing/ratelimit.js +58 -0
- package/dist/server/routing/resolver.js +43 -0
- package/dist/server/security/redact.js +111 -0
- package/dist/server/selfupdate/index.js +154 -0
- package/dist/server/upstream/client.js +179 -0
- package/dist/server/util/cidr.js +91 -0
- package/dist/server/util/client-ip.js +15 -0
- package/dist/server/util/stable-json.js +19 -0
- package/dist/shared/types.js +2 -0
- package/dist/web/assets/index-COSbvF8Z.css +1 -0
- package/dist/web/assets/index-DbnEzuxq.js +251 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -0
- package/dist/web/logo.png +0 -0
- package/migrations/0001_initial_schema.sql +323 -0
- package/migrations/0002_source_api_key_secrets.sql +7 -0
- package/package.json +117 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Gateway response cache: SQLite-backed exact-key cache.
|
|
2
|
+
// Disabled by default. Per-key/per-target controls. Stream-bypassed.
|
|
3
|
+
import { and, eq, sql, lt } from 'drizzle-orm';
|
|
4
|
+
import { getDb, schema } from '../db/index.js';
|
|
5
|
+
import { sha256Hex } from '../auth/ids.js';
|
|
6
|
+
import { getSettings } from '../db/repositories/settings.js';
|
|
7
|
+
import { stableStringify } from '../util/stable-json.js';
|
|
8
|
+
export function cacheAllowed(opts) {
|
|
9
|
+
if (!opts.globalEnabled)
|
|
10
|
+
return false;
|
|
11
|
+
if (opts.streaming)
|
|
12
|
+
return false;
|
|
13
|
+
if (opts.keyAllowed === false)
|
|
14
|
+
return false;
|
|
15
|
+
if (opts.targetAllowed === false)
|
|
16
|
+
return false;
|
|
17
|
+
// Both unspecified or true => allowed
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
export function buildCacheKey(input) {
|
|
21
|
+
const material = JSON.stringify({
|
|
22
|
+
p: input.protocol,
|
|
23
|
+
k: input.resolvedTargetKind,
|
|
24
|
+
id: input.resolvedTargetId,
|
|
25
|
+
v: input.configVersion,
|
|
26
|
+
r: stableStringify(input.canonicalRequest),
|
|
27
|
+
});
|
|
28
|
+
return sha256Hex(material);
|
|
29
|
+
}
|
|
30
|
+
export function lookupCache(cacheKey) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
const row = db.select().from(schema.responseCache).where(eq(schema.responseCache.cacheKey, cacheKey)).get();
|
|
33
|
+
if (!row)
|
|
34
|
+
return { hit: false, payload: null, usage: null };
|
|
35
|
+
if (new Date(row.expiresAt).getTime() < Date.now()) {
|
|
36
|
+
db.delete(schema.responseCache).where(eq(schema.responseCache.id, row.id)).run();
|
|
37
|
+
return { hit: false, payload: null, usage: null };
|
|
38
|
+
}
|
|
39
|
+
db.update(schema.responseCache)
|
|
40
|
+
.set({ hitCount: row.hitCount + 1, lastHitAt: new Date().toISOString() })
|
|
41
|
+
.where(eq(schema.responseCache.id, row.id))
|
|
42
|
+
.run();
|
|
43
|
+
let payload;
|
|
44
|
+
try {
|
|
45
|
+
payload = JSON.parse(row.responseJson);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { hit: false, payload: null, usage: null };
|
|
49
|
+
}
|
|
50
|
+
let usage = null;
|
|
51
|
+
if (row.usageJson) {
|
|
52
|
+
try {
|
|
53
|
+
usage = JSON.parse(row.usageJson);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
usage = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { hit: true, payload, usage };
|
|
60
|
+
}
|
|
61
|
+
export function storeCache(input) {
|
|
62
|
+
const db = getDb();
|
|
63
|
+
const responseJson = JSON.stringify(input.payload);
|
|
64
|
+
const usageJson = input.usage ? JSON.stringify(input.usage) : null;
|
|
65
|
+
const bytes = Buffer.byteLength(responseJson, 'utf8');
|
|
66
|
+
const expiresAt = new Date(Date.now() + input.ttlSeconds * 1000).toISOString();
|
|
67
|
+
// Enforce max size
|
|
68
|
+
enforceSizeBudget();
|
|
69
|
+
db.insert(schema.responseCache).values({
|
|
70
|
+
id: crypto.randomUUID(),
|
|
71
|
+
cacheKey: input.cacheKey,
|
|
72
|
+
targetKind: input.targetKind,
|
|
73
|
+
targetId: input.targetId,
|
|
74
|
+
targetConfigVersion: input.configVersion,
|
|
75
|
+
protocol: input.protocol,
|
|
76
|
+
responseJson,
|
|
77
|
+
usageJson,
|
|
78
|
+
expiresAt,
|
|
79
|
+
hitCount: 0,
|
|
80
|
+
bytes,
|
|
81
|
+
}).run();
|
|
82
|
+
}
|
|
83
|
+
export function invalidateCacheFor(kind, id) {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
db.delete(schema.responseCache).where(and(eq(schema.responseCache.targetKind, kind), eq(schema.responseCache.targetId, id))).run();
|
|
86
|
+
}
|
|
87
|
+
export function clearExpired() {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const r = db.delete(schema.responseCache).where(lt(schema.responseCache.expiresAt, new Date().toISOString())).run();
|
|
90
|
+
return r.changes ?? 0;
|
|
91
|
+
}
|
|
92
|
+
export function clearAllCache() {
|
|
93
|
+
const db = getDb();
|
|
94
|
+
const r = db.delete(schema.responseCache).run();
|
|
95
|
+
return r.changes ?? 0;
|
|
96
|
+
}
|
|
97
|
+
function enforceSizeBudget() {
|
|
98
|
+
const s = getSettings();
|
|
99
|
+
const db = getDb();
|
|
100
|
+
const row = db.select({ total: sql `COALESCE(SUM(bytes), 0)` }).from(schema.responseCache).get();
|
|
101
|
+
const total = Number(row?.total ?? 0);
|
|
102
|
+
const limit = s.gatewayCacheMaxSizeMb * 1024 * 1024;
|
|
103
|
+
if (total < limit)
|
|
104
|
+
return;
|
|
105
|
+
// Evict oldest 10% by createdAt
|
|
106
|
+
const tenPct = Math.max(1, Math.floor((db.select({ c: sql `COUNT(*)` }).from(schema.responseCache).get()?.c ?? 1) * 0.1));
|
|
107
|
+
const toEvict = db
|
|
108
|
+
.select({ id: schema.responseCache.id })
|
|
109
|
+
.from(schema.responseCache)
|
|
110
|
+
.orderBy(schema.responseCache.createdAt)
|
|
111
|
+
.limit(tenPct)
|
|
112
|
+
.all();
|
|
113
|
+
if (toEvict.length === 0)
|
|
114
|
+
return;
|
|
115
|
+
for (const e of toEvict) {
|
|
116
|
+
db.delete(schema.responseCache).where(eq(schema.responseCache.id, e.id)).run();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
import crypto from 'node:crypto';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
const EnvSchema = z.object({
|
|
6
|
+
LATEDEV_HOST: z.string().default('0.0.0.0'),
|
|
7
|
+
// Port 0 is valid: it means "OS-assigned random port" (used by tests and dev).
|
|
8
|
+
LATEDEV_PORT: z.coerce.number().int().min(0).max(65535).default(8787),
|
|
9
|
+
LATEDEV_DATA_DIR: z.string().optional(),
|
|
10
|
+
LATEDEV_MASTER_KEY: z.string().optional(),
|
|
11
|
+
LATEDEV_TRUST_PROXY: z.coerce.number().int().min(0).max(8).default(0),
|
|
12
|
+
LATEDEV_LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']).default('info'),
|
|
13
|
+
LATEDEV_DB_URL: z.string().optional(),
|
|
14
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
|
15
|
+
});
|
|
16
|
+
let cached = null;
|
|
17
|
+
/** Empty-string env vars (e.g. `LATEDEV_MASTER_KEY:` in docker-compose) must not
|
|
18
|
+
* shadow real defaults — treat them as unset. */
|
|
19
|
+
function normalizeEnv(env) {
|
|
20
|
+
const out = { ...env };
|
|
21
|
+
for (const k of Object.keys(out)) {
|
|
22
|
+
if (out[k] === '')
|
|
23
|
+
delete out[k];
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function readMasterKeyFile(dataDir) {
|
|
28
|
+
try {
|
|
29
|
+
const p = path.join(dataDir, 'master.key');
|
|
30
|
+
if (fs.existsSync(p)) {
|
|
31
|
+
return fs.readFileSync(p, 'utf8').trim();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* ignore unreadable file — treat as absent */
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export function loadConfig(env = process.env, argv = process.argv) {
|
|
40
|
+
if (cached)
|
|
41
|
+
return cached;
|
|
42
|
+
const parsed = EnvSchema.parse(normalizeEnv(env));
|
|
43
|
+
const cliArgs = parseArgs(argv);
|
|
44
|
+
const dataDir = cliArgs.dataDir ??
|
|
45
|
+
parsed.LATEDEV_DATA_DIR ??
|
|
46
|
+
(parsed.NODE_ENV === 'test' ? path.resolve('../.tmp/test-data') : path.resolve(os.homedir(), '.latedev-router'));
|
|
47
|
+
const isContainer = Boolean(env.LATEDEV_DATA_DIR?.startsWith('/data') || process.env.CONTAINER === '1');
|
|
48
|
+
const dbFile = parsed.LATEDEV_DB_URL ?? path.join(dataDir, 'data.sqlite');
|
|
49
|
+
cached = {
|
|
50
|
+
host: cliArgs.host ?? parsed.LATEDEV_HOST,
|
|
51
|
+
port: cliArgs.port ?? parsed.LATEDEV_PORT,
|
|
52
|
+
dataDir,
|
|
53
|
+
dbFile,
|
|
54
|
+
masterKey: parsed.LATEDEV_MASTER_KEY ?? readMasterKeyFile(dataDir),
|
|
55
|
+
trustProxyHops: parsed.LATEDEV_TRUST_PROXY,
|
|
56
|
+
logLevel: parsed.LATEDEV_LOG_LEVEL,
|
|
57
|
+
env: parsed.NODE_ENV,
|
|
58
|
+
isContainer,
|
|
59
|
+
appVersion: process.env.npm_package_version ?? '0.1.0',
|
|
60
|
+
};
|
|
61
|
+
return cached;
|
|
62
|
+
}
|
|
63
|
+
export function resetConfigForTests() {
|
|
64
|
+
cached = null;
|
|
65
|
+
}
|
|
66
|
+
export function setConfigMasterKey(key) {
|
|
67
|
+
process.env.LATEDEV_MASTER_KEY = key; // future loadConfig reads pick it up
|
|
68
|
+
if (cached) {
|
|
69
|
+
cached.masterKey = key;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function parseArgs(argv) {
|
|
73
|
+
const out = {};
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
const a = argv[i];
|
|
76
|
+
if (a === '--host' && argv[i + 1]) {
|
|
77
|
+
out.host = argv[++i];
|
|
78
|
+
}
|
|
79
|
+
else if (a?.startsWith('--host=')) {
|
|
80
|
+
out.host = a.split('=')[1];
|
|
81
|
+
}
|
|
82
|
+
else if (a === '--port' && argv[i + 1]) {
|
|
83
|
+
out.port = Number(argv[++i]);
|
|
84
|
+
}
|
|
85
|
+
else if (a?.startsWith('--port=')) {
|
|
86
|
+
out.port = Number(a.split('=')[1]);
|
|
87
|
+
}
|
|
88
|
+
else if (a === '--data-dir' && argv[i + 1]) {
|
|
89
|
+
out.dataDir = argv[++i];
|
|
90
|
+
}
|
|
91
|
+
else if (a?.startsWith('--data-dir=')) {
|
|
92
|
+
out.dataDir = a.split('=')[1];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Database bootstrap: opens SQLite WAL, applies pending migrations, returns a Drizzle DB handle.
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import * as schema from './schema.js';
|
|
8
|
+
import { getLogger } from '../logging/logger.js';
|
|
9
|
+
import { runMigrations } from './migrate.js';
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
/** Locate the migrations directory across the possible layouts:
|
|
12
|
+
* - Docker dist: dist/server/db → ../../migrations (dist/migrations)
|
|
13
|
+
* - npm package dist: dist/server/db → ../../../migrations (<pkg>/migrations)
|
|
14
|
+
* - source tree (dev/test): src/server/db → ../../../migrations (repo/migrations)
|
|
15
|
+
*/
|
|
16
|
+
function resolveMigrationsDir() {
|
|
17
|
+
const distDir = path.resolve(__dirname, '../../migrations');
|
|
18
|
+
const rootDir = path.resolve(__dirname, '../../../migrations');
|
|
19
|
+
if (fs.existsSync(distDir))
|
|
20
|
+
return distDir;
|
|
21
|
+
if (fs.existsSync(rootDir))
|
|
22
|
+
return rootDir;
|
|
23
|
+
return distDir; // let runMigrations log the missing-dir fallback
|
|
24
|
+
}
|
|
25
|
+
let _db = null;
|
|
26
|
+
let _raw = null;
|
|
27
|
+
export function openDb(dbFile) {
|
|
28
|
+
if (_db && _raw)
|
|
29
|
+
return { db: _db, raw: _raw };
|
|
30
|
+
const dir = path.dirname(dbFile);
|
|
31
|
+
if (dir && !fs.existsSync(dir))
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const raw = new Database(dbFile);
|
|
34
|
+
// Pragmas for WAL + safety
|
|
35
|
+
raw.pragma('journal_mode = WAL');
|
|
36
|
+
raw.pragma('synchronous = NORMAL');
|
|
37
|
+
raw.pragma('foreign_keys = ON');
|
|
38
|
+
raw.pragma('busy_timeout = 5000');
|
|
39
|
+
raw.pragma('temp_store = MEMORY');
|
|
40
|
+
raw.pragma('cache_size = -20000');
|
|
41
|
+
const db = drizzle(raw, { schema });
|
|
42
|
+
_raw = raw;
|
|
43
|
+
_db = db;
|
|
44
|
+
runMigrations(raw, getLogger(), resolveMigrationsDir());
|
|
45
|
+
return { db, raw };
|
|
46
|
+
}
|
|
47
|
+
export function closeDb() {
|
|
48
|
+
if (_raw) {
|
|
49
|
+
try {
|
|
50
|
+
_raw.close();
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
getLogger().warn({ err: String(e) }, 'failed to close db');
|
|
54
|
+
}
|
|
55
|
+
_raw = null;
|
|
56
|
+
_db = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function getDb() {
|
|
60
|
+
if (!_db)
|
|
61
|
+
throw new Error('Database not opened');
|
|
62
|
+
return _db;
|
|
63
|
+
}
|
|
64
|
+
export { schema };
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// Migration runner for LateDev Router.
|
|
2
|
+
// Migrations are raw SQL files in /migrations, applied in version order.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
export function runMigrations(raw, log, migrationsDir) {
|
|
7
|
+
// Ensure migration tracking table
|
|
8
|
+
raw.exec(`
|
|
9
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
10
|
+
version INTEGER PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
13
|
+
);
|
|
14
|
+
`);
|
|
15
|
+
const applied = new Set(raw.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version));
|
|
16
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
17
|
+
log.warn({ migrationsDir }, 'migrations directory missing — assuming fresh schema bootstrap');
|
|
18
|
+
// Create the table schema directly as a single bootstrap migration.
|
|
19
|
+
const bootstrap = generateBootstrapMigration();
|
|
20
|
+
raw.exec(bootstrap.sql);
|
|
21
|
+
raw.prepare('INSERT INTO schema_migrations (version, name) VALUES (?, ?)').run(bootstrap.version, bootstrap.name);
|
|
22
|
+
ensureSettingsRow(raw, log);
|
|
23
|
+
log.info({ version: bootstrap.version }, 'applied bootstrap migration');
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const files = fs
|
|
27
|
+
.readdirSync(migrationsDir)
|
|
28
|
+
.filter((f) => /^\d{4}_.+\.sql$/.test(f))
|
|
29
|
+
.sort();
|
|
30
|
+
const parsed = files.map((f) => {
|
|
31
|
+
const full = path.join(migrationsDir, f);
|
|
32
|
+
const sql = fs.readFileSync(full, 'utf8');
|
|
33
|
+
const ver = parseInt(f.split('_')[0] ?? '0', 10);
|
|
34
|
+
return { version: ver, name: f.replace(/^\d+_/, '').replace(/\.sql$/, ''), sql };
|
|
35
|
+
});
|
|
36
|
+
// Always ensure a version 1 bootstrap exists on fresh databases
|
|
37
|
+
if (!parsed.some((m) => m.version === 1) && applied.size === 0) {
|
|
38
|
+
const bootstrap = generateBootstrapMigration();
|
|
39
|
+
parsed.unshift(bootstrap);
|
|
40
|
+
}
|
|
41
|
+
for (const m of parsed) {
|
|
42
|
+
if (applied.has(m.version))
|
|
43
|
+
continue;
|
|
44
|
+
log.info({ version: m.version, name: m.name }, 'applying migration');
|
|
45
|
+
const tx = raw.transaction(() => {
|
|
46
|
+
raw.exec(m.sql);
|
|
47
|
+
raw.prepare('INSERT INTO schema_migrations (version, name) VALUES (?, ?)').run(m.version, m.name);
|
|
48
|
+
});
|
|
49
|
+
tx();
|
|
50
|
+
}
|
|
51
|
+
ensureSettingsRow(raw, log);
|
|
52
|
+
}
|
|
53
|
+
function ensureSettingsRow(raw, log) {
|
|
54
|
+
const row = raw.prepare('SELECT id FROM app_settings WHERE id = 1').get();
|
|
55
|
+
if (!row) {
|
|
56
|
+
raw.prepare('INSERT INTO app_settings (id) VALUES (1)').run();
|
|
57
|
+
log.info('initialized app_settings row');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function generateBootstrapMigration() {
|
|
61
|
+
// The complete bootstrap SQL. Version 1 contains the entire initial schema;
|
|
62
|
+
// subsequent migrations (0002+) are loaded from the migrations folder and
|
|
63
|
+
// applied on top. Keeping this at version 1 (not SCHEMA_VERSION) matters:
|
|
64
|
+
// file migrations use the same 1-based numbering, so a bootstrap stamped
|
|
65
|
+
// with a higher version would block the real file migrations.
|
|
66
|
+
const schemaSql = buildInitialSchemaSql();
|
|
67
|
+
return {
|
|
68
|
+
version: 1,
|
|
69
|
+
name: 'initial_schema',
|
|
70
|
+
sql: schemaSql,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function buildInitialSchemaSql() {
|
|
74
|
+
// The full DDL — every table, index, and pragma setting we need.
|
|
75
|
+
return `
|
|
76
|
+
CREATE TABLE IF NOT EXISTS app_settings (
|
|
77
|
+
id INTEGER PRIMARY KEY,
|
|
78
|
+
setup_complete INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
retention_days INTEGER NOT NULL DEFAULT 30,
|
|
80
|
+
content_log_mode TEXT NOT NULL DEFAULT 'metadata',
|
|
81
|
+
db_size_limit_mb INTEGER NOT NULL DEFAULT 2048,
|
|
82
|
+
trust_proxy_hops INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
schema_version INTEGER NOT NULL DEFAULT 0,
|
|
84
|
+
app_version TEXT NOT NULL DEFAULT '0.0.0',
|
|
85
|
+
gateway_cache_enabled INTEGER NOT NULL DEFAULT 0,
|
|
86
|
+
gateway_cache_default_ttl_seconds INTEGER NOT NULL DEFAULT 300,
|
|
87
|
+
gateway_cache_max_size_mb INTEGER NOT NULL DEFAULT 256,
|
|
88
|
+
master_key_version INTEGER NOT NULL DEFAULT 1,
|
|
89
|
+
master_key_configured INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE IF NOT EXISTS admin_account (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
username TEXT NOT NULL UNIQUE,
|
|
96
|
+
password_hash TEXT NOT NULL,
|
|
97
|
+
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
|
98
|
+
totp_secret_encrypted TEXT,
|
|
99
|
+
totp_secret_nonce TEXT,
|
|
100
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
101
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
102
|
+
last_login_at TEXT
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
CREATE TABLE IF NOT EXISTS admin_recovery_codes (
|
|
106
|
+
id TEXT PRIMARY KEY,
|
|
107
|
+
admin_id TEXT NOT NULL REFERENCES admin_account(id) ON DELETE CASCADE,
|
|
108
|
+
code_hash TEXT NOT NULL,
|
|
109
|
+
used_at TEXT,
|
|
110
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_recovery_admin ON admin_recovery_codes(admin_id);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS admin_sessions (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
token_digest TEXT NOT NULL UNIQUE,
|
|
117
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
118
|
+
expires_at TEXT NOT NULL,
|
|
119
|
+
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
120
|
+
ip TEXT,
|
|
121
|
+
user_agent TEXT
|
|
122
|
+
);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_session_expires ON admin_sessions(expires_at);
|
|
124
|
+
|
|
125
|
+
CREATE TABLE IF NOT EXISTS login_attempts (
|
|
126
|
+
id TEXT PRIMARY KEY,
|
|
127
|
+
username TEXT NOT NULL,
|
|
128
|
+
ip TEXT NOT NULL,
|
|
129
|
+
success INTEGER NOT NULL,
|
|
130
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
131
|
+
);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_login_user_time ON login_attempts(username, created_at);
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_login_ip_time ON login_attempts(ip, created_at);
|
|
134
|
+
|
|
135
|
+
CREATE TABLE IF NOT EXISTS providers (
|
|
136
|
+
id TEXT PRIMARY KEY,
|
|
137
|
+
name TEXT NOT NULL,
|
|
138
|
+
slug TEXT NOT NULL UNIQUE,
|
|
139
|
+
type TEXT NOT NULL CHECK (type IN ('openai','anthropic')),
|
|
140
|
+
base_url TEXT NOT NULL,
|
|
141
|
+
encrypted_api_key TEXT NOT NULL,
|
|
142
|
+
api_key_nonce TEXT NOT NULL,
|
|
143
|
+
api_key_version INTEGER NOT NULL DEFAULT 1,
|
|
144
|
+
custom_headers_encrypted TEXT,
|
|
145
|
+
custom_headers_nonce TEXT,
|
|
146
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
147
|
+
connect_timeout_ms INTEGER NOT NULL DEFAULT 10000,
|
|
148
|
+
first_token_timeout_ms INTEGER NOT NULL DEFAULT 30000,
|
|
149
|
+
stream_idle_timeout_ms INTEGER NOT NULL DEFAULT 60000,
|
|
150
|
+
total_timeout_ms INTEGER NOT NULL DEFAULT 180000,
|
|
151
|
+
max_retries INTEGER NOT NULL DEFAULT 2,
|
|
152
|
+
retry_base_ms INTEGER NOT NULL DEFAULT 500,
|
|
153
|
+
retry_max_ms INTEGER NOT NULL DEFAULT 8000,
|
|
154
|
+
cb_failure_threshold INTEGER NOT NULL DEFAULT 5,
|
|
155
|
+
cb_cooldown_seconds INTEGER NOT NULL DEFAULT 60,
|
|
156
|
+
health_state TEXT NOT NULL DEFAULT 'unknown' CHECK (health_state IN ('healthy','degraded','down','circuit_open','unknown')),
|
|
157
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
158
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
CREATE TABLE IF NOT EXISTS models (
|
|
162
|
+
id TEXT PRIMARY KEY,
|
|
163
|
+
provider_id TEXT NOT NULL REFERENCES providers(id) ON DELETE RESTRICT,
|
|
164
|
+
upstream_model_id TEXT NOT NULL,
|
|
165
|
+
public_model_id TEXT NOT NULL UNIQUE,
|
|
166
|
+
display_name TEXT NOT NULL,
|
|
167
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
168
|
+
upstream_available INTEGER NOT NULL DEFAULT 1,
|
|
169
|
+
capabilities_json TEXT NOT NULL DEFAULT '{}',
|
|
170
|
+
max_context_tokens INTEGER,
|
|
171
|
+
max_output_tokens INTEGER,
|
|
172
|
+
discovered_metadata_json TEXT,
|
|
173
|
+
cache_override_enabled INTEGER,
|
|
174
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
175
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
176
|
+
last_seen_upstream_at TEXT
|
|
177
|
+
);
|
|
178
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_model_public ON models(public_model_id);
|
|
179
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_provider_upstream ON models(provider_id, upstream_model_id);
|
|
180
|
+
CREATE INDEX IF NOT EXISTS idx_model_enabled ON models(enabled);
|
|
181
|
+
|
|
182
|
+
CREATE TABLE IF NOT EXISTS combos (
|
|
183
|
+
id TEXT PRIMARY KEY,
|
|
184
|
+
name TEXT NOT NULL,
|
|
185
|
+
slug TEXT NOT NULL UNIQUE,
|
|
186
|
+
public_model_id TEXT NOT NULL UNIQUE,
|
|
187
|
+
mode TEXT NOT NULL CHECK (mode IN ('fallback','weighted_round_robin')),
|
|
188
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
189
|
+
max_total_attempts INTEGER NOT NULL DEFAULT 3,
|
|
190
|
+
fallback_on_connection INTEGER NOT NULL DEFAULT 1,
|
|
191
|
+
fallback_on_connect_timeout INTEGER NOT NULL DEFAULT 1,
|
|
192
|
+
fallback_on_first_token_timeout INTEGER NOT NULL DEFAULT 1,
|
|
193
|
+
fallback_on_408 INTEGER NOT NULL DEFAULT 1,
|
|
194
|
+
fallback_on_429 INTEGER NOT NULL DEFAULT 1,
|
|
195
|
+
fallback_on_5xx INTEGER NOT NULL DEFAULT 1,
|
|
196
|
+
cache_override_enabled INTEGER,
|
|
197
|
+
config_version INTEGER NOT NULL DEFAULT 1,
|
|
198
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
199
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
CREATE TABLE IF NOT EXISTS combo_members (
|
|
203
|
+
id TEXT PRIMARY KEY,
|
|
204
|
+
combo_id TEXT NOT NULL REFERENCES combos(id) ON DELETE CASCADE,
|
|
205
|
+
model_id TEXT NOT NULL REFERENCES models(id) ON DELETE RESTRICT,
|
|
206
|
+
position INTEGER NOT NULL,
|
|
207
|
+
weight INTEGER NOT NULL DEFAULT 1,
|
|
208
|
+
enabled INTEGER NOT NULL DEFAULT 1
|
|
209
|
+
);
|
|
210
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_combo_model ON combo_members(combo_id, model_id);
|
|
211
|
+
CREATE INDEX IF NOT EXISTS idx_combo_pos ON combo_members(combo_id, position);
|
|
212
|
+
|
|
213
|
+
CREATE TABLE IF NOT EXISTS model_aliases (
|
|
214
|
+
id TEXT PRIMARY KEY,
|
|
215
|
+
alias TEXT NOT NULL UNIQUE,
|
|
216
|
+
target_kind TEXT NOT NULL CHECK (target_kind IN ('model','combo')),
|
|
217
|
+
target_id TEXT NOT NULL,
|
|
218
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
219
|
+
config_version INTEGER NOT NULL DEFAULT 1,
|
|
220
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
221
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
225
|
+
id TEXT PRIMARY KEY,
|
|
226
|
+
name TEXT NOT NULL,
|
|
227
|
+
key_prefix TEXT NOT NULL,
|
|
228
|
+
key_digest TEXT NOT NULL UNIQUE,
|
|
229
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
230
|
+
expires_at TEXT,
|
|
231
|
+
rpm_limit INTEGER,
|
|
232
|
+
tpm_limit INTEGER,
|
|
233
|
+
daily_token_limit INTEGER,
|
|
234
|
+
monthly_token_limit INTEGER,
|
|
235
|
+
max_concurrent INTEGER,
|
|
236
|
+
max_output_tokens_per_request INTEGER,
|
|
237
|
+
allow_all_models INTEGER NOT NULL DEFAULT 0,
|
|
238
|
+
cache_override_enabled INTEGER,
|
|
239
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
240
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
241
|
+
last_used_at TEXT
|
|
242
|
+
);
|
|
243
|
+
CREATE INDEX IF NOT EXISTS idx_key_prefix ON api_keys(key_prefix);
|
|
244
|
+
|
|
245
|
+
CREATE TABLE IF NOT EXISTS api_key_model_permissions (
|
|
246
|
+
id TEXT PRIMARY KEY,
|
|
247
|
+
api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
|
248
|
+
target_kind TEXT NOT NULL CHECK (target_kind IN ('model','combo','alias')),
|
|
249
|
+
target_id TEXT NOT NULL
|
|
250
|
+
);
|
|
251
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_key_target ON api_key_model_permissions(api_key_id, target_kind, target_id);
|
|
252
|
+
|
|
253
|
+
CREATE TABLE IF NOT EXISTS api_key_ip_rules (
|
|
254
|
+
id TEXT PRIMARY KEY,
|
|
255
|
+
api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
|
256
|
+
mode TEXT NOT NULL CHECK (mode IN ('allow','deny')),
|
|
257
|
+
cidr TEXT NOT NULL,
|
|
258
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
259
|
+
);
|
|
260
|
+
CREATE INDEX IF NOT EXISTS idx_ip_rule_key ON api_key_ip_rules(api_key_id);
|
|
261
|
+
|
|
262
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
263
|
+
id TEXT PRIMARY KEY,
|
|
264
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
265
|
+
completed_at TEXT,
|
|
266
|
+
api_key_id TEXT,
|
|
267
|
+
key_prefix_snapshot TEXT,
|
|
268
|
+
client_ip TEXT NOT NULL,
|
|
269
|
+
protocol TEXT NOT NULL CHECK (protocol IN ('openai','anthropic')),
|
|
270
|
+
endpoint TEXT NOT NULL,
|
|
271
|
+
requested_model TEXT NOT NULL,
|
|
272
|
+
resolved_target_kind TEXT NOT NULL CHECK (resolved_target_kind IN ('model','combo','alias','unknown')),
|
|
273
|
+
resolved_target_id TEXT,
|
|
274
|
+
final_model_id TEXT,
|
|
275
|
+
streaming INTEGER NOT NULL,
|
|
276
|
+
http_status INTEGER NOT NULL,
|
|
277
|
+
success INTEGER NOT NULL,
|
|
278
|
+
total_latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
279
|
+
ttft_ms INTEGER,
|
|
280
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
281
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
282
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
283
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
284
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
285
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
286
|
+
attempts_count INTEGER NOT NULL DEFAULT 0,
|
|
287
|
+
error_type TEXT,
|
|
288
|
+
error_message TEXT,
|
|
289
|
+
request_payload_json TEXT,
|
|
290
|
+
response_payload_json TEXT,
|
|
291
|
+
gateway_cache_hit INTEGER NOT NULL DEFAULT 0,
|
|
292
|
+
partial_stream INTEGER NOT NULL DEFAULT 0
|
|
293
|
+
);
|
|
294
|
+
CREATE INDEX IF NOT EXISTS idx_request_created ON requests(created_at);
|
|
295
|
+
CREATE INDEX IF NOT EXISTS idx_request_success ON requests(success, created_at);
|
|
296
|
+
CREATE INDEX IF NOT EXISTS idx_request_apikey ON requests(api_key_id, created_at);
|
|
297
|
+
CREATE INDEX IF NOT EXISTS idx_request_final_model ON requests(final_model_id, created_at);
|
|
298
|
+
CREATE INDEX IF NOT EXISTS idx_request_requested ON requests(requested_model, created_at);
|
|
299
|
+
CREATE INDEX IF NOT EXISTS idx_request_protocol ON requests(protocol, created_at);
|
|
300
|
+
|
|
301
|
+
CREATE TABLE IF NOT EXISTS request_attempts (
|
|
302
|
+
id TEXT PRIMARY KEY,
|
|
303
|
+
request_id TEXT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
|
|
304
|
+
attempt_number INTEGER NOT NULL,
|
|
305
|
+
provider_id TEXT NOT NULL,
|
|
306
|
+
model_id TEXT NOT NULL,
|
|
307
|
+
started_at TEXT NOT NULL,
|
|
308
|
+
completed_at TEXT,
|
|
309
|
+
status_code INTEGER,
|
|
310
|
+
success INTEGER NOT NULL,
|
|
311
|
+
latency_ms INTEGER NOT NULL DEFAULT 0,
|
|
312
|
+
ttft_ms INTEGER,
|
|
313
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
314
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
315
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
316
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
317
|
+
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
|
|
318
|
+
stream_started INTEGER NOT NULL DEFAULT 0,
|
|
319
|
+
partial_response INTEGER NOT NULL DEFAULT 0,
|
|
320
|
+
selection_reason TEXT NOT NULL,
|
|
321
|
+
failure_reason TEXT,
|
|
322
|
+
error_message TEXT,
|
|
323
|
+
upstream_request_id TEXT
|
|
324
|
+
);
|
|
325
|
+
CREATE INDEX IF NOT EXISTS idx_attempt_request ON request_attempts(request_id, attempt_number);
|
|
326
|
+
CREATE INDEX IF NOT EXISTS idx_attempt_provider_model ON request_attempts(provider_id, model_id, started_at);
|
|
327
|
+
|
|
328
|
+
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
329
|
+
id TEXT PRIMARY KEY,
|
|
330
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
331
|
+
action TEXT NOT NULL,
|
|
332
|
+
actor TEXT NOT NULL DEFAULT 'admin',
|
|
333
|
+
ip TEXT,
|
|
334
|
+
success INTEGER NOT NULL,
|
|
335
|
+
target_type TEXT,
|
|
336
|
+
target_id TEXT,
|
|
337
|
+
target_name TEXT,
|
|
338
|
+
metadata_json TEXT
|
|
339
|
+
);
|
|
340
|
+
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
|
|
341
|
+
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action, created_at);
|
|
342
|
+
|
|
343
|
+
CREATE TABLE IF NOT EXISTS response_cache (
|
|
344
|
+
id TEXT PRIMARY KEY,
|
|
345
|
+
cache_key TEXT NOT NULL UNIQUE,
|
|
346
|
+
target_kind TEXT NOT NULL CHECK (target_kind IN ('model','combo','alias')),
|
|
347
|
+
target_id TEXT NOT NULL,
|
|
348
|
+
target_config_version INTEGER NOT NULL DEFAULT 1,
|
|
349
|
+
protocol TEXT NOT NULL CHECK (protocol IN ('openai','anthropic')),
|
|
350
|
+
response_json TEXT NOT NULL,
|
|
351
|
+
usage_json TEXT,
|
|
352
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
353
|
+
expires_at TEXT NOT NULL,
|
|
354
|
+
last_hit_at TEXT,
|
|
355
|
+
hit_count INTEGER NOT NULL DEFAULT 0,
|
|
356
|
+
bytes INTEGER NOT NULL DEFAULT 0
|
|
357
|
+
);
|
|
358
|
+
CREATE INDEX IF NOT EXISTS idx_cache_expires ON response_cache(expires_at);
|
|
359
|
+
CREATE INDEX IF NOT EXISTS idx_cache_target ON response_cache(target_kind, target_id);
|
|
360
|
+
|
|
361
|
+
CREATE TABLE IF NOT EXISTS usage_daily (
|
|
362
|
+
day TEXT NOT NULL,
|
|
363
|
+
api_key_id TEXT NOT NULL,
|
|
364
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
365
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
366
|
+
total_tokens INTEGER NOT NULL DEFAULT 0
|
|
367
|
+
);
|
|
368
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_usage_day_key ON usage_daily(day, api_key_id);
|
|
369
|
+
|
|
370
|
+
CREATE TABLE IF NOT EXISTS usage_monthly (
|
|
371
|
+
month TEXT NOT NULL,
|
|
372
|
+
api_key_id TEXT NOT NULL,
|
|
373
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
374
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
375
|
+
total_tokens INTEGER NOT NULL DEFAULT 0
|
|
376
|
+
);
|
|
377
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_usage_month_key ON usage_monthly(month, api_key_id);
|
|
378
|
+
|
|
379
|
+
CREATE TABLE IF NOT EXISTS backups (
|
|
380
|
+
id TEXT PRIMARY KEY,
|
|
381
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
382
|
+
schema_version INTEGER NOT NULL,
|
|
383
|
+
app_version TEXT NOT NULL,
|
|
384
|
+
size_bytes INTEGER NOT NULL,
|
|
385
|
+
checksum TEXT NOT NULL,
|
|
386
|
+
path TEXT NOT NULL,
|
|
387
|
+
notes TEXT
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
CREATE TABLE IF NOT EXISTS csrf_tokens (
|
|
391
|
+
id TEXT PRIMARY KEY,
|
|
392
|
+
session_id TEXT NOT NULL,
|
|
393
|
+
token TEXT NOT NULL,
|
|
394
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
395
|
+
expires_at TEXT NOT NULL
|
|
396
|
+
);
|
|
397
|
+
CREATE INDEX IF NOT EXISTS idx_csrf_session ON csrf_tokens(session_id);
|
|
398
|
+
CREATE INDEX IF NOT EXISTS idx_csrf_expires ON csrf_tokens(expires_at);
|
|
399
|
+
`;
|
|
400
|
+
}
|
|
401
|
+
export function checksumOfBuffer(buf) {
|
|
402
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
403
|
+
}
|
|
404
|
+
// Public so migration tooling can dump the bootstrap as a real SQL file
|
|
405
|
+
// (single source of truth with the fresh-schema bootstrap path).
|
|
406
|
+
export function buildBootstrapMigrationSql() {
|
|
407
|
+
return buildInitialSchemaSql();
|
|
408
|
+
}
|