File size: 9,422 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
/**
 * Convex transport for the historical intelligence memory (#5694).
 *
 * Shared by the three Pro-gated RPCs in
 * server/worldmonitor/intelligence/v1/{search-intel-history,get-intel-timeline,
 * get-similar-events}.ts. Owns one concern: reading convex/intelHistory.ts
 * through its two secret-guarded internal HTTP routes, and adapting stored
 * records to the wire shape. Query embedding lives in
 * ./intel-history-embed.ts — a different upstream with its own budget and
 * failure mode.
 *
 * Every function returns `null` rather than throwing, so a store outage
 * surfaces as `upstream_unavailable: true` on a 200 — the gateway reads that
 * flag out of the body and drops the response to Cache-Control: no-store
 * (server/_shared/cache-contract.ts) instead of pinning a false-empty result
 * for the cache tier's full TTL.
 */

// @ts-expect-error — JS module, no declaration file
import { captureSilentError } from '../../api/_sentry-edge.js';

const CONVEX_INTERNAL_SEARCH_PATH = '/api/internal-intel-search';
const CONVEX_INTERNAL_TIMELINE_PATH = '/api/internal-intel-timeline';

/** Convex read budget, matching the entitlement gate's posture. */
const CONVEX_TIMEOUT_MS = 5_000;

let _didWarnMissingConvexSiteUrl = false;
let _didWarnMissingConvexSharedSecret = false;

/**
 * Warn once per missing variable rather than per request. Mirrors
 * server/_shared/entitlement-check.ts: a deploy missing only one of the pair
 * would otherwise disable these routes with no signal in the logs.
 */
function getConvexSiteUrl(): string {
  const siteUrl = process.env.CONVEX_SITE_URL ?? '';
  if (!siteUrl && !_didWarnMissingConvexSiteUrl) {
    _didWarnMissingConvexSiteUrl = true;
    console.warn('[intel-history] CONVEX_SITE_URL not set; history reads disabled');
  }
  return siteUrl;
}

function getConvexSharedSecret(): string {
  const secret = process.env.CONVEX_SERVER_SHARED_SECRET ?? '';
  if (!secret && !_didWarnMissingConvexSharedSecret) {
    _didWarnMissingConvexSharedSecret = true;
    console.warn('[intel-history] CONVEX_SERVER_SHARED_SECRET not set; history reads disabled');
  }
  return secret;
}

/**
 * Scope values are compared with `eq` against what the seeders wrote, so the
 * caller's casing and padding decide whether anything matches at all. A
 * request for country "ua " silently returns nothing against stored "UA" —
 * indistinguishable, to the caller, from "we have no history for Ukraine".
 * Normalize to the stored form instead of trusting the wire.
 *
 * buf.validate documents these shapes in the proto but does not run: the
 * gateway supplies no `validateRequest` (server/gateway.ts), so this is the
 * only place the contract is actually applied.
 */
export function normalizeCountry(value: unknown): string {
  return typeof value === 'string' ? value.trim().toUpperCase() : '';
}

export function normalizeDomain(value: unknown): string {
  return typeof value === 'string' ? value.trim().toLowerCase() : '';
}

/**
 * Scope accepted by both read routes. Absent fields are omitted from the
 * request body entirely: convex/http.ts distinguishes "field absent" from
 * "field present but empty", and the timeline route rejects an unscoped read.
 */
export interface IntelHistoryScope {
  domain?: string;
  country?: string;
  from?: number;
  to?: number;
  limit: number;
}

/** One record as convex/intelHistory.ts:projectRecord emits it. */
interface WireRecord {
  id?: unknown;
  domain?: unknown;
  resource?: unknown;
  country?: unknown;
  category?: unknown;
  title?: unknown;
  summary?: unknown;
  sourceUrl?: unknown;
  occurredAt?: unknown;
  ingestedAt?: unknown;
  _score?: unknown;
}

/**
 * Structural mirror of the generated `IntelHistoryRecord` (proto
 * intel_history_record.proto). Declared here rather than imported so this
 * module stays independent of src/generated; the handlers assign these into
 * the generated response types, so any proto field change fails typecheck at
 * the call sites.
 */
export interface IntelHistoryRecordView {
  id: string;
  domain: string;
  resource: string;
  country: string;
  category: string;
  title: string;
  summary: string;
  sourceUrl: string;
  occurredAt: number;
  ingestedAt: number;
  score: number;
}

function str(value: unknown): string {
  return typeof value === 'string' ? value : '';
}

function num(value: unknown): number {
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}

/**
 * Adapt one stored record to the wire shape. `_score` is present only on the
 * vector path; a chronological read leaves it 0, which the proto documents as
 * "no similarity applies" rather than "no similarity".
 */
