docs: update RAM section, add explorer delete buttons, add /api/ram-install

- README/README_RUS: RAM tab now describes Explore Card + Install Package operations,
  explorer view with inline Delete/Delete All buttons for Applications and ELFs
- docs/api.md: add full /api/ram-install endpoint documentation
- index.html: remove Delete from dropdown, add ramDeleteFromExplorer() with confirm dialog,
  add Delete buttons in explorer for Apps and ELFs (ELFs get cascade option)
This commit is contained in:
2026-08-31 09:09:20 +03:00
parent 8de81a6a33
commit b5f871641f
5 changed files with 339 additions and 26 deletions
+21 -2
View File
@@ -132,9 +132,28 @@ Error Action supports the same builder (DISPLAY TEXT, PLAY TONE).
## RAM Tab
CLA = `80` (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management.
CLA = `80` (GlobalPlatform Card Specification v2.3.1). Remote Application Management operations for card content management over SCP80.
### Commands
### Operations
The RAM subtab offers two operations selected from the **Operation** dropdown:
| Operation | Description |
|---|---|
| **Explore Card (all GP data)** | Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item **Delete** buttons. |
| **Install Package (.cap file)** | Sends a `.cap` file to the card via the server: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
### Explorer View
After "Explore Card" runs, the explorer view displays:
- **ISD** — AID, lifecycle, privileges (no delete; the ISD cannot be removed)
- **Applications** — AID, lifecycle, privileges, associated ELF/SD. Each has a **Delete** button (GP `DELETE` by AID).
- **Executable Load Files** — AID, lifecycle, version, module AIDs. Each has **Delete** (ELF only) and **Delete All** (cascade: ELF + modules + installed Applications, P2=0x80) buttons.
Delete confirms via a browser prompt before sending the GP `DELETE` command via SCP80. The explorer auto-refreshes after a successful deletion.
### GP Commands Reference
| Command | INS | P1 | Description |
|---|---|---|---|
+21 -2
View File
@@ -127,9 +127,28 @@ CLA = `00` (ETSI TS 102 221). Те же команды, что и SIM, но SELE
## Вкладка RAM
CLA = `80` (GlobalPlatform v2.3.1). Команды удалённого управления приложениями.
CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление содержимым карты через SCP80.
### Команды
### Операции
В RAM-подвкладке доступны две операции через выпадающий список **Operation**:
| Операция | Описание |
|---|---|
| **Explore Card (all GP data)** | Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками **Delete** для каждого элемента. |
| **Install Package (.cap file)** | Отправка `.cap` файла на карту через сервер: INSTALL\[for load\] → LOAD ×N → INSTALL\[for install (+make selectable)\]. |
### Обзор карты (Explorer View)
После выполнения "Explore Card" отображается:
- **ISD** — AID, жизненный цикл, привилегии (без удаления; ISD нельзя удалить)
- **Приложения** — AID, жизненный цикл, привилегии, связанный ELF/SD. Каждое имеет кнопку **Delete** (GP `DELETE` по AID).
- **Executable Load Files** — AID, жизненный цикл, версии, AID модулей. Каждый имеет **Delete** (только ELF) и **Delete All** (каскадное: ELF + модули + установленные приложения, P2=0x80).
Удаление подтверждается через диалог браузера перед отправкой команды GP `DELETE` через SCP80. Обзор автоматически обновляется после успешного удаления.
### Справочник GP-команд
| Команда | INS | P1 | Описание |
|---|---|---|---|
+55
View File
@@ -32,6 +32,7 @@ connect and warns if versions are incompatible.
| `/api/send-ota` | POST | SCP80 OTA secured packet delivery |
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
| `/api/sp-verify` | POST | Verify secured packet against pySim reference |
| `/api/ram-install` | POST | Install a Java Card `.cap` file via SCP80 (INSTALL[for load] → LOAD ×N → INSTALL[for install]) |
| `/api/menu` | GET | Current STK menu (title + items + active) |
| `/api/menu-select` | POST | ENVELOPE(Menu Selection) with item_id |
| `/api/menu-respond` | POST | TERMINAL RESPONSE for paused STK command |
@@ -213,6 +214,60 @@ and the decoded SPI fields.
"diffs": [], "spi": {"counter": "counter_must_be_higher", ...}}
```
### `POST /api/ram-install`
Install a Java Card `.cap` file on the card via GlobalPlatform commands (INSTALL[for load] → LOAD ×N → INSTALL[for install (+ make selectable)]) wrapped in SCP80 secured packets. Each step is sent via ENVELOPE and its PoR is checked; the sequence aborts on the first PoR error. Requires pySim with `pySim.javacard.CapFile` and `pySim.global_platform` available on the server.
**Request body:**
```json
{
"cap_hex": "DECAFFED...",
"sd_aid": "A000000003000000",
"install_params": "C90000",
"stk_params": "",
"nv_quota": 0,
"volatile_quota": 0,
"make_selectable": true,
"spi1": "0E", "spi2": "01",
"kic": "15", "kid": "15",
"tar": "000000",
"cntr": "0000000001",
"kicKey": "D6FCC023...",
"kidKey": "1B07E7E0..."
}
```
| Field | Req | Description |
|---|---|---|
| `cap_hex` | yes | Even-length hex of the `.cap` file (zipped Java Card CAP), max 48 kB (98304 hex chars) |
| `sd_aid` | no | Security Domain AID for INSTALL[for load]; empty → default ISD `A000000003000000` |
| `install_params` | no | Hex C9 TLV install parameters; if empty, `gen_install_parameters()` is used with the quota/stk params |
| `stk_params` | no | Hex CA TLV (TS 102 226 §8.2.1.3.2.1) for SIM toolkit app-specific params |
| `nv_quota` / `volatile_quota` | no | Integer memory quotas (bytes) for `gen_install_parameters()` |
| `make_selectable` | no | If true (default), final INSTALL uses P1=`0C` (install + make selectable) |
**Response (success):**
```json
{"success": true, "failed_step": null,
"steps": [{"name": "install_for_load", "apdu": "80E60200...", "por_status": "por_ok", "sw": "9000"},
{"name": "load_0", "apdu": "80E80000...", "por_status": "por_ok", "sw": "9000"},
{"name": "install_for_install", "apdu": "80E60C00...", "por_status": "por_ok", "sw": "9000"}],
"final_cntr": "0000000004",
"load_file_aid": "A000000003000000",
"module_aid": "A000000003000000",
"application_aid": "A000000003000000"}
```
**Response (failure):**
```json
{"success": false, "failed_step": "load_1",
"steps": [{"name": "install_for_load", "por_status": "por_ok", "sw": "9000"},
{"name": "load_1", "por_status": "rc_error", "sw": null}],
"error": "..."}
```
The `steps` array contains one entry per GP command. `final_cntr` is the counter value after all successful steps (use it to update the card preset). The response is not streamed — all steps run server-side before the JSON is returned.
### `GET /api/menu`
Returns the SIM Toolkit SETUP MENU captured from the card's TERMINAL PROFILE
+29 -22
View File
@@ -891,20 +891,11 @@
<div class="mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="Operation">Operation</label>
<select id="ram-op" onchange="ramOpChanged()" class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800">
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
<option value="delete" data-l10n="Delete (DELETE)">Delete (DELETE)</option>
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
<option value="explore" data-l10n="Explore Card (all GP data)">Explore Card (all GP data)</option>
<option value="install-cap" data-l10n="Install Package (.cap file)">Install Package (.cap file)</option>
</select>
</div>
<div id="ram-del-params" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="AID to delete (hex)">AID to delete (hex)</label>
<input id="ram-del-aid" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="A000000003000000" maxlength="32">
<label class="flex items-center gap-2 mt-2">
<input type="checkbox" id="ram-del-related"> <span class="text-sm" data-l10n="Delete related objects">Delete related objects</span>
</label>
</div>
<div id="ram-install-params" class="hidden mb-3">
<label class="block mb-1 text-sm font-medium text-gray-700 dark:text-slate-300" data-l10n="CAP file (max 48 kB)">CAP file (max 48 kB)</label>
<input type="file" id="ram-cap-file" accept=".cap,.zip" class="w-full text-sm text-gray-600 dark:text-slate-300">
@@ -4259,7 +4250,6 @@ function ramApplyCard(idx) {
function ramOpChanged() {
const op = document.getElementById('ram-op').value;
document.getElementById('ram-del-params').classList.toggle('hidden', op !== 'delete');
document.getElementById('ram-install-params').classList.toggle('hidden', op !== 'install-cap');
}
@@ -4565,7 +4555,11 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
html += '<div class="font-semibold text-sm mb-1" data-l10n="Applications">Applications</div>';
apps.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-green-400">';
html += '<div>AID: ' + (o.aid || '?') + '</div>';
html += '<div>AID: ' + (o.aid || '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400" data-l10n="Delete">Delete</button>';
}
html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.privileges) html += '<div>Privileges: ' + ramFmtPrivileges(o.privileges) + ' (' + o.privileges + ')</div>';
if (o.implicitSel) html += '<div>Implicit sel: ' + o.implicitSel + '</div>';
@@ -4580,7 +4574,12 @@ function ramRenderExploreHtml(mem, isd, apps, elfs) {
html += '<div class="font-semibold text-sm mb-1" data-l10n="Executable Load Files">Executable Load Files</div>';
elfs.forEach(o => {
html += '<div class="mb-1 pl-2 border-l-2 border-purple-400">';
html += '<div>AID: ' + (o.aid || '?') + '</div>';
html += '<div>AID: ' + (o.aid || '?');
if (o.aid) {
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', false)" class="ml-2 px-2 py-0.5 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400" data-l10n="Delete">Delete</button>';
html += ' <button onclick="ramDeleteFromExplorer(\'' + o.aid + '\', true)" class="ml-1 px-2 py-0.5 text-xs bg-red-200 text-red-800 rounded hover:bg-red-300 dark:bg-red-900/50 dark:text-red-300" data-l10n="Delete All">Delete All</button>';
}
html += '</div>';
html += '<div>Lifecycle: ' + ramFmtLifecycle(o.lifecycle || '') + '</div>';
if (o.version) html += '<div>Version: ' + o.version + '</div>';
if (o.moduleAids && o.moduleAids.length) {
@@ -4715,14 +4714,20 @@ async function ramExplore(sp) {
explorerEl.classList.remove('hidden');
}
async function ramDelete(sp) {
const aid = (document.getElementById('ram-del-aid').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!aid) { alert('Enter AID to delete'); return; }
const related = document.getElementById('ram-del-related').checked;
const p2 = related ? '80' : '00';
async function ramDeleteFromExplorer(aid, withCascade) {
const sp = getRamSpParams();
if (!sp.kicKey || !sp.kidKey) {
alert('Select a card preset with keys first (RAM subtab → Card preset)');
return;
}
const label = withCascade ? 'DELETE (cascade)' : 'DELETE';
if (!confirm(label + ' — AID: ' + aid + '?')) return;
const p2 = withCascade ? '80' : '00';
const aidLen = (aid.length / 2).toString(16).padStart(2, '0');
const apdu = '80E400' + p2 + (2 + aid.length / 2) + '4F' + aidLen + aid;
const apdu = '80E400' + p2 + _ber_len(2 + aid.length / 2) + '4F' + aidLen + aid;
ramShowProgress(label + ' ' + aid + '...');
const res = await ramSendOta(apdu, sp);
ramHideProgress();
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
if (!res.success || !res.por || res.por.response_status !== 'por_ok') {
@@ -4733,9 +4738,10 @@ async function ramDelete(sp) {
ramSaveCntr(ramIncrementCntr(sp.cntr));
const sw = res.por.decoded ? res.por.decoded.last_status_word : '';
stepsEl.classList.remove('hidden');
stepsEl.textContent = 'DELETE ' + aid + ' -> ' + sw;
stepsEl.textContent = label + ' ' + aid + ' -> ' + sw;
resultEl.textContent = 'OK';
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
await ramExplore(sp);
}
async function ramInstallCap(sp) {
@@ -4797,7 +4803,6 @@ async function ramExecute() {
}
try {
if (op === 'explore') await ramExplore(sp);
else if (op === 'delete') await ramDelete(sp);
else if (op === 'install-cap') await ramInstallCap(sp);
} catch (e) {
const resultEl = document.getElementById('ram-result');
@@ -6656,6 +6661,8 @@ const LANG_RU = {
'ICCID': 'ICCID',
'Cntr': 'Сч',
'Remove': 'Удалить',
'Delete': 'Удалить',
'Delete All': 'Удалить все',
'No cards defined.': 'Карты не заданы.',
'Card presets': 'Выбор карты',
'— Select card —': '— Выберите карту —',
+213
View File
@@ -206,6 +206,74 @@ def _encode_scts(dt=None):
])
def _lv(hex_str):
"""Length-prefix a hex string (1-byte length)."""
n = len(hex_str) // 2
return '%02x%s' % (n, hex_str)
def _ber_len(n):
"""BER-TLV length encoding."""
if n < 0x80:
return '%02x' % n
elif n < 0x100:
return '81%02x' % n
else:
return '82%04x' % n
def _cap_parse(cap_hex):
"""Parse a .cap file (as hex string) and return (loadfile_aid, module_aid, loadfile_data) as hex strings.
The .cap file is a ZIP archive containing nested .cap component files.
We extract the Header (for package AID / Load File AID) and Applet component
(for applet AID / Module AID), then concatenate all components for the loadfile data.
"""
import zipfile, io, struct
cap_bytes = bytes.fromhex(cap_hex)
zf = zipfile.ZipFile(io.BytesIO(cap_bytes))
components = {}
for name in zf.namelist():
if name.lower().endswith('.cap') and not name.lower().endswith('.capx'):
key = name.split('/')[-1].removesuffix('.cap')
components[key] = zf.read(name)
zf.close()
if 'Header' not in components:
raise ValueError('.cap file missing Header component')
if 'Applet' not in components:
raise ValueError('.cap file missing Applet component')
# Header component: tag(1) size(2) magic(4) minor(1) major(1) flags(1) package(minor(1) major(1) aid(LV))
hdr = components['Header']
magic = struct.unpack('>I', hdr[3:7])[0]
if magic != 0xDECAFFED:
raise ValueError('Invalid .cap Header magic: 0x%08X (expected 0xDECAFFED)' % magic)
aid_len = hdr[12]
loadfile_aid = hdr[13:13 + aid_len].hex().upper()
# Applet component: tag(1) size(2) count(1) [aid_len(1) aid(N) install_offset(2)]*
app = components['Applet']
num_applets = app[3]
if num_applets < 1:
raise ValueError('.cap file has no applets')
off = 4
aid_len2 = app[off]
module_aid = app[off + 1:off + 1 + aid_len2].hex().upper()
# Concatenate all components for the loadfile (GP spec order)
order = ['Header', 'Directory', 'Import', 'Applet', 'Class', 'Method',
'StaticField', 'Export', 'ConstantPool', 'RefLocation', 'Descriptor']
loadfile_parts = []
for comp in order:
if comp in components:
loadfile_parts.append(components[comp])
loadfile_data = b''.join(loadfile_parts).hex().upper()
return loadfile_aid, module_aid, loadfile_data
def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', include_cpi=True):
chunk = bytes.fromhex(chunk_hex)
# TS 23.040 UDH: first octet is UDHL, then the information elements.
@@ -2040,6 +2108,151 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
elif self.path == '/api/ram-install':
body = self._read_body()
self._log_req(body)
scc = self.server.scc
if not scc:
self._send_json({'error': _err('reader_not_init', lang)}, 503)
self._log_resp({'error': _err('reader_not_init', lang)})
return
try:
cap_hex = body.get('cap_hex', '').replace(' ', '')
if not cap_hex:
self._send_json({'error': 'No cap_hex provided'}, 400)
return
# Parse .cap file
loadfile_aid, module_aid, loadfile_data = _cap_parse(cap_hex)
# SCP80 params
spi1 = body.get('spi1', '16')
spi2 = body.get('spi2', '01')
kic = body.get('kic', '25')
kid = body.get('kid', '25')
tar = body.get('tar', '000000')
cntr = body.get('cntr', '00000000')
kic_key = body.get('kicKey', '')
kid_key = body.get('kidKey', '')
sd_aid = body.get('sd_aid', '').replace(' ', '')
install_params_hex = body.get('install_params', '').replace(' ', '')
stk_params_hex = body.get('stk_params', '').replace(' ', '')
make_selectable = body.get('make_selectable', True)
steps = []
include_cpi = body.get('includeCpi', True)
spi2_val = int(spi2, 16)
por_in_submit = bool(spi2_val & 0x20)
def _send_gp_apdu(apdu_hex, step_name):
nonlocal cntr
sp_hex, _ = _ota_reference(spi1, spi2, kic, kid, tar, cntr, apdu_hex, kic_key, kid_key)
sp_bytes = bytes.fromhex(sp_hex)
max_chunk = 130
chunks = [sp_bytes[i:i + max_chunk] for i in range(0, len(sp_bytes), max_chunk)]
submit_handler = None
old_proactive = None
if por_in_submit and hasattr(scc, '_tp'):
submit_handler = PoRSubmitHandler()
old_proactive = scc._tp.proactive_handler
scc._tp.proactive_handler = submit_handler
try:
last_data = None
last_sw = None
for i, chunk in enumerate(chunks):
tpdu = _build_sms_tpdu(chunk.hex(), len(chunks), i + 1,
oa_number=self.server.sms_oa, include_cpi=include_cpi)
data, sw = _send_envelope(tpdu, scc, sm_sc=self.server.sms_sc,
submit_handler=submit_handler)
last_data = data
last_sw = sw
if sw != '9000' and not sw.startswith('91'):
steps.append({'name': step_name, 'por_status': 'envelope_error', 'sw': sw})
sys.stderr.write('RAM-INSTALL: %s ENVELOPE failed SW %s\n' % (step_name, sw))
return False
# Decode PoR
por_src = 'envelope'
por_hex = last_data
if submit_handler and submit_handler.submit_tpdu_hex:
tpdu_b = bytes.fromhex(submit_handler.submit_tpdu_hex)
idx = tpdu_b.find(b'\x02\x71\x00')
if idx >= 0:
por_hex = tpdu_b[idx:].hex()
por_src = 'sms-submit'
por = _decode_por(spi1, spi2, kic, kid, cntr, kic_key, kid_key, por_hex)
por_status = 'unknown'
if por and por.get('decoded'):
ps = por['decoded'].get('response_status', '')
por_status = 'por_ok' if ps == '9100' else 'por_error_%s' % ps
sys.stderr.write('RAM-INSTALL: %s PoR[%s] status=%s\n' % (step_name, por_src, ps))
elif last_sw == '9000' and not por_hex:
por_status = 'no_por'
steps.append({'name': step_name, 'por_status': por_status, 'sw': last_sw})
# Increment counter
cntr = '%010X' % ((int(cntr, 16) + 1) % (2 ** 32))
return True
finally:
if submit_handler and hasattr(scc, '_tp'):
scc._tp.proactive_handler = old_proactive
# Step 1: INSTALL [for load]
sys.stderr.write('RAM-INSTALL: Step 1 — INSTALL [for load] loadfile_aid=%s\n' % loadfile_aid)
ifl_data = _lv(loadfile_aid) + _lv(sd_aid) + '00' + '00' + '00'
ifl_apdu = '80E60200%02X%s00' % (len(ifl_data) // 2, ifl_data)
if not _send_gp_apdu(ifl_apdu, 'INSTALL [for load]'):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'INSTALL [for load] failed', 'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
# Step 2: LOAD blocks
loadfile_tlv = 'C4' + _ber_len(len(loadfile_data) // 2) + loadfile_data
block_size = 240
total_bytes = len(loadfile_tlv) // 2
blocks = [loadfile_tlv[i * 2:(i + block_size) * 2] for i in range(0, (total_bytes + block_size - 1) // block_size)]
sys.stderr.write('RAM-INSTALL: Step 2 — LOAD %d bytes in %d blocks\n' % (total_bytes, len(blocks)))
for block_idx, block in enumerate(blocks):
is_last = (block_idx == len(blocks) - 1)
p1 = 0x80 if is_last else 0x00
p2 = block_idx % 256
load_apdu = '80E8%02X%02X%02X%s00' % (p1, p2, len(block) // 2, block)
if not _send_gp_apdu(load_apdu, 'LOAD (%d/%d)' % (block_idx + 1, len(blocks))):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'LOAD block %d failed' % (block_idx + 1),
'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
# Step 3: INSTALL [for install]
sys.stderr.write('RAM-INSTALL: Step 3 — INSTALL [for install]\n')
instance_aid = module_aid
privileges = '00'
inst_params = install_params_hex if install_params_hex else 'C900'
if stk_params_hex:
inst_params += stk_params_hex
p1_install = 0x0C if make_selectable else 0x04
ifi_data = _lv(loadfile_aid) + _lv(module_aid) + _lv(instance_aid) + _lv(privileges) + _lv(inst_params) + '00'
ifi_apdu = '80E6%02X00%02X%s00' % (p1_install, len(ifi_data) // 2, ifi_data)
if not _send_gp_apdu(ifi_apdu, 'INSTALL [for install]'):
resp = {'success': False, 'steps': steps, 'failed_step': len(steps),
'error': 'INSTALL [for install] failed', 'load_file_aid': loadfile_aid, 'module_aid': module_aid}
self._send_json(resp)
self._log_resp(resp)
return
resp = {'success': True, 'steps': steps, 'load_file_aid': loadfile_aid,
'module_aid': module_aid, 'final_cntr': cntr}
sys.stderr.write('RAM-INSTALL: Complete — loadfile_aid=%s module_aid=%s cntr=%s\n' % (
loadfile_aid, module_aid, cntr))
self._send_json(resp)
self._log_resp(resp)
except Exception as e:
sys.stderr.write('RAM-INSTALL error: %s\n' % e)
err = {'success': False, 'error': str(e)}
self._send_json(err, 500)
self._log_resp(err)
else:
self._send_json({'error': _err('not_found', lang)}, 404)
self._log_resp({'error': _err('not_found', lang)})