Translator API key management.

This commit is contained in:
2026-09-03 19:23:56 +00:00
parent eb2e8f8273
commit 38af57218a
5 changed files with 178 additions and 46 deletions
+9 -8
View File
@@ -305,14 +305,15 @@ An external machine-translation service connects over WebSocket at
`/_translate/{key}` — deliberately **not** under `/_api`: the SSO `/_translate/{key}` — deliberately **not** under `/_api`: the SSO
forward-auth does not cover that route, and the key in the path is the forward-auth does not cover that route, and the key in the path is the
access control. Keys live in `Data.translate_keys` (key -> display name) — access control. Keys live in `Data.translate_keys` (key -> display name) —
12 lowercase alphanumeric characters each, the first one generated at 12 lowercase alphanumeric characters each; the first is generated at
database bootstrap and multiple keys reserved for future management (e.g. database bootstrap, further ones are managed in the editor's lang tab
a web UI). The full WS URL(s) are printed in the startup log (add/rename/delete ride the `PUT /_api/settings` round-trip; the name is
(`ws://localhost:{port}/_translate/{key}` locally, an inline display label only). The full WS URL(s) are printed in the
`wss://{hostname}/_translate/{key}` on a public hostname) and the keys are startup log (`ws://localhost:{port}/_translate/{key}` locally,
surfaced to the admin in `GET /_api/settings` as `translate_keys`. An `wss://{hostname}/_translate/{key}` on a public hostname) and shown in the
unknown or empty key rejects the handshake (close-before-accept → HTTP lang tab as click-to-copy links; the keys are also surfaced in
403). Transactions storing results record the connecting key as the kanta `GET /_api/settings` as `translate_keys`. An unknown or empty key rejects
the handshake (close-before-accept → HTTP 403). Transactions storing results record the connecting key as the kanta
transaction `user`. transaction `user`.
Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`; Frames are JSON-encoded tagged msgspec structs (`pagerite/translate.py`;
+155 -26
View File
@@ -1,16 +1,21 @@
<script setup> <script setup>
// Lang tab: the site-wide translation target languages (translate_langs) // Lang tab: the site-wide translation target languages (translate_langs)
// and the translator service WebSocket URL(s) (translate_keys). ALL // and the translator service keys (translate_keys) with their WebSocket
// languages are listed, English included — a page whose primary language // URLs. ALL languages are listed, English included — a page whose primary
// (Node.language, configured per row in the structure tab, inherited down // language (Node.language, configured per row in the structure tab,
// the hierarchy) differs can be translated INTO any other. Flag clicks // inherited down the hierarchy) differs can be translated INTO any other.
// toggle and save immediately; the settings round-trip re-reads the // Flag clicks toggle and save immediately; the settings round-trip
// payload, so this tab only ever changes translate_langs. The settings // re-reads the payload, so this tab only ever changes translate_langs. The
// write's invalidation hook kicks the translation dispatcher. The refresh // settings write's invalidation hook kicks the translation dispatcher. The
// button drops all machine translations (user patches are kept), making // refresh button drops all machine translations (user patches are kept),
// the dispatcher re-translate everything. // making the dispatcher re-translate everything. Translator keys are
// managed inline ( add, name edit, ✕ delete); new keys are generated
// here in the server's format and everything rides the settings
// round-trip. Clicking a key copies its full URL (following ws:// would
// fail).
import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue' import { computed, onActivated, onMounted, onUnmounted, ref } from 'vue'
import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs' import { LANG_GROUPS, TRANSLATABLE, flagFor, langName } from './langs'
import { copyList } from './analytics/format.js'
import { dropPageCache } from './swapdoc' import { dropPageCache } from './swapdoc'
defineProps({ pagePath: { type: String, default: '' } }) defineProps({ pagePath: { type: String, default: '' } })
@@ -21,6 +26,16 @@ const saveError = ref('')
const selected = ref(new Set()) const selected = ref(new Set())
const keyUrls = ref([]) const keyUrls = ref([])
// Full WebSocket URL for a key. New keys are generated right here: 12
// lowercase alphanumerics, the server-side format (state._KEY_ALPHABET).
const wsUrl = (key) =>
`${location.origin.replace(/^http/, 'ws')}/_translate/${key}`
const KEY_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
const newKey = () =>
[...crypto.getRandomValues(new Uint8Array(12))]
.map((b) => KEY_ALPHABET[b % KEY_ALPHABET.length])
.join('')
// The toggleable targets: every translatable language, laid out in // The toggleable targets: every translatable language, laid out in
// geographic/cultural groups (one row each) rather than alphabetized — // geographic/cultural groups (one row each) rather than alphabetized —
// related languages sit together (a node's own primary is excluded per // related languages sit together (a node's own primary is excluded per
@@ -52,9 +67,8 @@ onMounted(async () => {
try { try {
const s = await (await fetch('/_api/settings')).json() const s = await (await fetch('/_api/settings')).json()
selected.value = new Set(s.translate_langs || []) selected.value = new Set(s.translate_langs || [])
const wsBase = location.origin.replace(/^http/, 'ws')
keyUrls.value = Object.entries(s.translate_keys || {}) keyUrls.value = Object.entries(s.translate_keys || {})
.map(([key, name]) => ({ name, url: `${wsBase}/_translate/${key}` })) .map(([key, name]) => ({ key, name, url: wsUrl(key) }))
} catch { /* keep defaults */ } } catch { /* keep defaults */ }
}) })
@@ -100,6 +114,39 @@ async function refresh() {
refreshing.value = false refreshing.value = false
} }
} }
// Key management rides the settings round-trip, like toggle() above:
// mutate keyUrls, then PUT the whole settings payload with the new
// translate_keys. adds a fresh unnamed key, names save on every
// keystroke (@input — spamming the server is fine), ✕ deletes without
// confirmation.
async function saveKeys() {
try {
const s = await (await fetch('/_api/settings')).json()
const res = await fetch('/_api/settings', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...s,
translate_keys: Object.fromEntries(keyUrls.value.map((k) => [k.key, k.name])),
}),
})
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
} catch {
saveError.value = '⚠️ changes could not be saved'
}
}
function addKey() {
const key = newKey()
keyUrls.value.push({ key, name: '', url: wsUrl(key) })
saveKeys()
}
function removeKey(k) {
keyUrls.value = keyUrls.value.filter((x) => x.key !== k.key)
saveKeys()
}
</script> </script>
<template> <template>
@@ -129,28 +176,37 @@ async function refresh() {
<section class="block"> <section class="block">
<div class="block-head"> <div class="block-head">
<span class="field-label">translations</span> <span class="field-label">Translator API</span>
<small class="muted">deleting re-translates everything; user edits are kept</small>
</div> </div>
<div v-for="k in keyUrls" :key="k.key" class="key-row">
<a
:href="k.url"
class="key-link"
title="click to copy the URL"
@click.prevent="copyList(k.url, $event)"
>{{ k.key }}</a>
<input
v-model="k.name"
type="text"
class="edit key-name"
title="display name"
@input="saveKeys()"
>
<button type="button" class="act del" title="delete key" @click="removeKey(k)"></button>
</div>
<div class="add-row">
<button type="button" class="add" title="new translator key" @click="addKey()"> API key</button>
</div>
<p><small class="muted">AI translator agents can connect with the API keys to do machine translations to your selected languages. Click the button below to delete all translations and start over. User edits are kept.</small></p>
<div class="refresh-row">
<button <button
type="button" type="button"
class="refresh-btn" class="refresh-btn"
:disabled="refreshing" :disabled="refreshing"
title="delete all machine translations and let the translator re-fill them"
@click="refresh" @click="refresh"
> >
{{ refreshing ? 'refreshing…' : 'refresh all translations' }} {{ refreshing ? 'Reseting…' : 'Reset' }}
</button> </button>
</section>
<section v-if="keyUrls.length" class="block">
<div class="block-head">
<span class="field-label">translator service</span>
<small class="muted">connect scripts/translator.py to</small>
</div>
<div v-for="k in keyUrls" :key="k.url" class="key-row">
<code>{{ k.url }}</code>
<small class="muted">{{ k.name }}</small>
</div> </div>
</section> </section>
</div> </div>
@@ -252,10 +308,83 @@ async function refresh() {
gap: 0.6rem; gap: 0.6rem;
} }
.key-row code { /* Real links (handy for right-click/drag) showing just the key, but the
click copies the full URL instead of following — ws:// would fail to
navigate. Normal text color, not link-styled; position: relative
anchors the "Copied!" popup (analytics/format.js). */
.key-link {
position: relative;
color: var(--text);
font-family: var(--font-code);
user-select: all; user-select: all;
} }
.refresh-row {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
/* Name input / ✕ / follow the structure tab's conventions: inputs stay
borderless until interacted with, glyph buttons redden / solidify on
hover. */
.key-name {
flex: 0 0 9rem;
}
.edit {
font: inherit;
font-size: 0.85rem;
padding: 0.1rem 0.4rem;
background: transparent;
color: var(--text);
border: 1px solid transparent;
border-radius: 4px;
min-width: 0;
}
.edit:hover {
border-color: var(--line);
}
.edit:focus {
background: var(--bg);
border-color: var(--accent);
outline: none;
}
.act {
padding: 0 0.25rem;
background: none;
border: none;
color: var(--muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.del:hover {
color: #e06c75;
}
.add-row {
display: flex;
align-items: center;
}
.add {
padding: 0 0.3rem;
background: none;
border: none;
font-size: 0.9rem;
cursor: pointer;
opacity: 0.5;
}
.add:hover {
opacity: 1;
}
.refresh-btn { .refresh-btn {
align-self: flex-start; align-self: flex-start;
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
+3
View File
@@ -298,6 +298,7 @@ class SettingsIn(BaseModel):
brand_html: str = "" brand_html: str = ""
transition: str = "cube" transition: str = "cube"
translate_langs: list[str] | None = None # None keeps the current set translate_langs: list[str] | None = None # None keeps the current set
translate_keys: dict[str, str] | None = None # None keeps the current keys
@router.put("/_api/settings", status_code=204) @router.put("/_api/settings", status_code=204)
@@ -318,6 +319,8 @@ async def put_settings(settings: SettingsIn, request: Request) -> None:
for lang in settings.translate_langs for lang in settings.translate_langs
if (tag := i18n.base_tag(lang)) if (tag := i18n.base_tag(lang))
} }
if settings.translate_keys is not None:
data.translate_keys = settings.translate_keys
_invalidate_pages() _invalidate_pages()
+2 -2
View File
@@ -104,8 +104,8 @@ class Data(msgspec.Struct):
#: API keys gating the translator service WebSocket (/_translate/{key}; #: API keys gating the translator service WebSocket (/_translate/{key};
#: the external forward-auth does not cover that route): key -> display #: the external forward-auth does not cover that route): key -> display
#: name. Keys are 12 lowercase alphanumeric characters; the first is #: name. Keys are 12 lowercase alphanumeric characters; the first is
#: generated at database bootstrap (see app.py), multiple keys are a #: generated at database bootstrap, more are managed in the editor
#: future reservation (e.g. managed via a web interface). #: shell's lang tab (via /_api/settings).
translate_keys: dict[str, str] = {} translate_keys: dict[str, str] = {}
#: Wanted target languages for the translator service (presence-keys, #: Wanted target languages for the translator service (presence-keys,
#: value always True). The dispatcher offers jobs only in the #: value always True). The dispatcher offers jobs only in the
+3 -4
View File
@@ -342,6 +342,7 @@ def _seed(data: Data) -> None:
#: Translator key format: 12 lowercase alphanumeric characters — not #: Translator key format: 12 lowercase alphanumeric characters — not
#: brute-forceable over a WebSocket handshake, still human-manageable. #: brute-forceable over a WebSocket handshake, still human-manageable.
#: The editor's lang tab generates further keys in the same format.
_KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" _KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
@@ -349,10 +350,8 @@ _KEY_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
def _translator_defaults(data: Data) -> None: def _translator_defaults(data: Data) -> None:
"""Translator defaults on database creation: the first service key and """Translator defaults on database creation: the first service key and
the wanted target languages (Spanish and Chinese — English is the the wanted target languages (Spanish and Chinese — English is the
original language, never a translation target). original language, never a translation target). Further keys are
managed in the editor shell's lang tab."""
Keys are a dict (key -> display name) with the future reservation that
multiple keys could be managed (e.g. via a web interface)."""
key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12)) key = "".join(secrets.choice(_KEY_ALPHABET) for _ in range(12))
data.translate_keys[key] = "default" data.translate_keys[key] = "default"
data.translate_langs = {"es": True, "zh": True} data.translate_langs = {"es": True, "zh": True}