Files
OmniRoute/src/lib/db/radar.ts
2026-08-14 19:47:07 -03:00

550 lines
17 KiB
TypeScript

/**
* radar.ts — Radar client local DB module
*
* Provides local cache + settings storage for the OmniRoute Radar client.
* Nothing here talks to the network (that's the sync layer).
*
* Tables (migration 136):
* - radar_feed_cache: single-row signed feed cache
* - radar_settings: opt-in + encrypted supporter key
*
* Tables (migration 142):
* - radar_referrals_cache: single-row signed referrals feed cache
* (`GET /v1/referrals/latest` — a separate, always-current artifact from
* the catalog feed, see `src/lib/radar/referralsSync.ts`).
*
* Tables (migration 153):
* - radar_local_model_state: operator-owned display/enabled overrides and
* deletion tombstones, keyed by provider + model ID.
*
* Tables (migration 144):
* - radar_offers_cache: single-row signed live offers feed cache.
*
* Tables (migration 145):
* - radar_intel_cache: single-row signed live Intel feed cache plus a
* one-way supporter identity used for local recognition.
*
* The supporter key is encrypted at rest with AES-256-GCM using the same
* `encrypt()`/`decrypt()` helpers from `./encryption.ts` that protect
* provider connection credentials.
*/
import { getDbInstance } from "./core";
import { encrypt, decrypt } from "./encryption";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface RadarCache {
version: string;
tier: string;
payload: string;
signature: string;
fetchedAt: string;
}
export interface RadarSettings {
optIn: boolean;
supporterKey: string | null;
updatedAt: string;
}
export interface RadarReferralsCache {
generatedAt: string;
tier: string;
payload: string;
signature: string;
fetchedAt: string;
}
export interface RadarOffersCache {
version: string;
tier: "live";
payload: string;
signature: string;
fetchedAt: string;
}
export interface RadarIntelCache {
version: string;
tier: "live";
payload: string;
signature: string;
supporterIdentity: string;
fetchedAt: string;
}
export interface RadarLocalModelState {
provider: string;
modelId: string;
displayName: string | null;
enabled: boolean | null;
tombstoned: boolean;
updatedAt: string;
}
export interface RadarLocalModelOverridePatch {
displayName?: string | null;
enabled?: boolean | null;
}
export interface RadarLocalMergeState {
localOverrides: Map<string, { displayName?: string; enabled?: boolean }>;
tombstones: Set<string>;
}
interface RadarLocalModelStateRow {
provider: string;
model_id: string;
display_name: string | null;
enabled: number | null;
tombstoned: number;
updated_at: string;
}
// ---------------------------------------------------------------------------
// radar_feed_cache
// ---------------------------------------------------------------------------
/**
* Read the cached Radar feed. Returns null when no feed has been cached yet.
*/
export function getRadarCache(): RadarCache | null {
const db = getDbInstance();
const row = db
.prepare(
"SELECT version, tier, payload, signature, fetched_at AS fetchedAt " +
"FROM radar_feed_cache WHERE id = 1"
)
.get() as RadarCache | undefined;
return row ?? null;
}
/**
* Upsert the Radar feed cache (single row). Replaces any existing entry.
* If `fetchedAt` is omitted, the current ISO timestamp is used.
*/
export function setRadarCache(entry: {
version: string;
tier: string;
payload: string;
signature: string;
fetchedAt?: string;
}): void {
const db = getDbInstance();
const fetchedAt = entry.fetchedAt ?? new Date().toISOString();
db.prepare(
`INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
).run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt);
}
// ---------------------------------------------------------------------------
// radar_settings
// ---------------------------------------------------------------------------
/**
* Read the Radar settings. The supporter key is decrypted on read.
* The settings row is seeded by migration 134, so this always returns a row.
*/
export function getRadarSettings(): RadarSettings {
const db = getDbInstance();
const row = db
.prepare("SELECT opt_in, supporter_key_encrypted, updated_at FROM radar_settings WHERE id = 1")
.get() as { opt_in: number; supporter_key_encrypted: string | null; updated_at: string };
return {
optIn: row.opt_in === 1,
supporterKey: decrypt(row.supporter_key_encrypted) ?? null,
updatedAt: row.updated_at,
};
}
/**
* Set the Radar opt-in state.
*/
export function setRadarOptIn(optIn: boolean): void {
const db = getDbInstance();
db.prepare("UPDATE radar_settings SET opt_in = ?, updated_at = datetime('now') WHERE id = 1").run(
optIn ? 1 : 0
);
}
/**
* Set (or clear) the Radar supporter key. The key is encrypted at rest
* using the same AES-256-GCM mechanism as provider credentials.
* Pass `null` to clear.
*/
export function setRadarKey(key: string | null): void {
const db = getDbInstance();
const encrypted = key !== null ? encrypt(key) : null;
const updateKey = db.prepare(
"UPDATE radar_settings SET supporter_key_encrypted = ?, updated_at = datetime('now') WHERE id = 1"
);
const clearCatalogCache = db.prepare("DELETE FROM radar_feed_cache WHERE id = 1");
const clearReferralsCache = db.prepare("DELETE FROM radar_referrals_cache WHERE id = 1");
const clearOffersCache = db.prepare("DELETE FROM radar_offers_cache WHERE id = 1");
const clearIntelCache = db.prepare("DELETE FROM radar_intel_cache WHERE id = 1");
db.transaction(() => {
updateKey.run(encrypted);
// All signed feeds are entitlement-sensitive. Clearing their cached
// variants forces the next sync/read to resolve the new key server-side
// instead of serving data fetched under the previous entitlement.
clearCatalogCache.run();
clearReferralsCache.run();
clearOffersCache.run();
clearIntelCache.run();
})();
}
// ---------------------------------------------------------------------------
// radar_referrals_cache
// ---------------------------------------------------------------------------
/**
* Read the cached Radar referrals feed (`GET /v1/referrals/latest`).
* Returns null when no referrals feed has been cached yet — separate from,
* and never falling back to, the catalog's `radar_feed_cache`.
*/
export function getRadarReferralsCache(): RadarReferralsCache | null {
const db = getDbInstance();
const row = db
.prepare(
"SELECT generated_at AS generatedAt, tier, payload, signature, fetched_at AS fetchedAt " +
"FROM radar_referrals_cache WHERE id = 1"
)
.get() as RadarReferralsCache | undefined;
return row ?? null;
}
/**
* Upsert the Radar referrals feed cache (single row). Replaces any existing
* entry. If `fetchedAt` is omitted, the current ISO timestamp is used.
*/
export function setRadarReferralsCache(entry: {
generatedAt: string;
tier: string;
payload: string;
signature: string;
fetchedAt?: string;
}): void {
const db = getDbInstance();
const fetchedAt = entry.fetchedAt ?? new Date().toISOString();
db.prepare(
`INSERT INTO radar_referrals_cache (id, generated_at, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
generated_at = excluded.generated_at,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
).run(entry.generatedAt, entry.tier, entry.payload, entry.signature, fetchedAt);
}
// ---------------------------------------------------------------------------
// radar_offers_cache
// ---------------------------------------------------------------------------
export function getRadarOffersCache(): RadarOffersCache | null {
const row = getDbInstance()
.prepare(
"SELECT version, tier, payload, signature, fetched_at AS fetchedAt " +
"FROM radar_offers_cache WHERE id = 1"
)
.get() as RadarOffersCache | undefined;
return row ?? null;
}
export function setRadarOffersCache(entry: {
version: string;
tier: "live";
payload: string;
signature: string;
fetchedAt?: string;
}): void {
const fetchedAt = entry.fetchedAt ?? new Date().toISOString();
getDbInstance()
.prepare(
`INSERT INTO radar_offers_cache (id, version, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
)
.run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt);
}
// ---------------------------------------------------------------------------
// radar_intel_cache
// ---------------------------------------------------------------------------
export function getRadarIntelCache(): RadarIntelCache | null {
const row = getDbInstance()
.prepare(
"SELECT version, tier, payload, signature, supporter_identity AS supporterIdentity, " +
"fetched_at AS fetchedAt FROM radar_intel_cache WHERE id = 1"
)
.get() as RadarIntelCache | undefined;
return row ?? null;
}
export function setRadarIntelCache(entry: {
version: string;
tier: "live";
payload: string;
signature: string;
supporterIdentity: string;
fetchedAt?: string;
}): void {
const fetchedAt = entry.fetchedAt ?? new Date().toISOString();
getDbInstance()
.prepare(
`INSERT INTO radar_intel_cache
(id, version, tier, payload, signature, supporter_identity, fetched_at)
VALUES (1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
supporter_identity = excluded.supporter_identity,
fetched_at = excluded.fetched_at`
)
.run(
entry.version,
entry.tier,
entry.payload,
entry.signature,
entry.supporterIdentity,
fetchedAt
);
}
// ---------------------------------------------------------------------------
// radar_local_model_state
// ---------------------------------------------------------------------------
const RADAR_PROVIDER_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}$/i;
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
function normalizeRadarIdentity(
provider: unknown,
modelId: unknown
): { provider: string; modelId: string } | null {
const normalizedProvider = typeof provider === "string" ? provider.trim() : "";
const normalizedModelId = typeof modelId === "string" ? modelId.trim() : "";
if (!RADAR_PROVIDER_PATTERN.test(normalizedProvider)) return null;
if (
normalizedModelId.length < 1 ||
normalizedModelId.length > 200 ||
CONTROL_CHARACTER_PATTERN.test(normalizedModelId)
) {
return null;
}
return { provider: normalizedProvider, modelId: normalizedModelId };
}
function normalizeDisplayName(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== "string") return undefined;
const normalized = value.trim();
if (
normalized.length < 1 ||
normalized.length > 160 ||
CONTROL_CHARACTER_PATTERN.test(normalized)
) {
return undefined;
}
return normalized;
}
function rowToRadarLocalModelState(row: RadarLocalModelStateRow): RadarLocalModelState {
return {
provider: row.provider,
modelId: row.model_id,
displayName: row.display_name,
enabled: row.enabled === null ? null : row.enabled === 1,
tombstoned: row.tombstoned === 1,
updatedAt: row.updated_at,
};
}
function readRadarLocalModelStateRow(
provider: string,
modelId: string
): RadarLocalModelStateRow | null {
return (
(getDbInstance()
.prepare(
`SELECT provider, model_id, display_name, enabled, tombstoned, updated_at
FROM radar_local_model_state WHERE provider = ? AND model_id = ?`
)
.get(provider, modelId) as RadarLocalModelStateRow | undefined) ?? null
);
}
function persistRadarLocalModelState(input: {
provider: string;
modelId: string;
displayName: string | null;
enabled: boolean | null;
tombstoned: boolean;
}): void {
const db = getDbInstance();
if (input.displayName === null && input.enabled === null && !input.tombstoned) {
db.prepare("DELETE FROM radar_local_model_state WHERE provider = ? AND model_id = ?").run(
input.provider,
input.modelId
);
return;
}
db.prepare(
`INSERT INTO radar_local_model_state
(provider, model_id, display_name, enabled, tombstoned, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(provider, model_id) DO UPDATE SET
display_name = excluded.display_name,
enabled = excluded.enabled,
tombstoned = excluded.tombstoned,
updated_at = excluded.updated_at`
).run(
input.provider,
input.modelId,
input.displayName,
input.enabled === null ? null : input.enabled ? 1 : 0,
input.tombstoned ? 1 : 0
);
}
/** List every persisted override/tombstone for UI editing and restore controls. */
export function listRadarLocalModelState(): RadarLocalModelState[] {
const rows = getDbInstance()
.prepare(
`SELECT provider, model_id, display_name, enabled, tombstoned, updated_at
FROM radar_local_model_state ORDER BY provider, model_id`
)
.all() as RadarLocalModelStateRow[];
return rows.map(rowToRadarLocalModelState);
}
/**
* Merge a validated partial override into the existing row. `null` clears a
* field; `undefined` preserves it. Tombstone state is never changed here.
*/
export function setRadarLocalModelOverride(
provider: unknown,
modelId: unknown,
patch: RadarLocalModelOverridePatch
): boolean {
const identity = normalizeRadarIdentity(provider, modelId);
if (!identity || !patch || typeof patch !== "object") return false;
const keys = Object.keys(patch);
if (
keys.length === 0 ||
keys.some((key) => key !== "displayName" && key !== "enabled") ||
(Object.hasOwn(patch, "enabled") &&
patch.enabled !== null &&
typeof patch.enabled !== "boolean")
) {
return false;
}
const normalizedDisplayName = normalizeDisplayName(patch.displayName);
if (Object.hasOwn(patch, "displayName") && normalizedDisplayName === undefined) return false;
const db = getDbInstance();
db.transaction(() => {
const current = readRadarLocalModelStateRow(identity.provider, identity.modelId);
persistRadarLocalModelState({
...identity,
displayName: Object.hasOwn(patch, "displayName")
? (normalizedDisplayName ?? null)
: (current?.display_name ?? null),
enabled: Object.hasOwn(patch, "enabled")
? (patch.enabled ?? null)
: current?.enabled === null || current?.enabled === undefined
? null
: current.enabled === 1,
tombstoned: current?.tombstoned === 1,
});
})();
return true;
}
/** Clear both editable fields while preserving an independent tombstone. */
export function clearRadarLocalModelOverride(provider: unknown, modelId: unknown): boolean {
const identity = normalizeRadarIdentity(provider, modelId);
if (!identity) return false;
const db = getDbInstance();
db.transaction(() => {
const current = readRadarLocalModelStateRow(identity.provider, identity.modelId);
persistRadarLocalModelState({
...identity,
displayName: null,
enabled: null,
tombstoned: current?.tombstoned === 1,
});
})();
return true;
}
/** Hide or restore one model without modifying its editable local fields. */
export function setRadarModelTombstone(
provider: unknown,
modelId: unknown,
tombstoned: boolean
): boolean {
const identity = normalizeRadarIdentity(provider, modelId);
if (!identity || typeof tombstoned !== "boolean") return false;
const db = getDbInstance();
db.transaction(() => {
const current = readRadarLocalModelStateRow(identity.provider, identity.modelId);
persistRadarLocalModelState({
...identity,
displayName: current?.display_name ?? null,
enabled:
current?.enabled === null || current?.enabled === undefined ? null : current.enabled === 1,
tombstoned,
});
})();
return true;
}
/** Convert persisted rows into the exact read-time merge structures. */
export function getRadarLocalMergeState(): RadarLocalMergeState {
const localOverrides = new Map<string, { displayName?: string; enabled?: boolean }>();
const tombstones = new Set<string>();
for (const state of listRadarLocalModelState()) {
const key = `${state.provider}:${state.modelId}`;
const override: { displayName?: string; enabled?: boolean } = {};
if (state.displayName !== null) override.displayName = state.displayName;
if (state.enabled !== null) override.enabled = state.enabled;
if (Object.keys(override).length > 0) localOverrides.set(key, override);
if (state.tombstoned) tombstones.add(key);
}
return { localOverrides, tombstones };
}