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
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,31 @@ All requests carry `Authorization: Bearer <token>`.

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

## Management (`ADMIN_TOKEN`)
## Management

| Request | Meaning |
|---|---|
| `POST /apps` `{ id, name, org? }` | create an app (`id` is a path segment; `org` is for billing only) |
| `POST /apps/<app>/packages` `{ name, publicKey }` | register a package and its verification key |
| `PUT /apps/<app>/packages/<pkg>/publicKey` | rotate the key |
| `POST /apps/<app>/packages/<pkg>/tokens` `{ channels }` | issue a publish token limited to channels; returned once, stored as a hash |
| `DELETE /apps/<app>/packages/<pkg>/tokens/<id>` | revoke |
An **app** is one host App: paths, host versions, snapshots and permissions hang off it. Its owner for billing (`org`, the tenant) can be renamed or transferred, so it appears in no path and grants nothing; one tenant may own several apps.

Three layers of credentials, each issued by the one above:

| Credential | Reaches | Issued by |
|---|---|---|
| the instance's `ADMIN_TOKEN` | every app; the only one that creates apps and issues app tokens | `wrangler secret put` |
| app token | everything under one app: packages, keys, publish tokens, releases, host snapshots | `ADMIN_TOKEN` |
| publish token | one package, limited to channels; can read its app's host snapshots | `ADMIN_TOKEN` or an app token of that app |

| Request | Credential | Meaning |
|---|---|---|
| `POST /apps` `{ id, name, org? }` | admin | create an app (`id` is a path segment) |
| `POST /apps/<app>/tokens` | admin | issue an app token; returned once, stored as a hash |
| `DELETE /apps/<app>/tokens/<id>` | admin | revoke it; an app token cannot mint or revoke app tokens |
| `POST /apps/<app>/packages` `{ name, publicKey }` | admin or app token | register a package and its verification key |
| `PUT /apps/<app>/packages/<pkg>/publicKey` | admin or app token | rotate the key |
| `POST /apps/<app>/packages/<pkg>/tokens` `{ channels }` | admin or app token | issue a publish token limited to channels; returned once, stored as a hash |
| `DELETE /apps/<app>/packages/<pkg>/tokens/<id>` | admin or app token | revoke |
| `PUT /apps/<app>/hosts/<hostVersion>` (snapshot bytes) | admin or app token | the host's CI uploads what that host version provides; written once, different bytes → 409 |
| `GET /apps/<app>/hosts/<hostVersion>` | any token of the app | read it back; `tinyui bundle` checks a package against it before signing |

None of these reach the clients' trust: devices verify with the key embedded in the App.

## Self-hosting

Expand Down
32 changes: 24 additions & 8 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,31 @@

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

## 管理(`ADMIN_TOKEN`)
## 管理

| 请求 | 语义 |
|---|---|
| `POST /apps` `{ id, name, org? }` | 建 app(`id` 是路径段;`org` 只用于计费归属) |
| `POST /apps/<app>/packages` `{ name, publicKey }` | 登记包与其验签公钥 |
| `PUT /apps/<app>/packages/<pkg>/publicKey` | 轮换公钥 |
| `POST /apps/<app>/packages/<pkg>/tokens` `{ channels }` | 签发限定 channel 的发布 token;只返回一次,服务端只存哈希 |
| `DELETE /apps/<app>/packages/<pkg>/tokens/<id>` | 吊销 |
**app** 是一个宿主 App:路径、宿主版本、快照与权限都挂在它上面。它的计费归属(`org`,即租户)可以改名、转让,所以不进路径、不带权限;一个租户可以有多个 app。

凭据三层,下层由上层签发:

| 凭据 | 范围 | 签发者 |
|---|---|---|
| 实例的 `ADMIN_TOKEN` | 所有 app;只有它能建 app、签发 app token | `wrangler secret put` |
| app token | 一个 app 下的一切:包、公钥、发布 token、release、宿主快照 | `ADMIN_TOKEN` |
| 发布 token | 一个包,限定 channel;可读本 app 的宿主快照 | `ADMIN_TOKEN` 或本 app 的 app token |

