> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zerohostcms.com/llms.txt
> Use this file to discover all available pages before exploring further.

# User Guide - OTA Server

## 1. Định nghĩa

`app` là namespace đã đăng ký;

`platform` là `android` hoặc `ios`;

`channel` là nhánh/kênh dev, có thể chứa `/`;

`binary` là phiên bản native tương thích, ví dụ `1.0.66+93` hoặc `1.6.6`.

**Ý nghĩa bắt buộc cập nhật:**

* `mandatory: true`: app mobile phải áp dụng bản cập nhật tương thích được chọn; không cung cấp lựa chọn bỏ qua bản đó.
* `mandatory: false`: cập nhật không bắt buộc; đây là mặc định khi publisher không gửi trường này.
* Mobile chọn đúng `app/platform/channel/binary`, đọc entry trong manifest, tải `file` từ cùng origin OTA, kiểm tra `size`/`sha256` trước khi áp dụng.

**Chú ý:**

<table>
  <colgroup>
    <col width="158" />

    <col width="584" />
  </colgroup>

  <thead>
    <tr>
      <th>Giá trị</th>
      <th>Vai trò và nơi sử dụng</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`signature`</td>
      <td>Metadata **công khai**, opaque, standard base64; có trong manifest và response. Server kiểm tra định dạng nhưng **không xác minh chữ ký**, không chọn Ed25519 hay thuật toán nào. Không đặt secret vào trường này.</td>
    </tr>

    <tr>
      <td>Bearer push token</td>
      <td>Credential **riêng tư**, theo app; gửi trong `Authorization`. Dùng cho publish, list bảo vệ và DELETE. Không nhúng vào app mobile.</td>
    </tr>

    <tr>
      <td>`X-OTA-Upload-Key`</td>
      <td>Credential **riêng tư** bổ sung, do admin sinh theo app; chỉ dùng với POST public releases khi app đã bật bảo vệ. Không phải `signature`, không đưa vào manifest/mobile.</td>
    </tr>
  </tbody>
</table>

SHA-256 đối chiếu bytes với hash do publisher khai báo, giúp phát hiện dữ liệu sai; nó không chứng minh nguồn gốc bundle khi publisher có thể cung cấp cả bytes và hash. Chữ ký S3 SigV4 dùng truy cập storage cũng không phải chữ ký phát hành cho mobile. Nếu mobile cần trusted signing, đó là hợp đồng trust/key verification riêng, chưa được backend này cung cấp.

## 2. API

Origin: `https://ota.zhst.app`.

<table>
  <colgroup>
    <col width="316" />

    <col width="142" />

    <col width="325" />
  </colgroup>

  <thead>
    <tr>
      <th>Method và path</th>
      <th>Xác thực</th>
      <th>Kết quả thành công</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`GET /ota/{app}/{platform}/manifest.json`</td>
      <td>Không</td>
      <td>`200`, `{"channels":{...}}`; app hợp lệ chưa có release trả `{"channels":{}}`, `Cache-Control: no-cache`</td>
    </tr>

    <tr>
      <td>`GET /ota/{app}/{platform}/{file}`</td>
      <td>Không</td>
      <td>Bundle, `200`; byte range hợp lệ có thể trả `206`</td>
    </tr>

    <tr>
      <td>`POST /ota/{app}/{platform}/releases`</td>
      <td>Bearer; thêm upload key nếu app đã cấu hình</td>
      <td>`201`, **một entry manifest**, không có envelope `{ok,data}`</td>
    </tr>

    <tr>
      <td>`GET /ota/{app}/{platform}/releases`</td>
      <td>Bearer</td>
      <td>`200`, cùng cấu trúc manifest; hỗ trợ lọc `channel`, `binary`</td>
    </tr>

    <tr>
      <td>`DELETE /ota/{app}/{platform}/releases?channel=...&binary=...`</td>
      <td>Bearer</td>
      <td>`204`, không body; xóa toàn bộ release của cặp đó</td>
    </tr>
  </tbody>
</table>

Đổi upload key không đổi auth download/list/DELETE. Backend không làm download private: manifest và bundle vẫn đọc ẩn danh.

### Form publish

Để `curl`/`FormData` tự đặt multipart boundary; không tự gán `Content-Type: multipart/form-data` thiếu boundary.

