Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ Two kinds of `GET`, relative to `https://<host>/<app>/<channel>`:

| Path | Content | Cache |
|---|---|---|
| `<pkg>/<rv>/current.json` | mutable pointer: `{ version, rollout, signature }` | `no-store` |
| `<pkg>/<rv>/<version>/…` | immutable content: `manifest.json`, `runtime/*.bin`, `pages/**/*.bin` | `immutable` |
| `<pkg>/<hostVersion>/current.json` | mutable pointer: `{ version, rollout, signature }` | `no-store` |
| `<pkg>/<hostVersion>/<version>/…` | immutable content: `manifest.json`, `runtime/*.bin`, `pages/**/*.bin` | `immutable` |

Content is stored once per `(app, pkg, rv, version)`; a channel is only a pointer, so promoting a verified version from `staging` to `production` moves the pointer and transfers nothing.
`<hostVersion>` is the positive integer a host App declares for what it provides to pages: it goes up when host components or capabilities change or the embedded TinyUI is upgraded. It is not the version of the TinyUI runtime or the JS engine (the manifest's `engine` / `protocol` cover those); it plays the role of Expo's `runtimeVersion` but is always a positive integer that counts host changes, never an App version such as `1.2.0`; see the TinyUI repo's `docs/updates.md`.

Content is stored once per `(app, pkg, hostVersion, version)`; a channel is only a pointer, so promoting a verified version from `staging` to `production` moves the pointer and transfers nothing.

## Publishing

All requests carry `Authorization: Bearer <token>`.

| Request | Meaning |
|---|---|
| `PUT /<app>/<pkg>/<rv>/<version>/<path>` | upload one file (idempotent; different bytes at an existing path → 409) |
| `PUT /<app>/<channel>/<pkg>/<rv>/current.json` with `{ version, signature, rollout? }` | publish: verifies the signature over the uploaded `manifest.json` with the registered key, checks `name` / `runtimeVersion` / `version` / `publicKey` and every file's sha256, records the release, moves the pointer |
| `GET /<app>/<pkg>/<rv>/releases` | published versions and where each channel points |
| `POST /<app>/<channel>/<pkg>/<rv>/pointer` with `{ version?, rollout? }` | rollback, promotion and rollout changes are the same operation |
| `PUT /<app>/<pkg>/<hostVersion>/<version>/<path>` | upload one file (idempotent; different bytes at an existing path → 409) |
| `PUT /<app>/<channel>/<pkg>/<hostVersion>/current.json` with `{ version, signature, rollout? }` | publish: verifies the signature over the uploaded `manifest.json` with the registered key, checks `name` / `hostVersion` / `version` / `publicKey` and every file's sha256, records the release, moves the pointer |
| `GET /<app>/<pkg>/<hostVersion>/releases` | published versions and where each channel points |
| `POST /<app>/<channel>/<pkg>/<hostVersion>/pointer` with `{ version?, rollout? }` | rollback, promotion and rollout changes are the same operation |

`tinyui publish` / `tinyui releases …` in `tinyui-cli` are thin clients of these endpoints.

Expand Down
16 changes: 9 additions & 7 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@

| 路径 | 内容 | 缓存 |
|---|---|---|
| `<pkg>/<rv>/current.json` | 可变指针:`{ version, rollout, signature }` | `no-store` |
| `<pkg>/<rv>/<version>/…` | 不可变内容:`manifest.json`、`runtime/*.bin`、`pages/**/*.bin` | `immutable` |
| `<pkg>/<hostVersion>/current.json` | 可变指针:`{ version, rollout, signature }` | `no-store` |
| `<pkg>/<hostVersion>/<version>/…` | 不可变内容:`manifest.json`、`runtime/*.bin`、`pages/**/*.bin` | `immutable` |

内容按 `(app, pkg, rv, version)` 存一份;channel 只是指针,staging 验过的版本晋级到 production 只动指针,不重传。
`<hostVersion>` 是宿主 App 为"它给页面提供了什么"声明的正整数:宿主组件、宿主能力增删或改动,或者升级了内置的 TinyUI,都加 1。它不是 TinyUI 运行时或 JS 引擎的版本(那两者由 manifest 的 `engine` / `protocol` 校验),作用同 Expo 的 `runtimeVersion`,但取值只能是正整数、数的是宿主的变化,不是 `1.2.0` 这类 App 版本号,见 TinyUI 仓的 `docs/updates.md`。

内容按 `(app, pkg, hostVersion, version)` 存一份;channel 只是指针,staging 验过的版本晋级到 production 只动指针,不重传。

## 发布

所有请求带 `Authorization: Bearer <token>`。

| 请求 | 语义 |
|---|---|
| `PUT /<app>/<pkg>/<rv>/<version>/<path>` | 上传一个文件(幂等;同路径不同内容 → 409) |
| `PUT /<app>/<channel>/<pkg>/<rv>/current.json`,body `{ version, signature, rollout? }` | 发布:用登记的公钥对已上传的 `manifest.json` 原始字节验签,核对 `name` / `runtimeVersion` / `version` / `publicKey` 与每个文件的 sha256,记录 release,切指针 |
| `GET /<app>/<pkg>/<rv>/releases` | 已发布的版本与各 channel 的指针 |
| `POST /<app>/<channel>/<pkg>/<rv>/pointer`,body `{ version?, rollout? }` | 回滚、晋级、改灰度是同一个操作 |
| `PUT /<app>/<pkg>/<hostVersion>/<version>/<path>` | 上传一个文件(幂等;同路径不同内容 → 409) |
| `PUT /<app>/<channel>/<pkg>/<hostVersion>/current.json`,body `{ version, signature, rollout? }` | 发布:用登记的公钥对已上传的 `manifest.json` 原始字节验签,核对 `name` / `hostVersion` / `version` / `publicKey` 与每个文件的 sha256,记录 release,切指针 |
| `GET /<app>/<pkg>/<hostVersion>/releases` | 已发布的版本与各 channel 的指针 |
| `POST /<app>/<channel>/<pkg>/<hostVersion>/pointer`,body `{ version?, rollout? }` | 回滚、晋级、改灰度是同一个操作 |

`tinyui-cli` 的 `tinyui publish` / `tinyui releases …` 是这些端点的薄封装。

Expand Down
68 changes: 34 additions & 34 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import type { Context } from "hono";
import { docKeys, isAdmin, issueToken, packageToken, revokeToken } from "./auth.ts";
import { isName, isObjectPath, isSegment } from "./ids.ts";
import { isHostVersion, isName, isObjectPath, isSegment } from "./ids.ts";
import { parseManifest } from "./manifest.ts";
import type { AppRecord, PackageRecord, PointerDoc, ReleaseRecord } from "./records.ts";
import { isPublicKey, verifySignature } from "./signature.ts";
Expand All @@ -18,7 +18,7 @@ const IMMUTABLE = "public, max-age=31536000, immutable";
export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
const app = new Hono();
const fail = (c: Context, status: 400 | 401 | 403 | 404 | 409 | 413, error: string) => c.json({ error }, status);
const objectKey = (app: string, pkg: string, rv: string, version: string, path: string) => `${app}/${pkg}/${rv}/${version}/${path}`;
const objectKey = (app: string, pkg: string, hostVersion: string, version: string, path: string) => `${app}/${pkg}/${hostVersion}/${version}/${path}`;

app.get("/", (c) => c.text("tinyui-updates"));

Expand Down Expand Up @@ -86,18 +86,18 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {

// ---- releases (package token or admin), §6.2 ----

app.get("/:app/:pkg/:rv/releases", async (c) => {
const { app: appId, pkg, rv } = c.req.param();
if (!isName(appId) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found");
app.get("/:app/:pkg/:hostVersion/releases", async (c) => {
const { app: appId, pkg, hostVersion } = c.req.param();
if (!isName(appId) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found");
if (!isAdmin(c, adminToken) && !(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required");
const releasePrefix = docKeys.releasePrefix(appId, pkg, rv);
const releasePrefix = docKeys.releasePrefix(appId, pkg, hostVersion);
const versions: { version: string; createdAt: string; publishedAt: string }[] = [];
for (const key of await storage.listDocs(releasePrefix)) {
const release = await storage.getDoc<ReleaseRecord>(key);
if (release) versions.push({ version: key.slice(releasePrefix.length), createdAt: release.createdAt, publishedAt: release.publishedAt });
}
versions.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
const pointerPrefix = docKeys.pointerPrefix(appId, pkg, rv);
const pointerPrefix = docKeys.pointerPrefix(appId, pkg, hostVersion);
const channels: Record<string, { version: string; rollout: number }> = {};
for (const key of await storage.listDocs(pointerPrefix)) {
const pointer = await storage.getDoc<PointerDoc>(key);
Expand All @@ -106,30 +106,30 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
return c.json({ versions, channels });
});

app.post("/:app/:channel/:pkg/:rv/pointer", async (c) => {
const { app: appId, channel, pkg, rv } = c.req.param();
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found");
app.post("/:app/:channel/:pkg/:hostVersion/pointer", async (c) => {
const { app: appId, channel, pkg, hostVersion } = c.req.param();
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found");
const token = await packageToken(c, storage, appId, pkg);
if (!token) return fail(c, 401, "a token for this package is required");
if (!token.channels.includes(channel)) return fail(c, 403, `token is not allowed to publish to ${channel}`);
const body = await json(c);
const current = await storage.getDoc<PointerDoc>(docKeys.pointer(appId, pkg, rv, channel));
const current = await storage.getDoc<PointerDoc>(docKeys.pointer(appId, pkg, hostVersion, channel));
const version = body?.["version"] ?? current?.version;
if (typeof version !== "string" || !isSegment(version)) return fail(c, 400, "version is required");
const release = await storage.getDoc<ReleaseRecord>(docKeys.release(appId, pkg, rv, version));
if (!release) return fail(c, 404, `version ${version} was never published under ${appId}/${pkg}/${rv}`);
const release = await storage.getDoc<ReleaseRecord>(docKeys.release(appId, pkg, hostVersion, version));
if (!release) return fail(c, 404, `version ${version} was never published under ${appId}/${pkg}/${hostVersion}`);
const rollout = parseRollout(body?.["rollout"], current && current.version === version ? current.rollout : 100);
if (rollout === null) return fail(c, 400, "rollout must be an integer from 0 to 100");
const pointer: PointerDoc = { version, rollout, signature: release.signature };
await storage.putDoc(docKeys.pointer(appId, pkg, rv, channel), pointer);
await storage.putDoc(docKeys.pointer(appId, pkg, hostVersion, channel), pointer);
return c.json(pointer);
});

// ---- publishing (package token), §6.1 ----

app.put("/:app/:channel/:pkg/:rv/current.json", async (c) => {
const { app: appId, channel, pkg, rv } = c.req.param();
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found");
app.put("/:app/:channel/:pkg/:hostVersion/current.json", async (c) => {
const { app: appId, channel, pkg, hostVersion } = c.req.param();
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found");
const token = await packageToken(c, storage, appId, pkg);
if (!token) return fail(c, 401, "a token for this package is required");
if (!token.channels.includes(channel)) return fail(c, 403, `token is not allowed to publish to ${channel}`);
Expand All @@ -143,7 +143,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
const rollout = parseRollout(body?.["rollout"], 100);
if (rollout === null) return fail(c, 400, "rollout must be an integer from 0 to 100");

const stored = await storage.getObject(objectKey(appId, pkg, rv, version, "manifest.json"));
const stored = await storage.getObject(objectKey(appId, pkg, hostVersion, version, "manifest.json"));
if (!stored) return fail(c, 409, `${version}/manifest.json is not uploaded yet: content first, pointer last`);
// the registered key, never the one inside the manifest: a token cannot bring its own key
if (!(await verifySignature(registered.publicKey, stored.body, signature))) return fail(c, 400, "signature does not verify with the registered publicKey");
Expand All @@ -154,36 +154,36 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
return fail(c, 400, (e as Error).message);
}
if (manifest.name !== pkg) return fail(c, 400, `manifest name ${manifest.name} is not the path package ${pkg}`);
if (manifest.runtimeVersion !== rv) return fail(c, 400, `manifest runtimeVersion ${manifest.runtimeVersion} is not the path runtime version ${rv}`);
if (manifest.hostVersion !== hostVersion) return fail(c, 400, `manifest hostVersion ${manifest.hostVersion} is not the path host version ${hostVersion}`);
if (manifest.version !== version) return fail(c, 400, `manifest version ${manifest.version} is not the pointer version ${version}`);
if (manifest.publicKey !== registered.publicKey) return fail(c, 400, "manifest publicKey is not the registered one");
for (const module of [...manifest.runtime, ...manifest.pages]) {
const path = manifest.files[module] + ".bin";
const info = await storage.headObject(objectKey(appId, pkg, rv, version, path));
const info = await storage.headObject(objectKey(appId, pkg, hostVersion, version, path));
if (!info) return fail(c, 409, `${version}/${path} is not uploaded yet`);
if (info.sha256 !== manifest.hashes[module]) return fail(c, 409, `${version}/${path} does not match manifest.hashes`);
}

const releaseKey = docKeys.release(appId, pkg, rv, version);
const releaseKey = docKeys.release(appId, pkg, hostVersion, version);
const existing = await storage.getDoc<ReleaseRecord>(releaseKey);
const release: ReleaseRecord = { createdAt: manifest.createdAt, signature, publishedAt: existing?.publishedAt ?? new Date().toISOString() };
await storage.putDoc(releaseKey, release);
const pointer: PointerDoc = { version, rollout, signature };
await storage.putDoc(docKeys.pointer(appId, pkg, rv, channel), pointer);
await storage.putDoc(docKeys.pointer(appId, pkg, hostVersion, channel), pointer);
return c.json(pointer);
});

app.put("/:app/:pkg/:rv/:version/*", async (c) => {
const { app: appId, pkg, rv, version } = c.req.param();
app.put("/:app/:pkg/:hostVersion/:version/*", async (c) => {
const { app: appId, pkg, hostVersion, version } = c.req.param();
const path = c.req.path.split("/").slice(5).join("/");
if (!isName(appId) || !isName(pkg) || !isSegment(rv) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found");
if (!isName(appId) || !isName(pkg) || !isHostVersion(hostVersion) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found");
if (!(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required");
const length = Number(c.req.header("content-length") ?? "0");
if (length > maxObjectBytes) return fail(c, 413, `objects are limited to ${maxObjectBytes} bytes`);
const body = await readBounded(c.req.raw.body, maxObjectBytes);
if (!body) return fail(c, 413, `objects are limited to ${maxObjectBytes} bytes`);
const sha256 = await sha256Hex(body);
const key = objectKey(appId, pkg, rv, version, path);
const key = objectKey(appId, pkg, hostVersion, version, path);
if (await storage.putObjectIfAbsent(key, body, sha256)) return c.json({ path, sha256, existing: false }, 201);
// a version never changes: the same bytes again are fine, different ones are another version's business
const existing = await storage.headObject(key);
Expand All @@ -193,20 +193,20 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {

// ---- delivery (public), §2 ----

app.get("/:app/:channel/:pkg/:rv/current.json", async (c) => {
const { app: appId, channel, pkg, rv } = c.req.param();
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv)) return fail(c, 404, "not found");
const pointer = await storage.getDoc<PointerDoc>(docKeys.pointer(appId, pkg, rv, channel));
app.get("/:app/:channel/:pkg/:hostVersion/current.json", async (c) => {
const { app: appId, channel, pkg, hostVersion } = c.req.param();
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion)) return fail(c, 404, "not found");
const pointer = await storage.getDoc<PointerDoc>(docKeys.pointer(appId, pkg, hostVersion, channel));
if (!pointer) return fail(c, 404, "not found");
return c.json(pointer, 200, { "Cache-Control": "no-store" });
});

app.get("/:app/:channel/:pkg/:rv/:version/*", async (c) => {
const { app: appId, channel, pkg, rv, version } = c.req.param();
app.get("/:app/:channel/:pkg/:hostVersion/:version/*", async (c) => {
const { app: appId, channel, pkg, hostVersion, version } = c.req.param();
const path = c.req.path.split("/").slice(6).join("/");
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isSegment(rv) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found");
if (!isName(appId) || !isName(channel) || !isName(pkg) || !isHostVersion(hostVersion) || !isSegment(version) || !isObjectPath(path)) return fail(c, 404, "not found");
// the channel is not part of the key: one upload serves every channel that points at it
const object = await storage.streamObject(objectKey(appId, pkg, rv, version, path));
const object = await storage.streamObject(objectKey(appId, pkg, hostVersion, version, path));
if (!object) return fail(c, 404, "not found");
return c.body(object.body, 200, {
"Cache-Control": IMMUTABLE,
Expand Down
8 changes: 4 additions & 4 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export const docKeys = {
tokenByHash: (hash: string) => `token:${hash}`,
tokenById: (app: string, pkg: string, id: string) => `tokenid:${app}:${pkg}:${id}`,
// one document per version and per channel: nothing is ever read, modified and written back
release: (app: string, pkg: string, rv: string, version: string) => `${docKeys.releasePrefix(app, pkg, rv)}${version}`,
releasePrefix: (app: string, pkg: string, rv: string) => `release:${app}:${pkg}:${rv}:`,
pointer: (app: string, pkg: string, rv: string, channel: string) => `${docKeys.pointerPrefix(app, pkg, rv)}${channel}`,
pointerPrefix: (app: string, pkg: string, rv: string) => `pointer:${app}:${pkg}:${rv}:`,
release: (app: string, pkg: string, hostVersion: string, version: string) => `${docKeys.releasePrefix(app, pkg, hostVersion)}${version}`,
releasePrefix: (app: string, pkg: string, hostVersion: string) => `release:${app}:${pkg}:${hostVersion}:`,
pointer: (app: string, pkg: string, hostVersion: string, channel: string) => `${docKeys.pointerPrefix(app, pkg, hostVersion)}${channel}`,
pointerPrefix: (app: string, pkg: string, hostVersion: string) => `pointer:${app}:${pkg}:${hostVersion}:`,
};

export function bearer(c: Context): string | null {
Expand Down
Loading
Loading