| 请求 | 凭据 | 语义 |
|---|---|---|
| `POST /apps` `{ id, name, org? }` | admin | 建 app(`id` 是路径段) |
| `POST /apps/<app>/tokens` | admin | 签发 app token;只返回一次,服务端只存哈希 |
| `DELETE /apps/<app>/tokens/<id>` | admin | 吊销;app token 不能签发或吊销 app token |
| `POST /apps/<app>/packages` `{ name, publicKey }` | admin 或 app token | 登记包与其验签公钥 |
| `PUT /apps/<app>/packages/<pkg>/publicKey` | admin 或 app token | 轮换公钥 |
| `POST /apps/<app>/packages/<pkg>/tokens` `{ channels }` | admin 或 app token | 签发限定 channel 的发布 token;只返回一次,服务端只存哈希 |
| `DELETE /apps/<app>/packages/<pkg>/tokens/<id>` | admin 或 app token | 吊销 |
| `PUT /apps/<app>/hosts/<hostVersion>`(快照字节) | admin 或 app token | 宿主 CI 上传该宿主版本提供了什么;只写一次,不同字节 → 409 |
| `GET /apps/<app>/hosts/<hostVersion>` | 本 app 的任一 token | 读回;`tinyui bundle` 签名前据此核对包 |

这些都碰不到客户端的信任链:设备只认 App 内置的公钥。

## 私有化部署

Expand Down
55 changes: 51 additions & 4 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import type { Context } from "hono";
import { docKeys, isAdmin, issueToken, packageToken, revokeToken } from "./auth.ts";
import { anyPackageToken, canManage, docKeys, isAdmin, issueAppToken, issueToken, packageToken, revokeAppToken, revokeToken } from "./auth.ts";
import { isHostVersion, isName, isObjectPath, isSegment } from "./ids.ts";
import { parseManifest } from "./manifest.ts";
import type { AppRecord, PackageRecord, PointerDoc, ReleaseRecord } from "./records.ts";
Expand All @@ -19,13 +19,23 @@ 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, hostVersion: string, version: string, path: string) => `${app}/${pkg}/${hostVersion}/${version}/${path}`;
// `_` is not allowed in an app id, so these keys never meet a package's content
const hostKey = (app: string, hostVersion: string) => `_hosts/${app}/${hostVersion}`;

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

// ---- management (ADMIN_TOKEN), tinyui docs/updates.md §6.3 ----

app.use("/apps/*", async (c, next) => (isAdmin(c, adminToken) ? next() : fail(c, 401, "admin token required")));
app.post("/apps", async (c, next) => (isAdmin(c, adminToken) ? next() : fail(c, 401, "admin token required")));
const admin = async (c: Context, next: () => Promise<void>) => (isAdmin(c, adminToken) ? next() : fail(c, 401, "admin token required"));
// everything else under /apps/<app>: the admin token or that app's app token; ids are checked by each route
const manager = async (c: Context, next: () => Promise<void>) =>
(await canManage(c, storage, adminToken, c.req.param("app") ?? "")) ? next() : fail(c, 401, "the admin token or an app token of this app is required");
app.post("/apps", admin);
app.post("/apps/:app/tokens", admin);
app.delete("/apps/:app/tokens/:tokenId", admin);
app.post("/apps/:app/packages", manager);
app.on(["POST", "PUT", "DELETE"], "/apps/:app/packages/*", manager);
app.put("/apps/:app/hosts/:hostVersion", manager);

app.post("/apps", async (c) => {
const body = await json(c);
Expand All @@ -41,6 +51,43 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
return c.json(record, 201);
});