| Trường | Dữ liệu gửi | Ràng buộc tích hợp |
| - | - | - |
| `bundle` | File | Bắt buộc, không rỗng; giới hạn theo `OTA_MAX_BUNDLE_BYTES` |
| `channel` | Text | Bắt buộc; tối đa 200 ký tự, không control character/backslash/whitespace đầu cuối |
| `binary` | Text | Bắt buộc; tối đa 64 ký tự, định dạng identifier theo schema |
| `sha256` | Text | Bắt buộc; 64 ký tự hex, tính từ đúng file đang gửi |
| `note` | Text | Tùy chọn, mặc định rỗng, tối đa 500 ký tự |
| `commit` | Text | Tùy chọn, mặc định rỗng, tối đa 64 ký tự, identifier theo schema |
| `signature` | Text | Tùy chọn, mặc định `""`; standard base64 canonical, tối đa 4096 ký tự. Giá trị 64 byte biểu diễn thành 88 ký tự là hợp lệ; **không bắt buộc mọi signature dài 88**. Không dùng base64url. |
| `mandatory` | Text | Tùy chọn; chỉ chuỗi chính xác `true` hoặc `false`, mặc định `false`. Response/manifest là **boolean JSON**, không phải chuỗi. Không gửi `1`, `0`, `TRUE` hoặc chuỗi rỗng. |

### Response `201` và manifest

Ví dụ một entry; signature dưới đây chỉ là 64 byte tuần tự mã hóa base64, **không phải chữ ký đã ký/xác minh**:

```json theme={null}
{
  "id": "20261008-091500",
  "file": "bundles/dev_delivery/1.0.66+93/20261008-091500.bundle",
  "sha256": "8a8adac45df963d4b491a18c17590cd376a53314c79d28bd27cb4a0e98a896d9",
  "size": 19707016,
  "note": "Sửa màn giao hàng",
  "commit": "e39c6d34",
  "signature": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
  "mandatory": true,
  "branch": "dev/delivery",
  "createdAt": "2026-10-08T09:15:00.000Z"
}
```

Manifest bọc **chính entry đó** tại `channels["dev/delivery"]["1.0.66+93"]`. Nếu bỏ cả hai metadata khi publish, entry vẫn có `"signature":""` và `"mandatory":false`. `201` có `Cache-Control: no-store` và `Location` trỏ bundle. `file` là đường tương đối do server sinh; không tự tái tạo từ branch. Manifest `id` là release ID; admin có row `id` riêng dùng để rollback/delete từng release.

### Publish từ CI

CI lấy bearer/upload key từ secret store đã được phê duyệt và inject qua môi trường, không từ repository. `OTA_PUBLIC_SIGNATURE` là metadata công khai của artifact, không phải upload key. Không bật shell trace `set -x`, không log header hay response rotate.

Ví dụ Bash dưới đây dành cho app **đã bật upload protection**:

```sh theme={null}
set -euo pipefail
BASE='https://ota.example.com'
APP='unitel-man'
PLATFORM='android'
CHANNEL='dev/delivery'
BINARY='1.0.66+93'
BUNDLE='./build/app.bundle'
: "${OTA_PUSH_TOKEN:?Inject bearer từ secret store}"
: "${OTA_UPLOAD_KEY:?Inject upload key của app từ secret store}"
SIGNATURE="${OTA_PUBLIC_SIGNATURE:-}"
MANDATORY="${OTA_MANDATORY:-false}"
SHA="$(node --input-type=module -e 'import {createHash} from "node:crypto"; import {readFileSync} from "node:fs"; process.stdout.write(createHash("sha256").update(readFileSync(process.argv[1])).digest("hex"))' "$BUNDLE")"

curl --fail-with-body -X POST "$BASE/ota/$APP/$PLATFORM/releases" \
  -H "Authorization: Bearer $OTA_PUSH_TOKEN" \
  -H "X-OTA-Upload-Key: $OTA_UPLOAD_KEY" \
  -F "bundle=@$BUNDLE" \
  --form-string "channel=$CHANNEL" \
  --form-string "binary=$BINARY" \
  --form-string "sha256=$SHA" \
  --form-string "signature=$SIGNATURE" \
  --form-string "mandatory=$MANDATORY" \
  --form-string 'note=nightly' \
  --form-string "commit=$(git rev-parse --short HEAD)"
```

Với app chưa cấu hình upload key, bỏ dòng kiểm tra `OTA_UPLOAD_KEY` và header `X-OTA-Upload-Key`; bearer vẫn bắt buộc. Không lấy signature để điền header, không tự sinh upload key ở CI. Khi bật bảo vệ lần đầu, chuẩn bị secret CI trước rồi chuyển publish sang hai credential; có thể tạm dừng publisher trong thời gian chuyển giao.

