Standalone Repository
Connect your CLI and Console to a self-hosted Hot Updater server.
Overview
Configure standaloneRepository as the database in hot-updater.config.ts
or in a deployed Console's configuration when your metadata lives behind a
self-hosted server. It does not open the database itself: it calls the
server's admin handler, and the server runs each change on its own database.
List the server's plugins beside it in plugins, so the CLI and the Console
know which ones the server runs.
See the Self-Hosting (Custom) Guide for complete server implementation instructions using @hot-updater/server with database adapters (Drizzle, Prisma, Kysely, MongoDB) and frameworks (Hono, Express, Elysia).
Installation
Install the adapter in the app workspace. The CLI provides the official
plugin factories through hot-updater/plugins:
npm install @hot-updater/standalone --save-devUsage
Configure your CLI to connect to your self-hosted server:
import { apiKeys, insights } from "hot-updater/plugins";
import { standaloneRepository } from "@hot-updater/standalone";
import { defineConfig } from "hot-updater";
const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) {
throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");
}
export default defineConfig({
build, // Your existing build adapter
storage, // Your existing storage adapter
database: standaloneRepository({
baseUrl: "http://localhost:3000/hot-updater/admin",
commonHeaders: {
Authorization: `Bearer ${adminToken}`,
},
}),
plugins: [insights(), apiKeys()], // The plugins the server runs
updateStrategy: "appVersion",
});Remote storage adapter
For CLI uploads through a separate HTTP storage service, use
standaloneStorage. Its configuration and
service contract belong to Storage Adapters; the repository below manages
database rows through the admin API.
Configuration
standaloneRepository accepts the following options:
interface StandaloneRepositoryConfig {
baseUrl: string; // Exact root where handlers.admin is mounted
commonHeaders?: Record<string, string>; // Sent with every admin request
}Every admin path is fixed relative to baseUrl. The repository is
{ name, core, fetchAdmin }:
coreis core's API for Bundles, Releases, Release Catalogs and Channels, sent over the admin API. The CLI's commands and the Console use it.fetchAdmin(path, init?)sends a request to the admin handler with the same headers: a GET, unlessinitnames another method, withinit's headers added. The Console reads the server's Insights and edits its Remote Config through it.
Before its first request, core reads GET /version from the admin handler.
When /version does not report adminProtocol 2 or later, or has no
plugins list because baseUrl is the client mount, the CLI fails with a
message to use the path of handlers.admin. When the server answers 503
because its database lacks its schema settings, the CLI says to run
hot-updater db migrate where the server's database is configured.
standaloneRepository belongs only in hot-updater.config.ts and Console
configuration. createHotUpdater needs the server's own database and throws
HotUpdaterConfigError when given this repository.
Secure example
const adminToken = process.env.HOT_UPDATER_ADMIN_TOKEN;
if (!adminToken) {
throw new Error("HOT_UPDATER_ADMIN_TOKEN is required");
}
database: standaloneRepository({
baseUrl: "http://localhost:3000/hot-updater/admin",
commonHeaders: {
Authorization: `Bearer ${adminToken}`,
},
});The admin handler does not authenticate requests itself. Keep its credential in server-side CLI or Console configuration and match the framework middleware in the server setup.
Alternate authentication
database: standaloneRepository({
baseUrl: process.env.HOT_UPDATER_ADMIN_URL!,
commonHeaders: {
"X-Internal-Service-Key": process.env.INTERNAL_SERVICE_KEY!,
},
});Server contract
standaloneRepository is an HTTP client for the admin API, protocol 2. All
paths are relative to baseUrl, and every response carries
cache-control: private, no-store.
If you use @hot-updater/server, you do not need to implement these
endpoints. createHotUpdater() provides this contract through
handlers.admin.
Version
GET /version reports the protocol and the ids of the plugins the server
runs, sorted:
{
"adminProtocol": 2,
"infrastructureGeneration": 1,
"version": "1.0.0",
"plugins": ["apiKeys", "insights"]
}Routes
The types are exported from @hot-updater/plugin-core. A successful JSON
response wraps its result in data.
| Method | Route | Request | Success response |
|---|---|---|---|
GET | /bundles | Page, platform, total=true | BundleDetail[], with next and total |
GET | /bundles/:id | BundleDetail, or 404 | |
PATCH | /bundles/:id | The Bundle fields to change | 204 |
POST | /bundles/delete | { ids: string[] } | 204 |
GET | /bundles/:id/children | Page | BundlePatchRow[] whose base is the Bundle |
GET | /bundles/count | platform | How many Bundles: all, or one platform's |
GET | /bundles/child-counts | ids=<bundle id>,... (1–100) | Each base Bundle's child count, by ID |
GET | /base-candidates/:candidateKey | before=<bundle id>, limit (1–100, default 3) | Auto-patch base Bundle IDs |
POST | /releases | { deployments: Deployment[] } | 201, ReleaseCatalogMutationResult[] |
GET | /releases | Page and one filter set | ReleaseRow[] |
GET | /releases/:id | ReleaseRow, or 404 | |
PATCH | /releases/:id | { patch, expectedRevision? } | ReleaseCatalogMutationResult |
POST | /releases/:id/preflight | { patch, expectedRevision? } | ReleaseCatalogMutationPreflight |
POST | /releases/:id/promote | { targetChannel, action?, expectedRevision? } | PromoteReleaseResult |
DELETE | /releases/:id | confirm=<release id>, optional expectedRevision | ReleaseCatalogMutationResult |
GET | /release-catalogs | Page | ReleaseCatalogRow[] |
GET | /release-catalogs/:scopeKey | ReleaseCatalogRow, or 404 | |
POST | /release-catalogs/:scopeKey/rebuild | ReleaseCatalogRebuildResult | |
POST | /release-catalogs/:scopeKey/preflight | ReleaseCatalogRebuildPreflight | |
GET | /channels | Optional name | ChannelRow[], or the ChannelRow for name or 404 |
POST | /channels | { name } | The ChannelRow of that name, created when missing |
DELETE | /channels/:id | 204 |
PATCH /releases/:id takes a ReleasePolicyPatch. promote copies a Bundle
Release into targetChannel; action: "move" also disables the source.
DELETE /releases/:id deletes a disabled Release and names it twice, in the
path and as confirm. PATCH /bundles/:id rejects a body whose id names
another Bundle; patches, when given, replaces the Bundle's patches.
Pages and filters
List routes page by key. order is asc or desc (default desc), limit
is 1–500 (default 50, at most 100 for /bundles), and cursor is the next
value of the previous page. A page carries next only when it is full.
GET /bundles?total=true adds total, the number of Bundles, for platform
when given.
GET /releases accepts one of the filter sets its indexes serve: none;
channelId and platform, optionally with enabled; bundleId; or
scopeKey, optionally with enabled. Any other combination answers 400.
Deployments
POST /releases writes each deployment's Bundle with its patches and its
Release, with the next Release Catalog of each scope, in one transaction. A
deployment names a new Bundle, or bundleId to publish a new Release for a
Bundle the server already stores:
interface DeployReleasePolicy {
channel: string;
enabled: boolean;
fingerprintHash: string | null;
message: string | null;
rolloutCohortCount?: number; // 0 to 1000
shouldForceUpdate: boolean;
targetAppVersion: string | null;
targetCohorts?: string[];
}
type Deployment =
| { bundle: Bundle; release: DeployReleasePolicy }
| { bundleId: string; release: DeployReleasePolicy };A Bundle holds immutable artifact data; Channel, rollout, targeting,
enablement, force-update and message policy belong to its Release:
interface BundlePatchArtifact {
baseBundleId: string;
baseFileHash: string;
byteSize: number;
patchFileHash: string;
patchStorageUri: string;
}
interface Bundle {
id: string;
platform: "ios" | "android";
gitCommitHash: string | null;
metadata?: {
app_version?: string;
};
manifestStorageUri: string;
manifestFileHash: string;
assetBaseStorageUri: string;
patches?: BundlePatchArtifact[] | null;
}Errors
A refusal answers { "code": "...", "error": "..." }:
| Status | Codes |
|---|---|
404 | RELEASE_NOT_FOUND, BUNDLE_NOT_FOUND |
409 | VERSION_CONFLICT, or REFERENCED when a Release still uses a Bundle to delete |
400 | Other Release and Catalog refusals, such as ENABLED_RELEASE and INVALID_SCOPE |
DELETE /channels/:id answers 404 with
{ "data": { "deleted": false, "reason": "not_found" } }, or 409 with the
reason not_empty while a Release still uses the Channel. A request that fails
validation answers 400 with { "error": "..." }. While the server's database
lacks its schema settings, every route that reaches it answers 503.
Insights routes
With the insights() plugin, the admin handler also serves GET /overview,
/events, /installations, /installations/:installId and
/installations/:installId/events. Without it, the server has no such routes,
and each answers 404. These routes read the reports that apps
send with the Insights client plugin.
GET /failures?platform=ios&channel=production&releaseId=<id> answers a
release's update failures since its first report; add start and end, in
epoch milliseconds, for a period of at most 30 days, which a channel without
releaseId needs. It answers 400 for a channel without them or for a longer
period. See Update failures.
insights() also serves GET /retention, which answers how long it keeps
data as { "rawDays": 90, "dailyDays": 400 }.
Minimal @hot-updater/server example
If you want this contract without implementing it yourself, use
@hot-updater/server:
import { createHotUpdater } from "@hot-updater/server";
import { apiKeys, insights } from "@hot-updater/server/plugins";
const hotUpdater = createHotUpdater({
database,
plugins: [insights(), apiKeys()],
storage,
});Mount hotUpdater.handlers.admin at /hot-updater/admin to expose these
routes. Protect that mount with framework middleware and configure matching
commonHeaders in standaloneRepository. Mounting the handler is the explicit
opt-in; there is no built-in admin authenticator.
Through standaloneRepository, the Console manages Bundles, Releases,
Channels and Remote Config, and reads the server's Insights overview,
events, installations, update failures and retention periods; the CLI's
remote-config and insights commands use the same routes. The Console
cannot show usage or Release activity, and it does not manage the server's
API keys; manage them with
hot-updater api-key against the
server's createHotUpdater module. A hosted Console must protect its browser
session separately and keep the admin bearer token in server-side
commonHeaders. Do not put that token in browser or React Native code.
Next steps
- CLI Configuration - Complete CLI configuration guide
- Self-Hosting (Custom) Overview - Learn about the architecture
- Quick Start Guide - Set up a server
- Database Adapters - Choose your ORM (Drizzle, Prisma, Kysely, MongoDB)
- Server Frameworks - Choose your framework (Hono, Express, Elysia)