function toIntelHistoryRecord(raw: WireRecord): IntelHistoryRecordView {
  return {
    id: str(raw.id),
    domain: str(raw.domain),
    resource: str(raw.resource),
    country: str(raw.country),
    category: str(raw.category),
    title: str(raw.title),
    summary: str(raw.summary),
    sourceUrl: str(raw.sourceUrl),
    occurredAt: num(raw.occurredAt),
    ingestedAt: num(raw.ingestedAt),
    score: num(raw._score),
  };
}

/**
 * Resolve a request limit: the server-side default when omitted or <= 0,
 * otherwise the requested value capped at the route's ceiling. The ceilings
 * mirror the clamps in convex/intelHistory.ts, so a caller never receives
 * fewer rows than the API documents without an explanation.
 */
export function resolveLimit(requested: unknown, fallback: number, max: number): number {
  const value = Number(requested);
  return Number.isFinite(value) && value > 0 ? Math.min(Math.floor(value), max) : fallback;
}

/**
 * Drop empty scope fields so convex/http.ts sees them as absent.
 *
 * `0` means "no bound" per the published contract, so it is omitted rather
 * than sent. Any other finite value is forwarded verbatim — including a
 * negative, which is a legitimate pre-1970 epoch bound and which the MCP tool
 * layer explicitly forwards on the promise that the route is the sole
 * authority on bounds. Silently discarding it here would break that promise.
 */
function scopeBody(scope: IntelHistoryScope): Record<string, unknown> {
  const body: Record<string, unknown> = {};
  if (scope.domain) body.domain = scope.domain;
  if (scope.country) body.country = scope.country;
  if (typeof scope.from === 'number' && Number.isFinite(scope.from) && scope.from !== 0) {
    body.from = scope.from;
  }
  if (typeof scope.to === 'number' && Number.isFinite(scope.to) && scope.to !== 0) {
    body.to = scope.to;
  }
  body.limit = scope.limit;
  return body;
}

/**
 * POST one of the two secret-guarded internal read routes. Returns null on a
 * missing configuration, a non-2xx, a malformed body, or a timeout — the
 * caller turns that into `upstream_unavailable`.
 */
async function readIntelHistory(
  path: string,
  payload: Record<string, unknown>,
): Promise<{ records: IntelHistoryRecordView[]; partial: boolean } | null> {
  const siteUrl = getConvexSiteUrl();
  const sharedSecret = getConvexSharedSecret();
  if (!siteUrl || !sharedSecret) return null;

  try {
    const resp = await fetch(`${siteUrl}${path}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'worldmonitor-gateway/1.0',
        'x-convex-shared-secret': sharedSecret,
      },
      body: JSON.stringify(payload),
      signal: AbortSignal.timeout(CONVEX_TIMEOUT_MS),
    });
    if (!resp.ok) {
      console.warn(`[intel-history] ${path} returned HTTP ${resp.status}`);
      return null;
    }
    const body = (await resp.json()) as { records?: unknown; partial?: unknown };
    if (!Array.isArray(body?.records)) {
      console.warn(`[intel-history] ${path} returned no records array`);
      return null;
    }
    return {
      records: (body.records as WireRecord[])
        .filter((rec): rec is WireRecord => rec !== null && typeof rec === 'object')
        .map(toIntelHistoryRecord),
      partial: body.partial === true,
    };
  } catch (err) {
    // Same reasoning as the embed path: a Convex outage surfaces to the caller
    // as an empty 200, so it has to reach Sentry to be distinguishable.
    const msg = err instanceof Error ? err.message : String(err);
    console.warn(`[intel-history] ${path} failed: ${msg}`);
    captureSilentError(err, {
      tags: { surface: 'server', component: 'intel-history', stage: 'convex-read' },
      fingerprint: ['intel-history', 'convex-read-error', path],
    });
    return null;
  }
}

/** Semantic read: rank stored history against a query vector. */
export function intelHistorySearch(
  params: IntelHistoryScope & { embedding: number[]; minScore?: number },
): Promise<{ records: IntelHistoryRecordView[]; partial: boolean } | null> {
  const { embedding, minScore, ...scope } = params;
  return readIntelHistory(CONVEX_INTERNAL_SEARCH_PATH, {
    embedding,
    ...scopeBody(scope),
    ...(typeof minScore === 'number' ? { minScore } : {}),
  });
}

/**
 * Chronological read. At least one of domain/country must be set — the caller
 * enforces that and returns 400, because Convex answers an unscoped read with
 * a 500 that this layer could only report as an outage.
 */
export function intelHistoryTimeline(
  scope: IntelHistoryScope,
): Promise<{ records: IntelHistoryRecordView[]; partial: boolean } | null> {
  return readIntelHistory(CONVEX_INTERNAL_TIMELINE_PATH, scopeBody(scope));
}