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.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- 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
|
+
};
|