app.post("/apps/:app/tokens", async (c) => {
const appId = c.req.param("app");
if (!isName(appId) || !(await storage.getDoc(docKeys.app(appId)))) return fail(c, 404, `no app ${appId}`);
const { token, record } = await issueAppToken(storage, appId);
return c.json({ id: record.id, token, createdAt: record.createdAt }, 201);
});

app.delete("/apps/:app/tokens/:tokenId", async (c) => {
const { app: appId, tokenId } = c.req.param();
if (!isName(appId) || !(await revokeAppToken(storage, appId, tokenId))) return fail(c, 404, "no such token");
return c.body(null, 204);
});

// host snapshots, §6.4: written once per (app, hostVersion) by the host's CI, read back by `tinyui bundle`
app.put("/apps/:app/hosts/:hostVersion", async (c) => {
const { app: appId, hostVersion } = c.req.param();
if (!isName(appId) || !isHostVersion(hostVersion) || !(await storage.getDoc(docKeys.app(appId)))) return fail(c, 404, "not found");
const body = await readBounded(c.req.raw.body, maxObjectBytes);
if (!body) return fail(c, 413, `snapshots are limited to ${maxObjectBytes} bytes`);
if (body.byteLength === 0) return fail(c, 400, "a snapshot cannot be empty");
const sha256 = await sha256Hex(body);
const key = hostKey(appId, hostVersion);
if (await storage.putObjectIfAbsent(key, body, sha256)) return c.json({ hostVersion, sha256, existing: false }, 201);
const existing = await storage.headObject(key);
if (existing?.sha256 === sha256) return c.json({ hostVersion, sha256, existing: true });
return fail(c, 409, `host version ${hostVersion} of ${appId} already has a different snapshot; a shipped host version is frozen`);
});