### Đọc, tải, list và gỡ

Dùng các biến `BASE`, `APP`, `PLATFORM`, `CHANNEL`, `BINARY` từ ví dụ publish. `FILE` phải lấy nguyên từ response/manifest của bản cần tải:

```sh theme={null}
curl --fail-with-body "$BASE/ota/$APP/$PLATFORM/manifest.json"
FILE='bundles/dev_delivery/1.0.66+93/20261008-091500.bundle'
curl --fail-with-body -H 'Accept-Encoding: identity' \
  "$BASE/ota/$APP/$PLATFORM/$FILE" -o ./downloaded.bundle
curl --fail-with-body --get "$BASE/ota/$APP/$PLATFORM/releases" \
  -H "Authorization: Bearer $OTA_PUSH_TOKEN" \
  --data-urlencode "channel=$CHANNEL" --data-urlencode "binary=$BINARY"
```

File ví dụ không chứng minh có object đó trên máy thật. Trước khi áp dụng, đối chiếu byte length và SHA-256 tải về với entry. Download có `Content-Length`, `ETag` bằng sha256, `application/octet-stream`, không content coding và cache immutable; resume bằng một explicit range, ví dụ `Range: bytes=1048576-`. Không giả định suffix/multi-range được phục vụ từng phần; handler sở hữu quy tắc range.

**Lệnh phá hủy**, chỉ dùng khi đã duyệt đúng app/platform/channel/binary:

```sh theme={null}
QUERY="$(node -e 'process.stdout.write(new URLSearchParams({channel:process.argv[1],binary:process.argv[2]}).toString())' "$CHANNEL" "$BINARY")"
curl --fail-with-body -X DELETE "$BASE/ota/$APP/$PLATFORM/releases?$QUERY" \
  -H "Authorization: Bearer $OTA_PUSH_TOKEN"
```

URL-encode `/` và `+` trong query; multipart text giữ nguyên. DELETE không phải rollback: nó gỡ mọi bản của cặp này và object tương ứng, không điều khiển bundle đã tải/cache hoặc bản đang chạy trên máy.

## 3. Admin API và upload protection

Admin API ở **cùng origin** `/api/ota/{action}`, dùng session EmDash có `settings:manage`, không dùng bearer publish để thay session. GET cần session; POST cần thêm `Origin` đúng origin và `X-EmDash-Request: 1`. JSON POST có `Content-Type: application/json`. Browser tự gửi cookie với `credentials: "same-origin"` và `Origin` khi POST; không tìm cách tự đặt `Origin` trong JavaScript. Multipart admin upload không tự đặt content type. [Client workspace](../../../../zhst-community-plugins/plugins/ota-server/src/admin/client.ts) là mẫu thực thi.

Envelope thành công: `{"ok":true,"data":...}`. Lỗi: `{"ok":false,"error":{"code":"...","message":"..."}}`. Admin response dùng `Cache-Control: private, no-store`.

### Xem trạng thái key, không đọc plaintext

`GET /api/ota/upload-key?app=unitel-man` trả `200`:

```json theme={null}
{"ok":true,"data":{"app":"unitel-man","configured":false,"updatedAt":null}}
```

Khi đã cấu hình, `configured` là `true`, `updatedAt` là timestamp ISO. Không có API lấy lại key plaintext.

### Tạo lần đầu hoặc rotate

`POST /api/ota/upload-key-rotate`, JSON `{"app":"unitel-man"}`, trả `201`:

```text theme={null}
{ok:true,data:{key:<plaintext chỉ có trong response này>,record:{app:"unitel-man",configured:true,updatedAt:<ISO timestamp>}}}
```

Đây là mô tả envelope, không phải JSON để gửi hay credential ví dụ. Key do server sinh bằng **64 byte ngẫu nhiên**, standard base64 **88 ký tự**, chỉ persist hash. Lần tạo đầu bật bảo vệ cho riêng app đó; không tạo thì vẫn dùng auth bearer hiện có. Rotate thay hash ngay: push **mới qua bước xác thực** phải dùng key mới. Request đã xác thực trước khi rotate có thể hoàn tất; không coi rotate là hủy upload đang chạy. Các release cũ, đường tải, list và DELETE không đổi. Không có grace period hoặc API tắt bảo vệ được mô tả ở đây.

Ví dụ chạy trong console của **workspace cùng origin, đã đăng nhập**; response chứa key chỉ giữ trong biến để chuyển vào secret store được duyệt, không `console.log` toàn response:

```js theme={null}
const statusResponse = await fetch('/api/ota/upload-key?app=unitel-man', {
  credentials: 'same-origin'
});
const status = await statusResponse.json();
// Kiểm tra status.ok và status.data.configured trước khi quyết định rotate.
const rotationResponse = await fetch('/api/ota/upload-key-rotate', {
  method: 'POST',
  credentials: 'same-origin',
  headers: {'Content-Type': 'application/json', 'X-EmDash-Request': '1'},
  body: JSON.stringify({app: 'unitel-man'})
});
const rotation = await rotationResponse.json();
// Nếu rotation.ok, chuyển rotation.data.key vào secret store được duyệt.
// Không log/chụp màn hình/export biến này; mất key thì rotate lại, không truy hồi hash.
```

Thao tác thứ hai thực sự rotate, không phải kiểm tra read-only; chỉ chạy trong phạm vi đã được phép. Dùng controls workspace là lựa chọn thông thường. Nếu gọi ngoài browser, cần session cookie hợp lệ và hai header CSRF nêu trên; không chia sẻ cookie hay dùng nó làm secret CI dài hạn.

### Các tác vụ admin vận hành

Các payload khác đối chiếu [dispatch admin](../../../../zhst-community-plugins/plugins/ota-server/src/http/admin.ts) và schema trước khi tự động hóa:

* `GET info`, `GET releases` (lọc và cursor), `GET tokens`, `GET storage`, `GET audit`: workspace tra cứu nguồn hiện hành. Admin releases trả `{items,nextCursor}` trong `data`, không phải manifest.
* `POST token-create` JSON `{app,name}` trả `201` với `{token,record}` trong `data`; `POST token-revoke` JSON `{id}` thu hồi bearer. Tạo bearer mới không tự thu hồi các bearer cũ.
* `POST release-promote` JSON `{id}` chọn lại release theo **row ID admin**; `POST release-delete` JSON `{id}` xóa riêng bản đó. Không dùng manifest ID timestamp thay row ID.
* `POST release-upload` multipart gồm `app`, `platform`, `bundle`, `channel`, `binary`, `note`, `commit`, `signature`, `mandatory`. Backend tính sha256; session admin được phép upload **không cần bearer hoặc upload key**. Đây là đường quản trị được duyệt, không phải lối upload anonymous. Metadata có cùng validation/default như public publish.
* `POST app-save`, `POST storage-save`, `POST storage-test`, `POST purge`: thay đổi cấu hình hoặc dữ liệu; dùng workspace và đọc schema/service trước khi dùng API. Test storage ghi/đọc/xóa probe object, không phải thao tác read-only.

## 4. Release, rollback và retention

Trong phạm vi `app/platform/channel/binary`, backend giữ bản current và bản trước theo [service](../../../../zhst-community-plugins/plugins/ota-server/src/domain/service.ts) và [defaults](../../../../zhst-community-plugins/plugins/ota-server/src/domain/data.ts). Bản cũ hơn bị xóa; retention mặc định **14 ngày tính từ `createdAt`**, kể cả current. Push mới có sweep; [Worker scheduled](../src/worker.ts) hỗ trợ kênh ngừng push.

Gửi lại current với cùng bytes/sha/size **và cùng signature/mandatory** là replay: trả bản đã lưu với `201`, không nhân bản, **không làm mới `createdAt`**. Thay signature hoặc mandatory của cùng bytes tạo release mới, không âm thầm sửa metadata bản cũ. Không dùng replay làm keep-alive; cấu hình retention phù hợp lịch dev.

Runbook rollback: chọn đúng cặp trong **Releases**, xác nhận bản trước còn object và metadata phù hợp, chọn rollback/promote, rồi đọc lại manifest và thử app dev. Pointer thay đổi ngay; app chỉ nhận khi nó poll và thực thi logic cập nhật của mình. `signature`/`mandatory` đi cùng bản được chọn; promote không tự đổi cờ. Nếu bản cũ đã bị retention/delete, rollback không thể phục hồi bytes; cần artifact lưu trữ và publish lại hoặc restore đã duyệt.

Trước delete/retention purge, kiểm tra phạm vi và việc mất đường tải đối với thiết bị chưa tải. Bản đã nằm trong cache/máy không bị thu hồi bởi việc xóa ở server. Xóa metadata có thể thành công dù xóa object lỗi, để lại object orphan vẫn tính phí; kiểm tra log và dọn trong storage theo quyền vận hành, không suy ra đã hết object chỉ từ `204`.

