champollion 0.3.3

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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
package/lib/serve.js ADDED
@@ -0,0 +1,854 @@
1
+ /**
2
+ * serve.js — self-hosted translation server engine (`champollion serve`).
3
+ *
4
+ * WHY THIS EXISTS:
5
+ * The hosted Champollion Translate API was shelved (founder decision,
6
+ * 2026-07-19). What ships instead is the self-hosted path: any project
7
+ * owner can stand their OWN configured translation stack up behind the
8
+ * exact HTTP contract that lib/methods/api.js (the `api` method client)
9
+ * already speaks. One command on each side:
10
+ *
11
+ * owner: CHAMPOLLION_SERVE_TOKEN=... champollion serve
12
+ * consumer: champollion plugin install ./<manifest-dir> && champollion sync
13
+ *
14
+ * THE CONTRACT (mirror of lib/methods/api.js):
15
+ * POST /translate
16
+ * { source_locale, target_locale, method, keys: { key: sourceString } }
17
+ * → 200 { translations, meta: { model, cost_usd, quality_tier, ... } }
18
+ * → 207 { translations, errors: { key: { message } }, meta } partial
19
+ * → 4xx/5xx { error: { code, message }, ... } structured
20
+ *
21
+ * NO FORKED TRANSLATION LOGIC:
22
+ * Requests run through the SAME pipeline `sync` uses — resolveRuntime for
23
+ * config/plugin/preflight resolution and translateAndValidate for the
24
+ * TM partition → API call → deterministic quality gate → TM store sequence.
25
+ * TM hits are served from cache at $0; gate failures come back as
26
+ * structured per-key errors, never as silently degraded output.
27
+ *
28
+ * SAFETY MODEL:
29
+ * - Bearer token required by default (CHAMPOLLION_SERVE_TOKEN / --token).
30
+ * Anonymous serving is allowed ONLY on a loopback bind (--no-auth).
31
+ * - Default bind is 127.0.0.1 — exposing the server is an explicit
32
+ * owner decision (--bind 0.0.0.0), because whoever can reach the port
33
+ * can spend the owner's upstream API budget.
34
+ * - Per-IP rate limit, request body size cap, per-request key cap.
35
+ * - Cost guard: --max-cost-per-request / --max-session-cost reuse the
36
+ * sync cost machinery (estimateCost + parseMaxCost). TM hits are free;
37
+ * an UNKNOWN estimate under a cap refuses the request (unknown ≠ free —
38
+ * same doctrine as `sync --max-cost`).
39
+ * - No request field is ever used as a filesystem path. Key names are
40
+ * screened with isUnsafeKey (prototype pollution) and a length cap.
41
+ */
42
+
43
+ import http from 'node:http';
44
+ import crypto from 'node:crypto';
45
+ import fs from 'node:fs';
46
+ import path from 'node:path';
47
+ import { createRequire } from 'node:module';
48
+
49
+ import { resolveConfig } from './config.js';
50
+ import { resolveRuntime } from './sync.js';
51
+ import { loadTM, saveTM, isTMDirty, partitionByTM, tmMethodKey } from './tm.js';
52
+ import { translateAndValidate } from './translate-pair.js';
53
+ import { estimateCost, QUALITY_TIERS } from './pairs.js';
54
+ import { getMethod } from './translate.js';
55
+ import { isUnsafeKey } from './security.js';
56
+ import { convertScript, applyScriptFallback } from './scripts.js';
57
+ import { verifyTerminology, logTermViolations } from './terminology.js';
58
+ import { validateManifest } from './plugins.js';
59
+ import { output } from './output.js';
60
+
61
+ const require = createRequire(import.meta.url);
62
+ const CLI_VERSION = require('../package.json').version;
63
+
64
+ /**
65
+ * Server defaults — exported so the command bridge, help text, and tests
66
+ * reference one source of truth instead of re-declaring numbers.
67
+ *
68
+ * Port 1822: the year Champollion deciphered the Rosetta hieroglyphs.
69
+ */
70
+ const SERVE_DEFAULTS = {
71
+ port: 1822,
72
+ bind: '127.0.0.1',
73
+ rateLimitPerMin: 120, // per client IP, /translate only; 0 disables
74
+ maxBodyBytes: 1_000_000, // 1 MB — the api.js client chunks at 100 keys/request
75
+ maxKeysPerRequest: 500, // hard per-request key cap (client sends ≤ 100)
76
+ minTokenLength: 12, // shorter tokens are brute-forceable even rate-limited
77
+ };
78
+
79
+ // Longest key NAME accepted in a request. Values are bounded by the body
80
+ // size cap; names this long are never legitimate dot-notation i18n keys.
81
+ const MAX_KEY_NAME_LENGTH = 300;
82
+
83
+ // -----------------------------------------------------------------
84
+ // Runtime resolution — the owner's configured stack
85
+ // -----------------------------------------------------------------
86
+
87
+ /**
88
+ * Resolve the owner's translation runtime for serving.
89
+ *
90
+ * Same sequence as `sync`: resolveConfig (config file + CLI overrides) →
91
+ * resolveRuntime (languages, pair graph, plugin merge, preflight). With
92
+ * preflight on, a stack that cannot execute (missing upstream key, …)
93
+ * fails HERE at startup — no gas, no ignition — instead of per-request.
94
+ *
95
+ * @param {object} options
96
+ * @param {string} options.cwd - Owner project root
97
+ * @param {object} [options.cliArgs] - Parsed CLI args (--method/--model/--pair/… work like sync)
98
+ * @param {boolean} [options.preflight=true] - Run method readiness checks
99
+ * @returns {Promise<{ config, apiKey, resolvedPairs, pairEntries, tm, cwd }>}
100
+ */
101
+ async function createServeRuntime({ cwd, cliArgs = {}, preflight = true }) {
102
+ const config = resolveConfig(cliArgs, cwd);
103
+ // resolveRuntime skips preflight when cliArgs.dryRun is set (read-only
104
+ // paths don't need an API key) — manifest emission reuses that lane.
105
+ const runtimeArgs = preflight ? { ...cliArgs } : { ...cliArgs, dryRun: true };
106
+ const { apiKey, resolvedPairs, pairEntries } = await resolveRuntime(config, cwd, runtimeArgs);
107
+
108
+ if (pairEntries.length === 0) {
109
+ throw new Error(
110
+ 'No translation pairs configured — nothing to serve. ' +
111
+ 'Add "languages" or "pairs" to champollion.config.json (or run `champollion init`).'
112
+ );
113
+ }
114
+
115
+ const tm = loadTM(cwd);
116
+ return { config, apiKey, resolvedPairs, pairEntries, tm, cwd };
117
+ }
118
+
119
+ /**
120
+ * Derive a kebab-case served-method name from the owner project directory.
121
+ * Falls back to 'champollion-serve' when the basename sanitizes to nothing.
122
+ *
123
+ * @param {string} cwd - Owner project root
124
+ * @returns {string} e.g. "my-project-serve"
125
+ */
126
+ function deriveMethodName(cwd) {
127
+ const base = path.basename(path.resolve(cwd))
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9]+/g, '-')
130
+ .replace(/^-+|-+$/g, '')
131
+ .replace(/-{2,}/g, '-');
132
+ return base ? `${base}-serve` : 'champollion-serve';
133
+ }
134
+
135
+ /**
136
+ * Most conservative quality tier across the served pairs.
137
+ *
138
+ * The manifest carries ONE qualityTier; when pairs differ, advertising the
139
+ * highest would overstate the weakest pair. Order comes from QUALITY_TIERS
140
+ * (standard < high < research < verified).
141
+ *
142
+ * @param {Array<[string, object]>} pairEntries
143
+ * @returns {string}
144
+ */
145
+ function minQualityTier(pairEntries) {
146
+ const order = Object.keys(QUALITY_TIERS);
147
+ let min = order.length - 1;
148
+ for (const [, pairConfig] of pairEntries) {
149
+ const idx = order.indexOf(pairConfig.qualityTier || 'standard');
150
+ min = Math.min(min, idx === -1 ? 0 : idx);
151
+ }
152
+ return order[Math.max(min, 0)];
153
+ }
154
+
155
+ // -----------------------------------------------------------------
156
+ // Manifest emission (--emit-manifest)
157
+ // -----------------------------------------------------------------
158
+
159
+ /**
160
+ * Resolve the consumer-facing endpoint URL for the manifest.
161
+ *
162
+ * @param {string|null} rawEndpoint - --endpoint value (public URL), or null
163
+ * @param {number} port - Serve port (for the loopback default)
164
+ * @returns {string} Absolute http(s) URL ending in a translate path
165
+ */
166
+ function resolveManifestEndpoint(rawEndpoint, port) {
167
+ if (!rawEndpoint) {
168
+ return `http://127.0.0.1:${port}/translate`;
169
+ }
170
+ let url;
171
+ try {
172
+ url = new URL(String(rawEndpoint));
173
+ } catch {
174
+ throw new Error(`--endpoint must be an absolute http(s) URL (got "${rawEndpoint}").`);
175
+ }
176
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
177
+ throw new Error(`--endpoint must use http or https (got "${url.protocol}//").`);
178
+ }
179
+ // A bare origin gets the standard path appended; an explicit path is
180
+ // respected verbatim (owners may mount behind a proxy at any path).
181
+ if (url.pathname === '/' || url.pathname === '') {
182
+ url.pathname = '/translate';
183
+ }
184
+ return url.href;
185
+ }
186
+
187
+ /**
188
+ * Build the method.json plugin manifest a CONSUMER installs to point
189
+ * `method: "api"` at this server (see docs/reference/plugin-spec).
190
+ *
191
+ * Honesty rules:
192
+ * - qualityTier is a PASSTHROUGH of the served pairs' own tiers (most
193
+ * conservative when they differ) — never invented.
194
+ * - provenance is merged from the underlying methods' own declarations:
195
+ * commercialReady only when EVERY pair's method is cleared; flags union.
196
+ * - The manifest is validated against the plugin contract before return,
197
+ * so an emitted file always installs.
198
+ *
199
+ * @param {object} runtime - From createServeRuntime
200
+ * @param {object} options
201
+ * @param {string} options.name - Served method name (kebab-case)
202
+ * @param {string} options.endpointUrl - Consumer-reachable endpoint URL
203
+ * @returns {object} Validated manifest object
204
+ */
205
+ function buildServeManifest(runtime, { name, endpointUrl }) {
206
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
207
+ throw new Error(`Served method name must be kebab-case (got "${name}"). Use --name to override.`);
208
+ }
209
+
210
+ const { pairEntries, cwd } = runtime;
211
+ const locales = [...new Set(pairEntries.map(([, p]) => p.target))];
212
+ const methodSummary = [...new Set(pairEntries.map(([, p]) => p.method || 'llm'))].join(', ');
213
+
214
+ // Owner project version when it is plugin-grade semver, else 0.1.0.
215
+ let version = '0.1.0';
216
+ try {
217
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
218
+ if (typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version)) {
219
+ version = pkg.version;
220
+ }
221
+ } catch {
222
+ // No package.json — the default stands.
223
+ }
224
+
225
+ // Merge provenance from the actual method implementations behind the server.
226
+ const resources = [];
227
+ const seenResources = new Set();
228
+ let commercialReady = true;
229
+ const flags = new Set();
230
+ for (const [, pairConfig] of pairEntries) {
231
+ let prov;
232
+ try {
233
+ prov = getMethod(pairConfig.method || 'llm', pairConfig).getProvenance();
234
+ } catch {
235
+ continue;
236
+ }
237
+ for (const res of prov?.resources || []) {
238
+ const key = `${res.name}|${res.license}`;
239
+ if (!seenResources.has(key)) {
240
+ seenResources.add(key);
241
+ resources.push(res);
242
+ }
243
+ }
244
+ if (prov && prov.commercialReady !== true) commercialReady = false;
245
+ for (const f of prov?.flags || []) flags.add(f);
246
+ }
247
+
248
+ const manifest = {
249
+ name,
250
+ type: 'api',
251
+ version,
252
+ description:
253
+ `Self-hosted champollion serve endpoint (${methodSummary}) for ${locales.join(', ')}. ` +
254
+ 'Translations run on the owner\'s own stack; this manifest only points the api method at it.',
255
+ endpoint: endpointUrl,
256
+ locales,
257
+ // For an api-type plugin, model/register/batchSize/temperature live
258
+ // SERVER-side and are OMITTED (the plugin schema does not allow null
259
+ // for them); the nullable coaching/context fields ship as explicit
260
+ // nulls per the canonical MethodConfig shape. qualityTier is the only
261
+ // consumer-relevant value: an honest passthrough of the served stack.
262
+ config: {
263
+ coachingFile: null,
264
+ coachingPrompt: null,
265
+ promptContext: null,
266
+ qualityTier: minQualityTier(pairEntries),
267
+ },
268
+ provenance: {
269
+ resources,
270
+ commercialReady,
271
+ flags: [...flags],
272
+ },
273
+ _emittedBy: `champollion-serve/${CLI_VERSION} (champollion serve --emit-manifest)`,
274
+ };
275
+
276
+ const validation = validateManifest(manifest);
277
+ if (!validation.valid) {
278
+ // Should be unreachable — the emitter must never produce an uninstallable
279
+ // manifest. Fail loud rather than write a broken file.
280
+ throw new Error(`Emitted manifest failed plugin validation: ${validation.errors.join(', ')}`);
281
+ }
282
+ return manifest;
283
+ }
284
+
285
+ // -----------------------------------------------------------------
286
+ // HTTP plumbing — rate limit, auth, body reading
287
+ // -----------------------------------------------------------------
288
+
289
+ /**
290
+ * Sliding-window per-IP rate limiter (timestamps within the last 60s).
291
+ * Client IP is the SOCKET address — X-Forwarded-For is never trusted
292
+ * (trivially spoofable by any direct client).
293
+ */
294
+ class RateLimiter {
295
+ constructor(limitPerMin) {
296
+ this.limit = limitPerMin;
297
+ this.windowMs = 60_000;
298
+ this.hits = new Map(); // ip → number[] (ms timestamps)
299
+ // Periodic sweep so idle IPs don't accumulate; unref'd so the timer
300
+ // never holds the process open.
301
+ if (this.limit > 0) {
302
+ this._sweep = setInterval(() => this._prune(Date.now()), this.windowMs);
303
+ this._sweep.unref();
304
+ }
305
+ }
306
+
307
+ _prune(now) {
308
+ for (const [ip, stamps] of this.hits) {
309
+ const live = stamps.filter(t => now - t < this.windowMs);
310
+ if (live.length === 0) this.hits.delete(ip);
311
+ else this.hits.set(ip, live);
312
+ }
313
+ }
314
+
315
+ /**
316
+ * @param {string} ip
317
+ * @returns {{ allowed: boolean, retryAfterSec?: number }}
318
+ */
319
+ check(ip) {
320
+ if (this.limit <= 0) return { allowed: true };
321
+ const now = Date.now();
322
+ const stamps = (this.hits.get(ip) || []).filter(t => now - t < this.windowMs);
323
+ if (stamps.length >= this.limit) {
324
+ const retryAfterSec = Math.max(1, Math.ceil((stamps[0] + this.windowMs - now) / 1000));
325
+ this.hits.set(ip, stamps);
326
+ return { allowed: false, retryAfterSec };
327
+ }
328
+ stamps.push(now);
329
+ this.hits.set(ip, stamps);
330
+ return { allowed: true };
331
+ }
332
+
333
+ stop() {
334
+ if (this._sweep) clearInterval(this._sweep);
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Constant-time bearer-token comparison (hash both sides so length never
340
+ * leaks through timingSafeEqual's equal-length requirement).
341
+ */
342
+ function tokenMatches(supplied, expected) {
343
+ const a = crypto.createHash('sha256').update(String(supplied)).digest();
344
+ const b = crypto.createHash('sha256').update(String(expected)).digest();
345
+ return crypto.timingSafeEqual(a, b);
346
+ }
347
+
348
+ /** Loopback binds are the only ones allowed to run without auth. */
349
+ function isLoopbackBind(bind) {
350
+ return bind === '127.0.0.1' || bind === '::1' || bind === 'localhost';
351
+ }
352
+
353
+ /**
354
+ * Read a request body with a hard size cap.
355
+ *
356
+ * @returns {Promise<Buffer>} Rejects with { statusCode: 413 } when over cap
357
+ */
358
+ function readBody(req, maxBytes) {
359
+ return new Promise((resolve, reject) => {
360
+ const chunks = [];
361
+ let size = 0;
362
+ let rejected = false;
363
+ req.on('data', (chunk) => {
364
+ if (rejected) return; // over cap: keep draining so the 413 can flush
365
+ size += chunk.length;
366
+ if (size > maxBytes) {
367
+ rejected = true;
368
+ chunks.length = 0;
369
+ const err = new Error(`Request body exceeds the ${maxBytes} byte cap`);
370
+ err.statusCode = 413;
371
+ // Do NOT destroy the socket here — the structured 413 still has to
372
+ // reach the client. The handler responds with Connection: close.
373
+ reject(err);
374
+ return;
375
+ }
376
+ chunks.push(chunk);
377
+ });
378
+ req.on('end', () => { if (!rejected) resolve(Buffer.concat(chunks)); });
379
+ req.on('error', (err) => { if (!rejected) reject(err); });
380
+ });
381
+ }
382
+
383
+ function sendJSON(res, status, body, extraHeaders = {}) {
384
+ // The socket may already be gone (client abort, over-cap upload) —
385
+ // a failed error-response write must never crash the server.
386
+ try {
387
+ if (res.writableEnded || res.destroyed) return;
388
+ res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
389
+ res.end(JSON.stringify(body));
390
+ } catch {
391
+ /* response socket unusable — nothing left to say */
392
+ }
393
+ }
394
+
395
+ function sendError(res, status, code, message, extra = {}, extraHeaders = {}) {
396
+ sendJSON(res, status, { error: { code, message }, ...extra }, extraHeaders);
397
+ }
398
+
399
+ // -----------------------------------------------------------------
400
+ // The /translate handler
401
+ // -----------------------------------------------------------------
402
+
403
+ /**
404
+ * Screen the raw `keys` payload from a request.
405
+ *
406
+ * Request data must never reach the pipeline unvetted: unsafe key names
407
+ * (prototype pollution), non-string values, and absurd key names are
408
+ * rejected PER KEY so one bad entry doesn't sink an honest batch.
409
+ *
410
+ * @param {object} rawKeys - Parsed request `keys` object
411
+ * @returns {{ payload: object, keyNames: string[], rejected: object }}
412
+ */
413
+ function screenRequestKeys(rawKeys) {
414
+ // Null-prototype containers: indexing a PLAIN object with a key named
415
+ // "__proto__" hits Object.prototype's setter instead of creating a
416
+ // property — the very record of rejecting "__proto__" would silently
417
+ // vanish (and swap the container's prototype). JSON.parse creates such
418
+ // keys as own properties, so hostile bodies genuinely reach this point.
419
+ const payload = Object.create(null);
420
+ const keyNames = [];
421
+ const rejected = Object.create(null);
422
+ for (const key of Object.keys(rawKeys)) {
423
+ const value = rawKeys[key];
424
+ if (key.length > MAX_KEY_NAME_LENGTH) {
425
+ rejected[key.slice(0, 80) + '…'] = { message: `key name exceeds ${MAX_KEY_NAME_LENGTH} characters` };
426
+ continue;
427
+ }
428
+ if (isUnsafeKey(key)) {
429
+ rejected[key] = { message: 'unsafe key name (reserved object property)' };
430
+ continue;
431
+ }
432
+ if (typeof value !== 'string' || value.length === 0) {
433
+ rejected[key] = { message: 'value must be a non-empty string' };
434
+ continue;
435
+ }
436
+ payload[key] = value;
437
+ keyNames.push(key);
438
+ }
439
+ return { payload, keyNames, rejected };
440
+ }
441
+
442
+ /**
443
+ * Handle one POST /translate request through the owner's real pipeline.
444
+ *
445
+ * @param {object} ctx - Server context (runtime, caps, session ledger)
446
+ * @param {object} body - Parsed request body
447
+ * @param {http.ServerResponse} res
448
+ */
449
+ async function handleTranslate(ctx, body, res) {
450
+ const { runtime, methodName, maxCostPerRequest, maxSessionCost, session } = ctx;
451
+ const startedAt = Date.now();
452
+
453
+ const sourceLocale = body.source_locale;
454
+ const targetLocale = body.target_locale;
455
+ if (typeof sourceLocale !== 'string' || sourceLocale.length === 0
456
+ || typeof targetLocale !== 'string' || targetLocale.length === 0) {
457
+ return sendError(res, 400, 'invalid_request', 'source_locale and target_locale are required strings.');
458
+ }
459
+
460
+ // Method routing: this server serves exactly ONE named method (plus the
461
+ // client default). Anything else is honestly "not found" — the consumer
462
+ // is pointed at the wrong server or an outdated manifest.
463
+ const requestedMethod = body.method == null ? 'default' : String(body.method);
464
+ if (requestedMethod !== 'default' && requestedMethod !== methodName) {
465
+ return sendError(res, 404, 'method_not_found',
466
+ `Method "${requestedMethod}" is not served here. This server serves "${methodName}" (or "default").`);
467
+ }
468
+
469
+ if (body.keys == null || typeof body.keys !== 'object' || Array.isArray(body.keys)) {
470
+ return sendError(res, 400, 'invalid_request', '"keys" must be an object of { key: sourceString }.');
471
+ }
472
+ const requestedCount = Object.keys(body.keys).length;
473
+ if (requestedCount === 0) {
474
+ return sendError(res, 400, 'invalid_request', '"keys" must contain at least one entry.');
475
+ }
476
+ if (requestedCount > ctx.maxKeysPerRequest) {
477
+ return sendError(res, 400, 'too_many_keys',
478
+ `Request has ${requestedCount} keys; this server accepts at most ${ctx.maxKeysPerRequest} per request.`);
479
+ }
480
+
481
+ const { payload, keyNames, rejected } = screenRequestKeys(body.keys);
482
+ if (keyNames.length === 0) {
483
+ return sendError(res, 400, 'no_translatable_keys',
484
+ 'No key in the request survived screening (values must be non-empty strings; reserved key names are refused).',
485
+ { errors: rejected });
486
+ }
487
+
488
+ // Pair lookup — the request only selects among pairs the OWNER configured.
489
+ // Locale strings are used as Map keys only, never as filesystem paths.
490
+ const pairKey = `${sourceLocale}:${targetLocale}`;
491
+ const pairConfig = runtime.resolvedPairs.get(pairKey);
492
+ if (!pairConfig) {
493
+ const supported = [...runtime.resolvedPairs.keys()].sort().join(', ');
494
+ return sendError(res, 400, 'unsupported_pair',
495
+ `Pair "${pairKey}" is not configured on this server. Served pairs: ${supported}.`);
496
+ }
497
+
498
+ // ── Cost guard (reuses the sync cost machinery) ─────────────────────
499
+ // TM hits are $0 by construction, so the gate prices only the misses.
500
+ // Unknown estimates under a cap refuse the request: unknown ≠ free.
501
+ const tmKey = tmMethodKey(pairConfig);
502
+ const { misses } = partitionByTM(runtime.tm, payload, keyNames, targetLocale, tmKey);
503
+ let estimate = null;
504
+ if (misses.length > 0) {
505
+ estimate = await estimateCost(misses.length, pairConfig);
506
+ const estimated = estimate?.estimatedCost ?? null;
507
+
508
+ if (maxCostPerRequest !== null || maxSessionCost !== null) {
509
+ if (estimated === null) {
510
+ return sendError(res, 402, 'cost_unknown',
511
+ `This server enforces a cost cap, but method "${pairConfig.method}" has unknown pricing for ` +
512
+ `${misses.length} uncached key(s) — refusing (unknown is not free).`);
513
+ }
514
+ if (maxCostPerRequest !== null && estimated > maxCostPerRequest) {
515
+ return sendError(res, 402, 'cost_cap_exceeded',
516
+ `Estimated request cost $${estimated.toFixed(4)} exceeds the per-request cap $${maxCostPerRequest.toFixed(4)}.`,
517
+ { estimated_cost_usd: estimated, max_cost_per_request_usd: maxCostPerRequest });
518
+ }
519
+ if (maxSessionCost !== null && session.spendUsd + estimated > maxSessionCost) {
520
+ return sendError(res, 402, 'session_ceiling_exceeded',
521
+ `Estimated request cost $${estimated.toFixed(4)} would push session spend past the ` +
522
+ `$${maxSessionCost.toFixed(4)} ceiling (spent so far: ~$${session.spendUsd.toFixed(4)}). Restart the server to reset.`,
523
+ { estimated_cost_usd: estimated, session_spend_usd: session.spendUsd, session_ceiling_usd: maxSessionCost });
524
+ }
525
+ }
526
+ }
527
+
528
+ // ── The real pipeline: TM partition → API → quality gate → TM store ──
529
+ let result;
530
+ try {
531
+ result = await translateAndValidate(keyNames, payload, pairConfig, pairKey, {
532
+ apiKey: runtime.apiKey,
533
+ tm: runtime.tm,
534
+ targetCode: targetLocale,
535
+ onProgress: null,
536
+ });
537
+ } catch (err) {
538
+ output.error(`[serve] ${pairKey}: pipeline error — ${err.message}`);
539
+ return sendError(res, 500, 'internal_error', 'Translation pipeline failed. See server logs.');
540
+ }
541
+ if (result.apiCalled) output.progress(' done\n');
542
+
543
+ // Session ledger: count the estimate for any request that actually hit
544
+ // the upstream API (conservative — estimates deliberately err high).
545
+ if (result.apiCalled && estimate?.estimatedCost != null) {
546
+ session.spendUsd += estimate.estimatedCost;
547
+ }
548
+
549
+ // Persist TM mutations (stores AND evictions) after every request so a
550
+ // crash never loses paid, gate-validated translations. Atomic write.
551
+ if (isTMDirty(runtime.tm)) {
552
+ try {
553
+ saveTM(runtime.cwd, runtime.tm);
554
+ } catch (err) {
555
+ output.warn(`[serve] TM save failed: ${err.message}`);
556
+ }
557
+ }
558
+
559
+ // Upstream produced nothing at all → the server has nothing honest to
560
+ // return. 5xx so the api.js client retries (transient upstream trouble).
561
+ if (!result.translated && result.apiReturnedNull && result.failures.length === 0) {
562
+ return sendError(res, 502, 'upstream_failed',
563
+ `Translation method "${pairConfig.method}" returned no results for ${pairKey}.`);
564
+ }
565
+
566
+ const translations = {};
567
+ let keptWorkingScript = 0;
568
+ if (result.translated) {
569
+ // Script conversion only when the pair's resolution asked for it — same
570
+ // rule as sync (see lib/scripts.js resolveTargetScript). On unmapped
571
+ // letters the whole value stays in the working script rather than mixing
572
+ // scripts, and the response meta says so.
573
+ const scriptConverterKey = pairConfig.scriptResolution?.converterKey || null;
574
+ for (const [key, value] of Object.entries(result.translated)) {
575
+ if (scriptConverterKey && typeof value === 'string') {
576
+ const prepared = applyScriptFallback(value, pairConfig.scriptFallback);
577
+ const { converted, unmapped } = convertScript(prepared, scriptConverterKey);
578
+ if (unmapped.length === 0) {
579
+ translations[key] = converted;
580
+ } else {
581
+ translations[key] = value;
582
+ keptWorkingScript++;
583
+ output.warn(`[serve] ${pairKey}: key "${key}" kept in working script — unmapped letter(s): ${unmapped.join(', ')}`);
584
+ }
585
+ } else {
586
+ translations[key] = value;
587
+ }
588
+ }
589
+ // Terminology enforcement is advisory, same as sync: log, don't reject.
590
+ if (pairConfig.coachingData?.dictionary) {
591
+ const { violations } = verifyTerminology(result.translated, payload, pairConfig.coachingData.dictionary);
592
+ if (violations.length > 0) logTermViolations(violations, pairKey);
593
+ }
594
+ }
595
+
596
+ // Per-key errors: screening rejections + quality-gate failures. A key that
597
+ // failed the gate (after the one feedback retry) is REPORTED, never
598
+ // replaced with degraded output. (Spread copies own properties — including
599
+ // a rejected "__proto__" — as data properties; only assignment is unsafe.)
600
+ const errors = { ...rejected };
601
+ for (const f of result.failures) {
602
+ errors[f.key] = { message: `quality gate: ${f.reason}` };
603
+ }
604
+
605
+ const translatedCount = Object.keys(translations).length;
606
+ const errorCount = Object.keys(errors).length;
607
+
608
+ // Honest cost meta: fully-TM-served requests are a KNOWN $0; API misses
609
+ // carry the pre-run estimate (upper bound), never a fabricated actual.
610
+ let costUsd = null;
611
+ let costBasis = 'unknown';
612
+ if (misses.length === 0) {
613
+ costUsd = 0;
614
+ costBasis = 'tm-cache';
615
+ } else if (estimate?.estimatedCost != null) {
616
+ costUsd = estimate.estimatedCost;
617
+ costBasis = 'estimate';
618
+ }
619
+
620
+ const meta = {
621
+ served_by: `champollion-serve/${CLI_VERSION}`,
622
+ method: methodName,
623
+ method_type: pairConfig.method || 'llm',
624
+ model: pairConfig.model || null,
625
+ quality_tier: pairConfig.qualityTier || 'standard',
626
+ source_locale: sourceLocale,
627
+ target_locale: targetLocale,
628
+ requested: requestedCount,
629
+ translated: translatedCount,
630
+ failed: errorCount,
631
+ tm_hits: result.tmHitCount,
632
+ // Script conversion status for this pair: which script was written and
633
+ // how many values stayed in the working script (unmapped letters).
634
+ script_conversion: {
635
+ script: pairConfig.scriptResolution?.script ?? null,
636
+ converts: !!pairConfig.scriptResolution?.converterKey,
637
+ kept_working_script: keptWorkingScript,
638
+ },
639
+ cost_usd: costUsd,
640
+ cost_basis: costBasis,
641
+ ...(maxSessionCost !== null && {
642
+ session_spend_usd: session.spendUsd,
643
+ session_ceiling_usd: maxSessionCost,
644
+ }),
645
+ };
646
+
647
+ let status;
648
+ let responseBody;
649
+ if (translatedCount === 0) {
650
+ // Everything failed the quality gate — a structured refusal, not output.
651
+ status = 422;
652
+ responseBody = {
653
+ error: {
654
+ code: 'quality_gate_failed',
655
+ message: `All ${errorCount} key(s) were rejected by the deterministic quality gate.`,
656
+ },
657
+ errors,
658
+ meta,
659
+ };
660
+ } else if (errorCount > 0) {
661
+ status = 207; // partial success — api.js logs per-key warnings and keeps the rest
662
+ responseBody = { translations, errors, meta };
663
+ } else {
664
+ status = 200;
665
+ responseBody = { translations, meta };
666
+ }
667
+
668
+ session.requests += 1;
669
+ output.info(
670
+ `[serve] ${pairKey} ${requestedCount} key(s) → ${status} in ${Date.now() - startedAt}ms ` +
671
+ `(tm ${result.tmHitCount}, api ${misses.length}, failed ${errorCount})`
672
+ );
673
+ return sendJSON(res, status, responseBody);
674
+ }
675
+
676
+ // -----------------------------------------------------------------
677
+ // Server lifecycle
678
+ // -----------------------------------------------------------------
679
+
680
+ /**
681
+ * Start the serve HTTP server.
682
+ *
683
+ * @param {object} options
684
+ * @param {string} options.cwd - Owner project root
685
+ * @param {object} [options.cliArgs] - CLI args forwarded to config resolution
686
+ * @param {object} [options.runtime] - Pre-built runtime (tests); built from cwd otherwise
687
+ * @param {string} [options.bind] - Bind address (default 127.0.0.1)
688
+ * @param {number} [options.port] - Port (default 1822; 0 = ephemeral)
689
+ * @param {string|null} [options.token] - Bearer token (required unless noAuth on loopback)
690
+ * @param {boolean} [options.noAuth] - Disable auth — loopback binds only
691
+ * @param {number} [options.rateLimitPerMin] - Per-IP /translate limit (0 disables)
692
+ * @param {number} [options.maxBodyBytes] - Request body cap
693
+ * @param {number} [options.maxKeysPerRequest] - Per-request key cap
694
+ * @param {number|null} [options.maxCostPerRequest] - USD cap per request
695
+ * @param {number|null} [options.maxSessionCost] - USD ceiling across the process lifetime
696
+ * @param {string} [options.methodName] - Served method name (manifest name)
697
+ * @returns {Promise<{ server, port, url, session, runtime, close }>}
698
+ */
699
+ async function startServeServer(options) {
700
+ const {
701
+ cwd,
702
+ cliArgs = {},
703
+ bind = SERVE_DEFAULTS.bind,
704
+ port = SERVE_DEFAULTS.port,
705
+ token = null,
706
+ noAuth = false,
707
+ rateLimitPerMin = SERVE_DEFAULTS.rateLimitPerMin,
708
+ maxBodyBytes = SERVE_DEFAULTS.maxBodyBytes,
709
+ maxKeysPerRequest = SERVE_DEFAULTS.maxKeysPerRequest,
710
+ maxCostPerRequest = null,
711
+ maxSessionCost = null,
712
+ } = options;
713
+
714
+ // ── Auth invariants — enforced in the ENGINE so no caller can skip them ──
715
+ if (noAuth && !isLoopbackBind(bind)) {
716
+ throw new Error(
717
+ `--no-auth is only allowed on a loopback bind (127.0.0.1 / ::1 / localhost), got --bind ${bind}. ` +
718
+ 'An unauthenticated server on a reachable interface lets anyone spend your upstream API budget.'
719
+ );
720
+ }
721
+ if (!noAuth && !token) {
722
+ throw new Error(
723
+ 'Refusing to start without a bearer token. Set CHAMPOLLION_SERVE_TOKEN (env or .env.local), ' +
724
+ 'pass --token <secret>, or opt out explicitly with --no-auth --bind 127.0.0.1 (loopback only).'
725
+ );
726
+ }
727
+ if (!noAuth && token.length < SERVE_DEFAULTS.minTokenLength) {
728
+ throw new Error(
729
+ `Bearer token must be at least ${SERVE_DEFAULTS.minTokenLength} characters — short tokens are ` +
730
+ 'brute-forceable even behind the rate limit. Try: openssl rand -hex 24'
731
+ );
732
+ }
733
+
734
+ const runtime = options.runtime || await createServeRuntime({ cwd, cliArgs });
735
+ const methodName = options.methodName || deriveMethodName(cwd);
736
+ const limiter = new RateLimiter(rateLimitPerMin);
737
+ const session = { spendUsd: 0, requests: 0, startedAt: new Date().toISOString() };
738
+
739
+ const ctx = {
740
+ runtime,
741
+ methodName,
742
+ maxKeysPerRequest,
743
+ maxCostPerRequest,
744
+ maxSessionCost,
745
+ session,
746
+ };
747
+
748
+ const server = http.createServer(async (req, res) => {
749
+ try {
750
+ const url = new URL(req.url, 'http://placeholder.invalid');
751
+
752
+ // Health probe: unauthenticated, minimal, does no translation work.
753
+ if (req.method === 'GET' && url.pathname === '/health') {
754
+ return sendJSON(res, 200, { ok: true, service: 'champollion-serve', version: CLI_VERSION });
755
+ }
756
+
757
+ if (url.pathname !== '/translate') {
758
+ return sendError(res, 404, 'not_found', `No route for ${req.method} ${url.pathname}. POST /translate is the contract endpoint.`);
759
+ }
760
+ if (req.method !== 'POST') {
761
+ return sendError(res, 405, 'method_not_allowed', 'Use POST /translate.', {}, { Allow: 'POST' });
762
+ }
763
+
764
+ // Rate limit BEFORE auth so token guessing is throttled too.
765
+ // Socket address only — X-Forwarded-For is client-controlled.
766
+ const ip = req.socket.remoteAddress || 'unknown';
767
+ const rate = limiter.check(ip);
768
+ if (!rate.allowed) {
769
+ return sendError(res, 429, 'rate_limited',
770
+ `Rate limit of ${rateLimitPerMin} requests/minute per IP exceeded.`,
771
+ {}, { 'Retry-After': String(rate.retryAfterSec) });
772
+ }
773
+
774
+ if (!noAuth) {
775
+ const header = req.headers['authorization'] || '';
776
+ const match = /^Bearer\s+(.+)$/.exec(header);
777
+ if (!match || !tokenMatches(match[1].trim(), token)) {
778
+ return sendError(res, 401, 'unauthorized',
779
+ 'Missing or invalid bearer token. Send "Authorization: Bearer <token>" ' +
780
+ '(the CHAMPOLLION_API_KEY on the consumer side).');
781
+ }
782
+ }
783
+
784
+ let raw;
785
+ try {
786
+ raw = await readBody(req, maxBodyBytes);
787
+ } catch (err) {
788
+ if (err.statusCode === 413) {
789
+ // Connection: close ends the socket after the response flushes,
790
+ // so the client's remaining upload is discarded, not read.
791
+ return sendError(res, 413, 'payload_too_large', err.message, {}, { Connection: 'close' });
792
+ }
793
+ throw err;
794
+ }
795
+
796
+ let body;
797
+ try {
798
+ body = JSON.parse(raw.toString('utf-8'));
799
+ } catch {
800
+ return sendError(res, 400, 'invalid_json', 'Request body is not valid JSON.');
801
+ }
802
+ if (body == null || typeof body !== 'object' || Array.isArray(body)) {
803
+ return sendError(res, 400, 'invalid_request', 'Request body must be a JSON object.');
804
+ }
805
+
806
+ await handleTranslate(ctx, body, res);
807
+ } catch (err) {
808
+ output.error(`[serve] request error: ${err.message}`);
809
+ sendError(res, 500, 'internal_error', 'Internal server error. See server logs.');
810
+ }
811
+ });
812
+
813
+ await new Promise((resolve, reject) => {
814
+ server.once('error', reject);
815
+ server.listen(port, bind, () => {
816
+ server.removeListener('error', reject);
817
+ resolve();
818
+ });
819
+ });
820
+
821
+ const actualPort = server.address().port;
822
+
823
+ const close = () => new Promise((resolve) => {
824
+ limiter.stop();
825
+ // Persist any TM entries a final in-flight request stored.
826
+ if (isTMDirty(runtime.tm)) {
827
+ try { saveTM(runtime.cwd, runtime.tm); } catch { /* best effort on shutdown */ }
828
+ }
829
+ server.closeAllConnections?.();
830
+ server.close(() => resolve());
831
+ });
832
+
833
+ return {
834
+ server,
835
+ port: actualPort,
836
+ url: `http://${isLoopbackBind(bind) ? '127.0.0.1' : bind}:${actualPort}/translate`,
837
+ session,
838
+ runtime,
839
+ methodName,
840
+ close,
841
+ };
842
+ }
843
+
844
+ export {
845
+ SERVE_DEFAULTS,
846
+ createServeRuntime,
847
+ deriveMethodName,
848
+ minQualityTier,
849
+ resolveManifestEndpoint,
850
+ buildServeManifest,
851
+ startServeServer,
852
+ screenRequestKeys,
853
+ isLoopbackBind,
854
+ };