app.get("/apps/:app/hosts/:hostVersion", async (c) => {
const { app: appId, hostVersion } = c.req.param();
if (!isName(appId) || !isHostVersion(hostVersion)) return fail(c, 404, "not found");
if (!(await canManage(c, storage, adminToken, appId)) && !(await anyPackageToken(c, storage, appId))) return fail(c, 401, "a token of this app is required");
const object = await storage.getObject(hostKey(appId, hostVersion));
if (!object) return fail(c, 404, `host version ${hostVersion} of ${appId} has no snapshot; upload it from the host's CI`);
return c.body(object.body as unknown as ArrayBuffer, 200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
});

app.post("/apps/:app/packages", async (c) => {
const appId = c.req.param("app");
if (!isName(appId) || !(await storage.getDoc(docKeys.app(appId)))) return fail(c, 404, `no app ${appId}`);
Expand Down Expand Up @@ -89,7 +136,7 @@ export function createApp({ storage, adminToken, maxObjectBytes }: Deps): Hono {
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");
if (!(await canManage(c, storage, adminToken, appId)) && !(await packageToken(c, storage, appId, pkg))) return fail(c, 401, "a token for this package is required");
const releasePrefix = docKeys.releasePrefix(appId, pkg, hostVersion);
const versions: { version: string; createdAt: string; publishedAt: string }[] = [];
for (const key of await storage.listDocs(releasePrefix)) {
Expand Down
67 changes: 58 additions & 9 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,20 @@ export interface TokenRecord {
createdAt: string;
}

/** An app token: the whole management of one app, issued by the admin token only (tinyui docs/updates.md §6). */
export interface AppTokenRecord {
kind: "app";
id: string;
app: string;
createdAt: string;
}

export const docKeys = {
app: (app: string) => `app:${app}`,
pkg: (app: string, pkg: string) => `pkg:${app}:${pkg}`,
tokenByHash: (hash: string) => `token:${hash}`,
tokenById: (app: string, pkg: string, id: string) => `tokenid:${app}:${pkg}:${id}`,
appTokenById: (app: string, id: string) => `apptokenid:${app}:${id}`,
// one document per version and per channel: nothing is ever read, modified and written back
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}:`,
Expand All @@ -35,22 +44,55 @@ export function isAdmin(c: Context, adminToken: string): boolean {

/** The token record the request carries when it is a live token of (app, pkg); null otherwise. */
export async function packageToken(c: Context, storage: Storage, app: string, pkg: string): Promise<TokenRecord | null> {
const record = await presented(c, storage);
return record && !isAppToken(record) && record.app === app && record.pkg === pkg ? record : null;
}

/** A live publish token of any package of [app]: it may read that app's host snapshots. */
export async function anyPackageToken(c: Context, storage: Storage, app: string): Promise<boolean> {
const record = await presented(c, storage);
return !!record && !isAppToken(record) && record.app === app;
}

/** The admin token, or a live app token of [app]: everything under /apps/<app> except minting app tokens. */
export async function canManage(c: Context, storage: Storage, adminToken: string, app: string): Promise<boolean> {
if (isAdmin(c, adminToken)) return true;
const record = await presented(c, storage);
return !!record && isAppToken(record) && record.app === app;
}

async function presented(c: Context, storage: Storage): Promise<TokenRecord | AppTokenRecord | null> {
const token = bearer(c);
if (!token) return null;
const record = await storage.getDoc<TokenRecord>(docKeys.tokenByHash(await sha256Hex(token)));
if (!record || record.app !== app || record.pkg !== pkg) return null;
return record;
return token ? storage.getDoc<TokenRecord | AppTokenRecord>(docKeys.tokenByHash(await sha256Hex(token))) : null;
}

function isAppToken(record: TokenRecord | AppTokenRecord): record is AppTokenRecord {
return (record as AppTokenRecord).kind === "app";
}

export async function issueAppToken(storage: Storage, app: string): Promise<{ token: string; record: AppTokenRecord }> {
const { id, token, hash } = await freshToken();
const record: AppTokenRecord = { kind: "app", id, app, createdAt: new Date().toISOString() };
// the revocation reference first: if the second write fails, no live token exists that revocation cannot find
await storage.putDoc(docKeys.appTokenById(app, id), { hash });
await storage.putDoc(docKeys.tokenByHash(hash), record);
return { token, record };
}

export async function revokeAppToken(storage: Storage, app: string, id: string): Promise<boolean> {
const ref = await storage.getDoc<{ hash: string }>(docKeys.appTokenById(app, id));
if (!ref) return false;
await storage.deleteDoc(docKeys.tokenByHash(ref.hash));
await storage.deleteDoc(docKeys.appTokenById(app, id));
return true;
}

/** A fresh token: `<id>.<secret>`; the id is what revocation names, the whole string is what gets hashed. */
export async function issueToken(storage: Storage, app: string, pkg: string, channels: string[]): Promise<{ token: string; record: TokenRecord }> {
const id = hex(crypto.getRandomValues(new Uint8Array(6)));
const secret = base64url(crypto.getRandomValues(new Uint8Array(32)));
const token = `${id}.${secret}`;
const { id, token, hash } = await freshToken();
const record: TokenRecord = { id, app, pkg, channels, createdAt: new Date().toISOString() };
const hash = await sha256Hex(token);
await storage.putDoc(docKeys.tokenByHash(hash), record);
await storage.putDoc(docKeys.tokenById(app, pkg, id), { hash });
await storage.putDoc(docKeys.tokenByHash(hash), record);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return { token, record };
}

Expand All @@ -62,6 +104,13 @@ export async function revokeToken(storage: Storage, app: string, pkg: string, id
return true;
}

async function freshToken(): Promise<{ id: string; token: string; hash: string }> {
// 128 bits: two live tokens sharing an id would leave the first one without a revocation reference
const id = hex(crypto.getRandomValues(new Uint8Array(16)));
const token = `${id}.${base64url(crypto.getRandomValues(new Uint8Array(32)))}`;
return { id, token, hash: await sha256Hex(token) };
}

async function sha256Hex(text: string): Promise<string> {
return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))));
}
Expand Down
Loading
Loading