1// Typed fetch client for the Worker API.23import type {4 ApiError,5 AppReservationInfo,6 CommitRequest,7 CommitResponse,8 Manifest,9 NotifyApprovalPreview,10 NotifyApprovalRequest,11 NotifyDeviceInfo,12 NotifyPairingWindow,13 NotifyRequest,14 NotifyResponse,15 PrepareResponse,16 SiteDetail,17 SiteInfo,18 SourceResponse,19 StatusResponse,20 Target,21 Ttl,22 VaultInfo,23 VaultLifecycle,24} from "@nzip/shared";25import { parseAddress, vaultSlotOf } from "@nzip/shared";26import { assertVaultAllowed, type Config } from "./config.ts";2728export class ApiClient {29 constructor(private config: Config) {}3031 private async request<T>(32 method: string,33 path: string,34 body?: BodyInit | Uint8Array,35 ): Promise<T> {36 const fetchBody: BodyInit | undefined = body instanceof Uint8Array37 ? body.buffer instanceof ArrayBuffer38 ? body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)39 : body.slice().buffer40 : body;41 const res = await fetch(`${this.config.server}${path}`, {42 method,43 headers: {44 authorization: `Bearer ${this.config.token}`,45 ...(typeof body === "string" ? { "content-type": "application/json" } : {}),46 ...(body instanceof Uint8Array ? { "content-length": String(body.byteLength) } : {}),47 },48 body: fetchBody,49 });50 const text = await res.text();51 if (!res.ok) {52 let message = text;53 try {54 message = (JSON.parse(text) as ApiError).error;55 } catch { /* not json */ }56 throw new Error(`${res.status}: ${message}`);57 }58 return JSON.parse(text) as T;59 }6061 private async requestBytes(62 method: string,63 path: string,64 ): Promise<Uint8Array> {65 const res = await fetch(`${this.config.server}${path}`, {66 method,67 headers: { authorization: `Bearer ${this.config.token}` },68 });69 if (!res.ok) {70 const text = await res.text();71 let message = text;72 try {73 message = (JSON.parse(text) as ApiError).error;74 } catch { /* not json */ }75 throw new Error(`${res.status}: ${message}`);76 }77 return new Uint8Array(await res.arrayBuffer());78 }7980 status(): Promise<StatusResponse> {81 return this.request("GET", "/api/status");82 }8384 prepare(manifest: Manifest): Promise<PrepareResponse> {85 return this.request(86 "POST",87 "/api/push/prepare",88 JSON.stringify({ manifest }),89 );90 }9192 uploadBlob(hash: string, bytes: Uint8Array): Promise<{ ok: true }> {93 return this.request(94 "PUT",95 `/api/blob/${hash}`,96 bytes,97 );98 }99100 commit(req: CommitRequest): Promise<CommitResponse> {101 return this.request("POST", "/api/push/commit", JSON.stringify(req));102 }103104 listSites(vault?: string): Promise<SiteInfo[]> {105 const q = vault ? `?vault=${encodeURIComponent(vault)}` : "";106 return this.request("GET", `/api/sites${q}`);107 }108109 siteDetail(target: string): Promise<SiteDetail> {110 return this.request("GET", `/api/sites/${encodeURIComponent(target)}`);111 }112113 source(target: string): Promise<SourceResponse> {114 return this.request(115 "GET",116 `/api/sites/${encodeURIComponent(target)}/source`,117 );118 }119120 downloadSourceBlob(target: string, hash: string): Promise<Uint8Array> {121 return this.requestBytes(122 "GET",123 `/api/sites/${encodeURIComponent(target)}/source/${encodeURIComponent(hash)}`,124 );125 }126127 patchSite(128 target: string,129 patch: { ttl?: number | "forever"; password?: string | null },130 ): Promise<SiteDetail> {131 return this.request(132 "PATCH",133 `/api/sites/${encodeURIComponent(target)}`,134 JSON.stringify(patch),135 );136 }137138 deleteSite(target: string): Promise<{ ok: true; address: string }> {139 return this.request("DELETE", `/api/sites/${encodeURIComponent(target)}`);140 }141142 revert(143 target: string,144 toSeq?: number,145 ): Promise<CommitResponse & { revertedTo: number }> {146 return this.request(147 "POST",148 `/api/sites/${encodeURIComponent(target)}/revert`,149 JSON.stringify(toSeq === undefined ? {} : { toSeq }),150 );151 }152153 listVaults(): Promise<VaultInfo[]> {154 return this.request("GET", "/api/vaults");155 }156157 createVault(158 name: string,159 slot?: number,160 description?: string,161 defaultTtl?: Ttl | null,162 defaultFor?: VaultLifecycle | null,163 ): Promise<VaultInfo> {164 return this.request(165 "POST",166 "/api/vaults",167 JSON.stringify({ name, slot, description, defaultTtl, defaultFor }),168 );169 }170171 updateVault(172 currentName: string,173 patch: {174 name?: string;175 description?: string | null;176 defaultTtl?: Ttl | null;177 defaultFor?: VaultLifecycle | null;178 },179 ): Promise<VaultInfo> {180 return this.request(181 "PATCH",182 `/api/vaults/${encodeURIComponent(currentName)}`,183 JSON.stringify(patch),184 );185 }186187 setDefaultVault(188 lifecycle: VaultLifecycle,189 name: string,190 ): Promise<{ defaultVaults: Record<VaultLifecycle, string | null>; vault: VaultInfo }> {191 return this.request(192 "PUT",193 `/api/default-vaults/${lifecycle}`,194 JSON.stringify({ name }),195 );196 }197198 initApp(vault: string, alias: string): Promise<AppReservationInfo> {199 return this.request(200 "POST",201 "/api/apps",202 JSON.stringify({ target: { vault, alias } }),203 );204 }205206 notify(event: NotifyRequest): Promise<NotifyResponse> {207 return this.request("POST", "/api/notify", JSON.stringify(event));208 }209210 openNotificationPairing(): Promise<NotifyPairingWindow> {211 return this.request("POST", "/api/notify/pairing", JSON.stringify({}));212 }213214 notificationDevices(): Promise<NotifyDeviceInfo[]> {215 return this.request("GET", "/api/notify/devices");216 }217218 notificationApprovalPreview(code: string): Promise<NotifyApprovalPreview> {219 return this.request(220 "GET",221 `/api/notify/approvals/${encodeURIComponent(code)}`,222 );223 }224225 approveNotificationDevice(226 approval: NotifyApprovalRequest,227 ): Promise<NotifyDeviceInfo> {228 return this.request(229 "POST",230 "/api/notify/approvals",231 JSON.stringify(approval),232 );233 }234235 revokeNotificationDevice(deviceId: string): Promise<{ ok: true; deviceId: string }> {236 return this.request(237 "DELETE",238 `/api/notify/devices/${encodeURIComponent(deviceId)}`,239 );240 }241}242243/**244 * Resolve a CLI target string to (API path target, commit body target).245 * A bare alias picks up the default vault from config.246 */247export function resolveCliTarget(248 raw: string,249 config: Config,250 vaults: Pick<VaultInfo, "slot" | "name">[] = [],251): string {252 if (/^[0-9a-f]{4}$/.test(raw)) {253 if (config.allowVaults) {254 const slot = vaultSlotOf(parseAddress(raw));255 const vault = vaults.find((candidate) => candidate.slot === slot);256 if (!vault) {257 throw new Error(258 `cannot validate address ${raw}: authenticated status did not identify vault slot 0x${259 slot.toString(16)260 }`,261 );262 }263 assertVaultAllowed(vault.name, config);264 }265 return raw;266 }267 if (raw.includes(":")) {268 const [vault] = raw.split(":");269 assertVaultAllowed(vault, config);270 return raw;271 }272 const vault = config.defaultVault;273 if (!vault) {274 throw new Error(275 `"${raw}" is a bare alias but no defaultVault is set — use vault:alias or set defaultVault in config`,276 );277 }278 assertVaultAllowed(vault, config);279 return `${vault}:${raw}`;280}281282export function commitTargetFor(283 raw: string | undefined,284 config: Config,285 vaults: Pick<VaultInfo, "slot" | "name">[] = [],286): Target {287 if (raw === undefined) {288 const vault = config.defaultVault;289 if (!vault) throw new Error("no target given and no defaultVault set");290 assertVaultAllowed(vault, config);291 return { vault };292 }293 if (/^[0-9a-f]{4}$/.test(raw)) {294 resolveCliTarget(raw, config, vaults);295 return { address: parseInt(raw, 16) };296 }297 const resolved = resolveCliTarget(raw, config, vaults);298 const [vault, alias] = resolved.split(":");299 return { vault, alias };300}301302/** Resolve a target, fetching vault-slot metadata only when a raw address needs guarding. */303export async function resolveCliTargetWithStatus(304 raw: string,305 config: Config,306 api: Pick<ApiClient, "status">,307): Promise<string> {308 const vaults = /^[0-9a-f]{4}$/.test(raw) && config.allowVaults ? (await api.status()).vaults : [];309 return resolveCliTarget(raw, config, vaults);310}