## 5. Mã lỗi

App error là `{"error":{"code":"...","message":"..."}}`; admin error thêm `ok:false`. Đọc cả HTTP status và code; không log token/header/body nhạy cảm khi báo lỗi. [HTTP shared](../../../../zhst-community-plugins/plugins/ota-server/src/http/shared.ts), [multipart](../../../../zhst-community-plugins/plugins/ota-server/src/http/multipart.ts) và [storage adapters](../../../../zhst-community-plugins/plugins/ota-server/src/storage/) sở hữu mapping đầy đủ.

<table>
  <colgroup>
    <col width="313" />

    <col width="424" />
  </colgroup>

  <thead>
    <tr>
      <th>Status/code điển hình</th>
      <th>Cách xử lý</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`400 INVALID_INPUT`, `INVALID_MULTIPART`, `INVALID_LENGTH`, `BUNDLE_MISSING`</td>
      <td>Kiểm tra schema, boolean text chính xác, standard base64, multipart boundary và file không rỗng.</td>
    </tr>

    <tr>
      <td>`400 SHA_MISMATCH`</td>
      <td>Tính lại hash từ bytes thật đang gửi; không bỏ kiểm tra hash hoặc gửi một hash khác để che lỗi.</td>
    </tr>

    <tr>
      <td>`401 AUTH_REQUIRED`, `TOKEN_INVALID`, `TOKEN_REVOKED`</td>
      <td>Kiểm tra bearer đúng app từ store; tạo/cấp lại theo quyền admin, không lấy từ mobile.</td>
    </tr>

    <tr>
      <td>`403 TOKEN_APP_MISMATCH`</td>
      <td>Sai namespace app với bearer.</td>
    </tr>

    <tr>
      <td>`403 UPLOAD_KEY_REQUIRED`, `UPLOAD_KEY_INVALID`</td>
      <td>App đã bật bảo vệ: gửi đúng key mới của app qua header, không lấy public signature thay thế.</td>
    </tr>

    <tr>
      <td>`403 FORBIDDEN`, `CSRF`</td>
      <td>Admin session/quyền hoặc same-origin, `Origin`, `X-EmDash-Request` sai; không nới gate.</td>
    </tr>

    <tr>
      <td>`404 APP_UNKNOWN`, `NOT_FOUND`, `BUNDLE_UNAVAILABLE`, `RELEASE_NOT_FOUND`</td>
      <td>Kiểm tra app/path, expiry/delete và object/backend; không coi mọi manifest rỗng là lỗi.</td>
    </tr>

    <tr>
      <td>`409 APP_DISABLED`</td>
      <td>Quản trị kiểm tra app đang bị disable.</td>
    </tr>

    <tr>
      <td>`405 METHOD_NOT_ALLOWED`</td>
      <td>Dùng đúng GET/POST/DELETE và `Allow`; endpoint rotate không phải GET.</td>
    </tr>

    <tr>
      <td>`413 BUNDLE_TOO_LARGE`, `BODY_TOO_LARGE`</td>
      <td>Giảm bundle hoặc điều chỉnh ceiling sau khi đánh giá bộ nhớ host, kể cả multipart overhead.</td>
    </tr>

    <tr>
      <td>`415 UNSUPPORTED_MEDIA_TYPE`, `CONTENT_TYPE`</td>
      <td>Public upload cần multipart đúng boundary; JSON admin cần application/json.</td>
    </tr>

    <tr>
      <td>`400/503 SECRET_KEY_MISSING`, `500 SECRET_UNREADABLE`</td>
      <td>Kiểm tra runtime key mã hóa, restore/rotation và nhập lại R2 secret qua workspace.</td>
    </tr>

    <tr>
      <td>`502 STORAGE_UNAUTHORIZED`, `STORAGE_MISSING`, `STORAGE_FAILED`; `503 STORAGE_NOT_CONFIGURED`</td>
      <td>Kiểm tra mode, binding, endpoint/bucket và quyền credential trong hồ sơ hạn chế quyền; chạy storage test được duyệt.</td>
    </tr>

    <tr>
      <td>`500 INTERNAL_ERROR`, `PUSH_FAILED`; `503 CONFIGURATION_ERROR`</td>
      <td>Tra runtime log đã sanitize theo môi trường và request/time window, migration/binding/config đúng; không công bố stack/secret.</td>
    </tr>
  </tbody>
</table>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.