# FaynoSync - Self-Hosted Update Server > FaynoSync is an open-source, self-hosted update server (backend) for desktop and cross-platform apps. It does not replace client updaters — it serves update feeds for them: Velopack, Sparkle, Squirrel.Windows/Mac, electron-updater and Tauri, plus manual JSON updates, with channels, staged rollouts, TUF-signed metadata, telemetry and multi-cloud storage. ## Key facts - **What it is:** an open-source (Apache-2.0), self-hosted update server written in Go. You upload builds, it serves update checks, update feeds and downloads to your apps. - **What it is not:** not an installer or packaging framework and not a client-side updater. It does not replace Velopack, Sparkle, Squirrel, electron-updater or the Tauri updater — it is the backend those clients talk to. - **History:** developed since January 2023 (first commit 2023-01-13). Source: https://github.com/ku9nov/faynoSync - **Stack:** Go API, MongoDB, optional Redis (performance mode, TUF state), object storage: AWS S3, Garage, DigitalOcean Spaces, GCP Cloud Storage (MinIO deprecated). Admin dashboard: https://github.com/ku9nov/faynoSync-dashboard ## Supported client updaters - **Velopack** — native releases feed (releases.{channel}.json), delta updates, no extra SDK. - **Sparkle (macOS)** — hosted per-channel appcast, signatures and deltas preserved, no extra SDK. - **Squirrel.Windows / Squirrel.Mac** — RELEASES manifest and ZIP responses. - **electron-builder / electron-updater** — YAML feed for the generic provider. - **Tauri** — signature-verified JSON for the built-in Tauri updater. - **Manual** — plain JSON with update URLs for custom update logic (desktop, browser extensions, mobile, anything else). ## Key features - Custom channels, platforms and architectures with any names. - Staged (canary) rollouts with deterministic client-side bucketing and adoption tracking. - Required intermediate builds to force update paths across breaking changes. - Critical updates and private apps (private bucket, download tokens, short-lived signed URLs). - TUF-inspired signed metadata with threshold signatures, expiration and rollback protection. - Telemetry (version adoption, platforms, active users) and crash / failed-update / failed-install reports grouped for triage. - Team-based authorization and scoped CI tokens; CLI, GitHub Action and Jenkins integration for uploads. - Performance mode (Redis cache), edge-first delivery with S3 response cache, presigned uploads for large builds. - Official JavaScript/TypeScript and Go SDKs. Pages below are ordered by importance: overview, updaters, features, setup, tutorials, then the full API reference. ## Introduction to FaynoSync FaynoSync derives its name from the Ukrainian word "файно" (fayno), meaning "excellent," "fine," or "great." This informal term captures the positive and enjoyable experience that FaynoSync aims to provide. FaynoSync is a comprehensive, powerful auto-updater service written in Golang. Its primary purpose is to keep your applications up-to-date effortlessly across multiple platforms and update mechanisms. With FaynoSync, you can upload your application to various cloud storage providers and set the version number. The client application then communicates with the FaynoSync API to check for updates. If a newer version is available, FaynoSync provides a link to the updated service, and your application will notify the user with an alert. One of FaynoSync's key strengths is its flexibility and user control. Users can create custom channels, platforms, and architectures with any names they choose, allowing the same workflow to support various application types such as desktop apps, browser extensions, mobile applications, and more. Our goal is to ensure that your creativity is the only limit. ## Key Features - **[Unified Registry](./api/apps/app-introduction)**: FaynoSync serves as a central registry for different types of applications, providing a streamlined, intuitive API that simplifies managing your updates. - **Multi-Updater Support**: Support for various update mechanisms including Velopack, Sparkle, Squirrel Windows/macOS, Electron Builder, Tauri, and custom manual updates with artifact isolation for proper file organization. - **Seamless Updates**: Keep your applications always up-to-date with a straightforward and efficient auto-updating process that adapts to your specific update mechanism. - **Maximum Flexibility**: Define your channels, platforms, and architectures freely, giving you complete control over how your updates are managed. - **[Security](./api/tuf/tuf-introduction)**: Keep your cloud storage buckets and applications private — downloads are only accessible through authenticated access with granular permissions. FaynoSync implements a TUF-inspired secure update system for metadata signing, verification, and rollback protection. - **[Team Management](./team_based_auth)**: Create and manage team users with fine-grained permissions, controlling access to applications and resources across your organization. - **[Performance Mode](./performance)**: Enable intelligent caching to reduce server load and minimize response times, optimizing your application's performance. - **[Telemetry System](./telemetry)**: Gain valuable insights with detailed analytics about your application's usage, version distribution, and user base across platforms. - **[Crash & Failure Reports](./api/reports/reports-introduction)**: Collect crash, failed-update, and failed-install reports from clients, aggregated into deduplicated groups by version, channel, and platform — with open/resolved/muted triage status. - **[Staged Rollouts](./rollout)**: Ship each version to a controlled percentage of your fleet with deterministic, sticky client-side bucketing, then ramp to 100% — with real adoption tracked on the dashboard against the configured target. - **[Required Intermediate Builds](./intermediate_build)**: Enforce specific update paths for breaking changes and critical updates, ensuring safe and controlled application upgrades. - **Multi-Cloud Storage**: Seamless integration with AWS S3, Garage (S3-compatible), Digital Ocean Spaces, and GCP Cloud Storage for maximum flexibility and reliability. MinIO remains available for backward compatibility but is deprecated for new setups. - **Universal Platform Support**: Designed to work with desktop applications, browser extensions, mobile applications, and any other software that requires updates. ## Supported Update Mechanisms FaynoSync supports multiple update mechanisms to ensure compatibility with various application frameworks: - **Manual Updates**: Custom update logic for applications with specific requirements - **Velopack**: Cross-platform desktop updates served through Velopack's native feed protocol — no per-language SDK, with delta updates and CDN-by-default delivery - **Sparkle**: Native macOS auto-updates — faynoSync hosts a materialized appcast, preserving every element verbatim (signatures, deltas, custom tags) with no SDK - **Squirrel Windows**: Native support for Windows applications using Squirrel update mechanism - **Squirrel macOS**: Optimized updates for macOS applications using Squirrel - **Electron Builder**: Full compatibility with Electron applications using electron-builder - **Tauri**: Full compatibility with Tauri applications See [Updaters Support](./updaters/index.mdx) for details on each mechanism. ## Tutorials Step-by-step guides from the blog: - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) — auto-updates for Electron apps with electron-builder. - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) — Velopack feeds, delta updates, rollback, and CLI uploads. - [Self-Hosted Sparkle Update Server: Appcast Hosting for macOS](https://faynosync.com/blog/self-hosted-sparkle-update-server) — Sparkle appcast hosting, EdDSA signatures, critical updates, and CLI uploads. - [Local Development with faynoSync — Choose Your Path](https://faynosync.com/blog/local-development) — run faynoSync locally with Docker or from source. - [Private Apps in faynoSync — Keep Your Software Secure](https://faynosync.com/blog/private-apps) — restrict downloads of your builds. - [Team-Based Authorization in faynoSync — Manage Your Team Like a Pro](https://faynosync.com/blog/team-based-auth) — give team members scoped access. - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) — see which versions your users actually run. - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) — speed up the API with Redis caching. - [Fetch Latest Version of App — Smart Update Links](https://faynosync.com/blog/fetch-latest-version) — permanent download links to the latest build. ## Getting Started This documentation is designed to help you get started with FaynoSync smoothly and enjoyably. Whether you're setting up your first application or configuring complex multi-platform deployments, our comprehensive guides will walk you through every step. Don't hesitate to request new features or start discussions on our GitHub. We are always looking to improve and make FaynoSync even better for our users. FaynoSync is designed to grow with your needs, from simple single-application deployments to complex enterprise-scale update management systems. --- ## Updaters Support FaynoSync is designed as a universal platform for application management and updates. The updaters feature allows FaynoSync to work with different update mechanisms and adapt its response or workflow to ensure smooth and proper client application updates. ## Overview Different applications use various update mechanisms, each with their own protocols and expected server responses. FaynoSync's updaters system lets you configure how the platform responds to update requests based on the specific updater type in use. Each updater has its own reference page: - [Manual](./manual.mdx) — the default FaynoSync flow with custom client update logic. - [Velopack](./velopack.mdx) — cross-platform installer/updater; faynoSync speaks its **native** feed protocol, no per-language SDK. - [Sparkle (macOS)](./sparkle.mdx) — `sparkle`; faynoSync hosts a materialized appcast, preserving every element verbatim, no SDK. - [Squirrel (macOS)](./squirrel-darwin.mdx) — `squirrel_darwin`. - [Squirrel (Windows)](./squirrel-windows.mdx) — `squirrel_windows`, generates `RELEASES`. - [Electron Builder](./electron-builder.mdx) — `electron-builder`, generates `*.yml`. - [Tauri](./tauri.mdx) — requires a cryptographic `signature`. ## Supported Updaters | Updater Type | Description | Use Case | Generates Files | Special Requirements | |--------------|-------------|----------|-----------------|---------------------| | `manual` | Standard FaynoSync update flow | Default updater, works with custom update logic | No | None | | `velopack` | Velopack cross-platform installer/updater | Desktop apps (C#, Rust, JS, Python, Go, …) using Velopack | Yes (`releases.{channel}.json` feed) | Requires channel, platform, and arch | | `sparkle` | Sparkle macOS update framework | Native macOS `.app` bundles using Sparkle | Yes (materialized `appcast.{channel}.xml`) | macOS only; upload the `generate_appcast` appcast + archives | | `squirrel_darwin` | Squirrel macOS update mechanism | Applications using Squirrel for macOS updates | No | None | | `squirrel_windows` | Squirrel Windows update mechanism | Applications using Squirrel for Windows updates | Yes (`RELEASES`) | None | | `electron-builder` | Electron Builder update mechanism | Electron applications using electron-builder | Yes (`*.yml`) | None | | `tauri` | Tauri update mechanism | Tauri applications with built-in updater | No | Requires `signature` field | :::info More updaters will be added as FaynoSync continues to evolve as a universal update platform. ::: :::tip Working examples See the [Example Apps](../examples/index.mdx) for runnable desktop integrations: [Electron](../examples/electron.mdx) (`electron-builder`), [Squirrel](../examples/squirrel.mdx) (`squirrel_darwin` / `squirrel_windows`), [Tauri](../examples/tauri.mdx), [Velopack](../examples/velopack.mdx) (`velopack`, no SDK), and [Sparkle](../examples/sparkle.mdx) (`sparkle`, no SDK). ::: ## Platform Configuration Updaters are configured at the platform level. Each platform can have multiple updaters configured, with one designated as the default. ### Updater Configuration Structure ```json { "type": "updater_type", "default": boolean } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | ✅ | The updater type (`manual`, `velopack`, `sparkle`, `squirrel_darwin`, `squirrel_windows`, `electron-builder`, `tauri`) | | `default` | boolean | ❌ | Whether this updater is the default for the platform (only one can be default) | ### Example Platform Configuration ```json { "id": "689f076f9ed9e38071926986", "platform": "darwin", "updaters": [ { "type": "manual", "default": true }, { "type": "velopack" }, { "type": "sparkle" }, { "type": "squirrel_darwin", "default": false }, { "type": "electron-builder" }, { "type": "tauri" } ] } ``` :::warning Only one updater can be set as default per platform. If no updaters are specified during platform creation, `manual` will be automatically set as default. ::: ## Upload with the `updater` Parameter Updaters that generate or ingest special files (`RELEASES`, `*.yml`, `releases.{channel}.json`, `appcast.{channel}.xml`) accept an `updater` parameter in the upload request. This places all uploaded files in an isolated folder specific to that updater type, keeping formats from colliding. See each updater's page for its exact upload payload. The general shape: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.exe"' \ --form 'file=@"/path_to_file/RELEASES"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"windows\",\"arch\":\"amd64\",\"updater\":\"squirrel_windows\"}"' ``` :::tip The [faynoSync CLI](../cli.mdx) exposes this as an `--updater` flag (and `--signature` for [Tauri](./tauri.mdx)), so you don't have to hand-craft the `data` JSON in CI. ::: ## Using Updaters in API Calls For JSON-style updaters (`manual`, `electron-builder`, `tauri`), request a specific format by adding the `updater` parameter to `/checkVersion`: ```bash # Standard request (uses default updater) curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=windows&arch=amd64&owner=admin' # Electron Builder curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=windows&arch=amd64&owner=admin&updater=electron-builder' # Tauri curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=amd64&owner=admin&updater=tauri' ``` :::note [`velopack`](./velopack.mdx), [`sparkle`](./sparkle.mdx), and [`squirrel_windows`](./squirrel-windows.mdx) do **not** use `/checkVersion`. They expose feed files their clients fetch natively — see their pages for the exact URLs. ::: ### Frontend Dashboard The FaynoSync frontend dashboard provides a user-friendly interface for configuring updaters: - **Platform Management** — configure updaters when creating or updating platforms. - **Visual Configuration** — set the default updater without editing JSON. - **Real-time Testing** — test different updater configurations. :::tip While the API provides full control, the frontend dashboard is recommended for easier management of updater configurations. ::: ## Best Practices 1. **Choose the right updater** — match your application's actual update mechanism. 2. **Set a default updater** — always designate one updater as default per platform. 3. **Use the `updater` parameter on upload** — required for updaters that generate/ingest special files so artifacts land in the correct isolated folder. 4. **Test configurations** — verify your updater works with your client before shipping. 5. **Monitor updates** — use the dashboard to track update success rates per updater. --- ## Electron Builder Updater # Electron Builder Returns the YAML content required for electron-builder updates and generates `*.yml` feed metadata. ## Response Format - YAML configuration files (`latest.yml`, `latest-mac.yml`, `latest-linux.yml`). - Compatible with the electron-builder / `electron-updater` update mechanism. ## Generated Files | Generated File | Purpose | |----------------|---------| | `*.yml` | Contains package information and update metadata for electron-builder | ## Checking for Updates ```bash curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=windows&arch=amd64&owner=admin&updater=electron-builder' ``` ## Upload with the `updater` Parameter Include the `updater` parameter so the binary and `*.yml` land in an isolated folder: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.exe"' \ --form 'file=@"/path_to_file/latest.yml"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"windows\",\"arch\":\"amd64\",\"updater\":\"electron-builder\"}"' ``` For macOS, upload the whole build output of one architecture in one request — `latest-mac.yml`, the `.zip`, the `.dmg` and both `.blockmap` files: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/latest-mac.yml"' \ --form 'file=@"/path_to_file/myapp-0.0.1-arm64-mac.zip"' \ --form 'file=@"/path_to_file/myapp-0.0.1-arm64-mac.zip.blockmap"' \ --form 'file=@"/path_to_file/myapp-0.0.1-arm64.dmg"' \ --form 'file=@"/path_to_file/myapp-0.0.1-arm64.dmg.blockmap"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"arm64\",\"updater\":\"electron-builder\"}"' ``` `electron-updater` updates macOS apps from the `.zip` and uses `.zip.blockmap` for differential downloads; the `.dmg` is for first installs from your download page (see [`/apps/latest`](../api/info/fetch-latest-build)). The `.dmg.blockmap` is not used, but uploading it is harmless — several `.blockmap` files per platform and architecture are accepted. Files are then organized as: ``` /updater/myapp/stable/windows/amd64/0.0.1/ ├── myapp.exe └── latest.yml ``` ## Related reading - [Updaters Support](./index.mdx) — overview of all updater types. - [Electron example app](../examples/electron) — `electron-updater` generic provider wired to FaynoSync. - [Automating Electron Auto-Updates](https://faynosync.com/blog/electron-auto-update) — end-to-end walkthrough. --- ## Manual Updater # Manual Updater (Default) The standard FaynoSync update flow, built for applications that implement their own update logic. When no updater is specified during platform creation, `manual` is set as the default. ## Response Format - Standard JSON response with `update_url_(extension)` fields (one per artifact type). - Compatible with any custom update mechanism — your client decides how to download and apply. ## Checking for Updates ```bash curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=windows&arch=amd64&owner=admin' ``` A typical response: ```json { "update_available": true, "critical": false, "changelog": "### Changelog\n\n- Added feature X\n", "update_url_deb": "https://.s3.amazonaws.com/myapp/stable/linux/amd64/myapp-1.0.1.deb", "update_url_rpm": "https://.s3.amazonaws.com/myapp/stable/linux/amd64/myapp-1.0.1.rpm" } ``` The manual updater pairs well with the [official SDKs](../sdk/intro), which wrap `/checkVersion`, add edge-first delivery, and expose a small update API. ## Related reading - [Updaters Support](./index.mdx) — overview of all updater types. - [SDK overview](../sdk/intro) — client libraries built around the manual flow. - [Required Intermediate Build](../intermediate_build) — enforce update paths. --- ## Sparkle Updater (macOS) [Sparkle](https://sparkle-project.org) is the de-facto auto-update framework for native macOS apps (Objective-C / Swift, linked into an `.app`). It is macOS-only — Windows uses the separate WinSparkle port, and Electron on macOS uses Squirrel.Mac, not Sparkle. FaynoSync serves Sparkle updates through the **stock Sparkle client**: you upload the `generate_appcast` output and FaynoSync hosts the feed. No per-language SDK, no client glue beyond one delegate method. ## A hosted appcast, not a translation layer Like [Velopack](./velopack.mdx), Sparkle is a native updater: FaynoSync reproduces the exact appcast the stock Sparkle client already knows how to read, rather than adapting a `/checkVersion` response. The difference from Velopack is that Sparkle's feed is **RSS/XML that carries publisher-authored, security-critical fields** (the Ed25519 signature above all), so FaynoSync treats the uploaded appcast as the source of truth and edits it as little as possible. FaynoSync stores each `` **verbatim** (via `github.com/beevik/etree`, which round-trips unknown elements and namespaces that `encoding/xml` would drop) and, when it materializes the feed, overlays only the four fields it owns. Everything else — `sparkle:edSignature`, `sparkle:version`, `minimumSystemVersion`, the whole `` subtree, `hardwareRequirements`, `minimumAutoupdateVersion`, `releaseNotesLink`, `maximumSystemVersion`, unknown namespaces — passes through untouched. ## Requirements - **macOS only.** Configure the `sparkle` updater on a `darwin` platform, with an explicit channel, platform, and arch — FaynoSync materializes one appcast per `(owner, app, platform, arch, channel)` tuple. - **Signing stays off the server.** The `sparkle:edSignature` is an Ed25519 signature over the archive **bytes**, produced at build time by Sparkle's `sign_update` / `generate_appcast` and verified client-side against `SUPublicEDKey` in `Info.plist`. The private key never touches FaynoSync — it reads the signature out of the uploaded appcast and re-emits it verbatim. - **Upload the whole appcast.** Unlike [Tauri](./tauri.mdx), Sparkle has **no `signature` upload field**. The signature already lives inside the appcast you upload. Configure the `sparkle` updater on the platform (see [Platform Configuration](./index.mdx#platform-configuration)). ## Packaging and upload Build and sign your `.app`, then run Sparkle's `generate_appcast` over the archive directory to produce `appcast.xml` with the `edSignature`, `length`, and `sparkle:version` for each archive. Upload that appcast (renamed `appcast.{channel}.xml`) together with the archives it references and any delta files: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/appcast.nightly.xml"' \ --form 'file=@"/path_to_file/MyApp-1.0.3.zip"' \ --form 'file=@"/path_to_file/MyApp-1.0.3-1.0.2.delta"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"1.0.3\",\"channel\":\"nightly\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"arm64\",\"updater\":\"sparkle\",\"changelog\":\"### Changelog\\n\\n- Added feature X\\n- Fixed bug Y\"}"' ``` Upload rules enforced by the `sparkle` validator: - The appcast (`appcast.{channel}.xml`) plus every archive it references must be present. Accepted archive types are `.zip`, `.dmg`, `.tar.*`, and `.aar`; `.delta` files are optional. - **Every supplied archive/delta must have a matching `` in the appcast.** An archive with no appcast entry is rejected rather than stored silently — this catches a stale appcast that predates the uploaded version. The `sparkle` updater accepts the same fields on the [edit flow](../api/apps/update-specific-version-app.mdx) (`POST /apps/update`), so adding a platform/arch to an existing version carries its sparkle metadata into the materialized feed. The [faynoSync CLI](../cli.mdx) exposes the same upload as `--updater sparkle` with repeated `--file` flags (no `--signature` — the `edSignature` is already in the appcast): ```bash faynosync upload \ --app myapp --version 1.0.3 --channel nightly \ --platform darwin --arch arm64 --updater sparkle \ --file ./appcast.nightly.xml \ --file ./MyApp-1.0.3.zip \ --file ./MyApp-1.0.3-1.0.2.delta \ --publish ``` ## What FaynoSync manages vs. passes through When FaynoSync materializes the feed it overlays exactly these managed fields onto each stored raw ``: | Field | Source | Behavior | |-------|--------|----------| | **publish** | FaynoSync flag | Include the `` when published; omit it entirely when not. | | **critical** | FaynoSync flag | `sparkle:criticalUpdate`. An existing tag (including a `sparkle:version` threshold) is kept as-is; a bare tag is added only if absent; the tag is dropped when critical is off. | | **changelog** | FaynoSync version changelog | `` (CDATA) — inserted/replaced **only when non-empty**. An empty changelog leaves the uploaded item's own `` / `releaseNotesLink` untouched. | | **pubDate** | Version `Updated_at` | Stamped in RFC1123Z, replacing the build tool's date — the release's availability date is FaynoSync-owned. | | enclosure `url` | rewritten | Every enclosure URL (full **and** nested deltas) is rewritten to its FaynoSync link by basename. `sparkle:edSignature` and `length` are left untouched, so signatures stay valid. | | `sparkle:channel` | dropped | The feed is already per-channel, so this tag is removed. | Everything not in this table is **pass-through** — taken verbatim from the uploaded appcast. Notably `sparkle:version` (CFBundleVersion) must pass through unchanged; substituting FaynoSync's own version would break Sparkle's client-side version comparison. ## The materialized feed Sparkle in FaynoSync is **static / materialized**, equivalent to Velopack's [Mode 1 (CDN)](./velopack.mdx#mode-1--cdn-on-by-default). There is no flag to turn it on: whenever a sparkle version changes state (upload, publish/unpublish, critical/changelog edit, version or artifact deletion), FaynoSync regenerates the appcast as a static object in `S3_BUCKET_NAME`: ``` sparkle/{owner}/{app}/{platform}/{arch}/appcast.{channel}.xml ``` Archives and deltas are stored next to it under `sparkle/{owner}/{app}/{platform}/{arch}/` with their original filenames. The uploaded appcast lands on this materialized key, so the managed feed overwrites the raw upload (with a skip-if-unchanged check via ETag). Point the app's `SUFeedURL` at that base and the stock Sparkle client fetches the appcast directly: ``` SUFeedURL → https://cdn.example.com/sparkle/admin/myapp/darwin/arm64/appcast.nightly.xml ``` Put a CDN in front of the public bucket and update checks never touch the API — Sparkle does version comparison client-side and picks the newest ``. :::note Not the same as the `cdn` edge cache This appcast materialization is inherent to how Sparkle works and is always on for sparkle apps. It is a different mechanism from the per-app [`cdn` edge/S3 response cache](../edge) used by SDK clients — that one caches FaynoSync's own JSON responses in `S3_BUCKET_NAME_CDN`. Sparkle feeds live in `S3_BUCKET_NAME`. ::: ## Feature mapping - **publish / critical / changelog** — fully supported through the overlay above. - **Rollout (native).** Sparkle's own `sparkle:phasedRolloutInterval` (time-based, client-side) passes through verbatim if `generate_appcast` emitted it. FaynoSync's [deviceId / percent rollout](../rollout) does **not** apply — a single materialized file is served to every client, so it can't diverge per device. See [Rollout — updater compatibility](../rollout#updater-compatibility). - **Intermediate builds — not yet.** [Required intermediate builds](../intermediate_build) need a per-client decision on the served item, which a static object can't make. This is deferred to a future dynamic sparkle route (mirroring the [Velopack API mode](./velopack.mdx#mode-2--api-dynamic)). ## Caveats - **Do not enable whole-feed signing (`SURequireSignedFeed`).** A server-rewritten feed can't carry an EdDSA signature over the entire appcast, because FaynoSync rewrites enclosure URLs and overlays elements. The security boundary is the **per-archive** `sparkle:edSignature`, which stays byte-identical and valid. - **`` vs. external release notes.** When a FaynoSync changelog is set, FaynoSync injects a ``, and Sparkle prefers inline `` over `releaseNotesLink`. With an empty changelog it keeps whatever the uploaded item had, so a publisher-provided `releaseNotesLink` still works. To keep an external link, leave the FaynoSync changelog empty. - **Re-upload after upgrading.** Feeds are rebuilt from the stored raw ``; any release uploaded before this format existed has no stored raw item and is skipped. Re-upload those versions. ## Related reading - [Self-Hosted Sparkle Update Server, step by step](/blog/self-hosted-sparkle-update-server) — tutorial: build, sign, upload with the CLI, ship a critical update, pull a bad release. - [Sparkle example app](../examples/sparkle.mdx) — a runnable native macOS AppKit app using the stock Sparkle client, no SDK. - [Updaters Support](./index.mdx) — how each updater shapes the FaynoSync response. - [Velopack Updater](./velopack.mdx) — the other native, no-SDK updater; the model for the materialized feed. - [Squirrel (macOS)](./squirrel-darwin.mdx) — the macOS updater used by Electron apps. - [Rollout](../rollout) — why native rollout passes through but FaynoSync rollout doesn't apply. - [Required Intermediate Build](../intermediate_build) — the deferred dynamic-route feature. --- ## Squirrel (macOS) Updater # Squirrel Darwin (macOS) Returns only ZIP archive links for macOS Squirrel updates. ## Key Behavior - The response contains a `url` field instead of `update_url_zip`. - Only ZIP archive links are returned. - Optimized for the Squirrel.Mac update mechanism. ## Checking for Updates ```bash curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=amd64&owner=admin&updater=squirrel_darwin' ``` ## Related reading - [Updaters Support](./index.mdx) — overview of all updater types. - [Squirrel (Windows)](./squirrel-windows.mdx) — the Windows counterpart and its `RELEASES` output. - [Sparkle (macOS)](./sparkle.mdx) — the macOS updater for native (non-Electron) `.app` bundles. - [Squirrel example app](../examples/squirrel) — Electron `autoUpdater` wired to FaynoSync. --- ## Squirrel (Windows) Updater # Squirrel Windows Uses URL-based routing instead of query parameters for Windows Squirrel updates, and generates a `RELEASES` manifest. ## URL Format ``` GET /update/{owner}/{app_name}/{channel}/{platform}/{arch}/{version}/ ``` **Example:** ```bash curl --location 'http://localhost:9000/update/admin/secondapp/stable/windows/amd64/0.0.0.1/' ``` :::note Squirrel Windows automatically appends `RELEASES` to the end of the URL and checks for updates. ::: ## Generated Files | Generated File | Purpose | |----------------|---------| | `RELEASES` | Contains package names and metadata for Squirrel Windows updates | ## Upload with the `updater` Parameter Include the `updater` parameter so the `.exe` and `RELEASES` land in an isolated folder: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.exe"' \ --form 'file=@"/path_to_file/RELEASES"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"windows\",\"arch\":\"amd64\",\"updater\":\"squirrel_windows\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` Files are then organized as: ``` /updater/myapp/stable/windows/amd64/0.0.1/ ├── myapp.exe └── RELEASES ``` ## Related reading - [Updaters Support](./index.mdx) — overview of all updater types. - [Squirrel (macOS)](./squirrel-darwin.mdx) — the macOS counterpart. - [Squirrel example app](../examples/squirrel) — Electron `autoUpdater` wired to FaynoSync. --- ## Tauri Updater # Tauri Supports Tauri applications with built-in updater functionality, including cryptographic signature verification. ## Key Features - Compatible with Tauri's built-in updater. - Supports cryptographic signature verification. - Returns a standard JSON response format with download URLs. - Requires a `signature` field for security validation. ## Signature Requirement The Tauri updater requires cryptographic signatures for update verification. When uploading Tauri applications you **must** include a `signature` field in the upload request. This signature is generated by Tauri's build process and ensures update integrity and authenticity. ## Checking for Updates ```bash curl -X GET 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=amd64&owner=admin&updater=tauri' ``` The response is a standard JSON payload with download URLs plus the signature information the Tauri updater expects. ## Which File the Updater Receives A version can hold more than the update bundle — for example a `.dmg` next to `myapp.app.tar.gz`, so that [`/apps/latest`](../api/info/fetch-latest-build) has an installer for your download page. `/checkVersion` always returns the file the Tauri updater can install, in this order: 1. Update bundles: `.app.tar.gz` (macOS), `.AppImage.tar.gz`, `.nsis.zip`, `.msi.zip` (Tauri v1) 2. Installers the Tauri v2 updater installs directly: `.exe`, `.msi`, `.AppImage` `.sig` and `.dmg` files are never returned as `url` — the Tauri updater cannot install them. If a version has nothing the updater can install for the requested platform and architecture (for example only a `.dmg`), `/checkVersion` answers `204 No Content`, which Tauri treats as "no update". Pass the bundle's signature as the `signature` parameter (`cat myapp.app.tar.gz.sig`), not as a file; it is stored for every file of that upload, so upload the bundle and any installers for your download page in the same request. `/apps/latest` works the other way round: it leaves out the update bundles and `.sig` files, and returns only the installers. ## Upload with the `updater` Parameter ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.app.tar.gz"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"1.0.0\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"amd64\",\"updater\":\"tauri\",\"signature\":\"dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUUzIzUGJLcHpQNHdCVEJzTXJjbWFhUUdUVEtaUGRLdGhWdGZJZkF2VmM0TzJGMkdPQUk4V1hzYWJuUUhWbWI2cTFWTkhEZE9lbkVwUERCQmhRPT0K\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ## Related reading - [Updaters Support](./index.mdx) — overview of all updater types. - [Tauri example app](../examples/tauri) — `@tauri-apps/plugin-updater` wired to FaynoSync. - [Velopack](./velopack.mdx) — another cross-platform desktop updater with delta updates. --- ## Velopack Updater [Velopack](https://velopack.io) is a cross-platform installer and auto-update framework for desktop apps. Its native installer/update engine is written in Rust, with official client libraries for C#, Rust, JavaScript, C++, and Python (Java, Swift, and Go are planned). FaynoSync serves Velopack updates through the **stock Velopack client** — no per-language SDK, no glue code. ## A native updater, not a translation layer Every other updater in FaynoSync adapts an existing FaynoSync response into the shape a client expects. Velopack is the first updater where FaynoSync instead **reproduces the updater's own protocol end to end**: the stock Velopack `HttpSource` talks to FaynoSync exactly as it would to any static file host. What that means in practice: - **Zero client-language code.** Velopack ships official client libraries for 5 languages (C#, Rust, JS, C++, Python) over a shared native engine. Rather than writing (and maintaining) a FaynoSync integration per language, the client uses its built-in `HttpSource` and compares versions itself. No FaynoSync SDK is involved on the client side. - **The feed is a projection, not a stored file.** `releases.{channel}.json` is never hand-edited or persisted as the source of truth. FaynoSync holds versions, artifacts, and the `published` flag in the database and **regenerates** the feed from that state on every relevant change. - **Metadata is ingested verbatim.** Package hashes (`SHA1`, `SHA256`) and sizes are taken exactly as `vpk` wrote them into `releases.{channel}.json` — FaynoSync does not recompute them. These are stored separately from FaynoSync's TUF hashes; the two never mix. ## Requirements Velopack in FaynoSync **requires channel, platform, and architecture** on every release. Velopack collapses platform/arch into its channel concept (one `releases.{channel}.json` per file), while FaynoSync keeps three explicit axes — so each combination gets its own feed. Make sure the app has the matching channel, platform, and arch created before uploading. Configure the `velopack` updater on the platform (see [Platform Configuration](./index.mdx#platform-configuration)). ## Packaging and upload Pack with `vpk`, then upload the packages plus the generated feed. The `--channel` you pass to `vpk` becomes the FaynoSync channel; platform and arch are separate FaynoSync axes. ```bash # 1. Pack (channel baked into the package) vpk pack --packId HelloVelopack --packVersion 1.0.3 \ --packDir dist/HelloVelopack.app --mainExe HelloVelopack \ --channel nightly --outputDir releases # 2. Upload the full package (+ delta, if present) and the releases feed faynosync-cli upload --app HelloVelopack --version 1.0.3 --channel nightly \ --platform darwin --arch arm64 --updater velopack \ --file releases/HelloVelopack-nightly-Setup.pkg \ --file releases/HelloVelopack-1.0.3-nightly-full.nupkg \ --file releases/HelloVelopack-1.0.3-nightly-delta.nupkg \ --file releases/releases.nightly.json \ --publish ``` Upload rules enforced by the `velopack` validator: - At least one `*-full.nupkg` is **required**. - Exactly one `releases.{channel}.json` is **required** — it is the sole source of hashes, sizes, types, and versions. - `Delta` packages are optional. `Installer`/`Portable` artifacts (e.g. `Setup.pkg`) may be uploaded but never enter the feed; they are stored as version-pinned copies under `installers/` for teams that need to install a **specific** version directly. The equivalent raw `/upload` call sets `"updater":"velopack"` in the `data` payload and attaches the same files. ## The feed Clients request `releases.{channel}.json`; FaynoSync builds it from the database in PascalCase, the exact format the stock client parses: ```json { "Assets": [ { "PackageId": "HelloVelopack", "Version": "1.0.3", "Type": "Full", "FileName": "HelloVelopack-1.0.3-nightly-full.nupkg", "SHA1": "…", "SHA256": "…", "Size": 13355053, "NotesMarkdown": "- fix crash on startup", "NotesHTML": "fix crash on startup" }, { "PackageId": "HelloVelopack", "Version": "1.0.3", "Type": "Delta", "FileName": "HelloVelopack-1.0.3-nightly-delta.nupkg", "SHA1": "…", "SHA256": "…", "Size": 172110 } ] } ``` - `NotesMarkdown` comes from the version's changelog; `NotesHTML` is rendered from it (sanitized). - `FileName` is the **bare package name** — the client resolves each download relative to the feed URL and stores it under the same name. - **Unpublishing removes the whole version.** Set `publish = false` and both the `Full` and `Delta` for that version leave the feed together — the version disappears entirely, it is never served half-published. - **Delta chaining safety.** A `Delta` is kept only while the version directly below it (its implicit base) is still published with a `Full`. Unpublish an intermediate base and the *higher* versions' deltas can no longer chain, so those deltas drop out while their `Full` packages remain as a fallback. ## Two delivery modes Both modes use the stock client with zero language-specific code. The only difference is where the app's `UPDATE_URL` points. | | Mode 1 — CDN / static (default) | Mode 2 — API / dynamic | |---|---|---| | `UPDATE_URL` base | Object storage / CDN | FaynoSync API route | | Who serves the feed | Static object in storage | FaynoSync, per request | | API load per check | ~none | every check | | publish / unpublish, changelog, delta | ✅ | ✅ | | Per-client intermediate stepping | ❌ | ✅ | | Scale | Very high (CDN) | Bounded by the API | ### Mode 1 — CDN, on by default There is **no flag to turn this on**. Whenever a velopack version changes state (upload, publish/unpublish, changelog edit, delete), FaynoSync automatically **materializes** the feed as a static object in `S3_BUCKET_NAME`, next to the packages: ``` velopack/{owner}/{app}/{platform}/{arch}/releases.{channel}.json ``` Point the app's `UPDATE_URL` at that base and let the stock client append the feed file: ```python # Stock Velopack HttpSource — no SDK UPDATE_URL = "https://cdn.example.com/velopack/admin/HelloVelopack/darwin/arm64" # client → GET .../releases.nightly.json (served straight from CDN/storage) ``` Put a CDN in front of that public bucket and update checks never touch the API. :::note Not the same as the `cdn` edge cache This static-feed materialization is inherent to how Velopack works and is **always on** for velopack apps. It is a different mechanism from the per-app [`cdn` edge/S3 response cache](../edge) used by SDK clients calling `/checkVersion` — that one caches FaynoSync's own JSON responses in `S3_BUCKET_NAME_CDN`. Velopack feeds live in `S3_BUCKET_NAME` and are not governed by the `cdn` flag. ::: ### Mode 2 — API, dynamic Point `UPDATE_URL` at the FaynoSync API route instead: ``` GET /velopack/{owner}/{app}/{platform}/{arch}/releases.{channel}.json?localVersion=..&id=.. ``` FaynoSync builds the feed per request, so it can apply per-client logic the static object can't (object storage ignores query parameters). Package (`*.nupkg`) requests to this route are `302`-redirected to the real storage link. See the [Velopack Feed endpoint](../api/info/velopack-feed.mdx) for the full request/response reference. ```python UPDATE_URL = "https://api.example.com/velopack/admin/HelloVelopack/darwin/arm64" # client → GET .../releases.nightly.json?localVersion=1.0.0 ``` ## Intermediate builds [Required intermediate builds](../intermediate_build) work with Velopack **only in Mode 2**. When the client sends its `localVersion` and a mandatory intermediate exists between it and the latest, FaynoSync caps the feed so the highest `Full` is the intermediate version — the client steps through it, then continues to latest on the next check. This is currently the way to preserve the intermediate feature for Velopack, since a static CDN object can't make per-client decisions. It is likely a **temporary** split; the goal is to cover intermediate stepping through the CDN path as well. ## TUF verification Velopack packages can be bound to FaynoSync's [TUF root of trust](../api/tuf/tuf-introduction). With TUF enabled for the app, a client that verifies targets checks each `.nupkg`'s SHA256/size against a TUF-signed target before applying it. Once TUF is on, **an unsigned or mismatched target is rejected** — the update does not apply. This layers FaynoSync's signing guarantees on top of Velopack's own package validation. ## Downgrade and revocation Set **publish = false** on a version and the next feed regeneration removes it. Clients then resolve the highest still-published version as the target — an effective rollback/revocation without touching files. ## Limitations - **`critical` is not understood by the stock client** — Velopack always installs the latest available version. Urgency is an application-level UX concern; the server can surface a marker, but enforcement lives in your app code. - Feeds are per `(channel, platform, arch)` tuple; deleting the last velopack version of a tuple leaves its old feed object in place (objects are not deleted, only regenerated). ## Related reading - [Velopack example app](../examples/velopack.mdx) — a runnable Python client using the stock Velopack updater, no SDK. - [Self-Hosted Velopack Update Server, step by step](/blog/self-hosted-velopack-update-server) — tutorial: build, upload with the CLI, ship a delta update, roll back. - [Sparkle Updater](./sparkle.mdx) — the other native, no-SDK updater; macOS-only, hosted appcast. - [Updaters Support](./index.mdx) — how each updater shapes the FaynoSync response. - [Required Intermediate Build](../intermediate_build) — enforcing update paths (Mode 2). - [Edge and S3 Response Cache](../edge) — the separate `cdn` response cache for `/checkVersion`. - [TUF](../api/tuf/tuf-introduction) — signing and verification. - [Telemetry System](../telemetry) — tracking update adoption. --- ## Update Example Apps (Electron, Squirrel, Tauri, Velopack, Sparkle) # Example Apps These are runnable reference applications that wire real desktop update flows to a faynoSync instance. Each one targets a different updater. The Electron, Squirrel, and Tauri apps show how the [JavaScript SDK](../sdk/js/sdk.mdx) fits in — from a simple edge-first update check to full native-feed resolution. The [Velopack](./velopack.mdx) and [Sparkle](./sparkle.mdx) apps are the exceptions: faynoSync serves each one its native feed, so their stock clients need **no SDK**. Every example is a complete, buildable app on GitHub. The snippets in these docs are **trimmed to the update logic only** (SDK calls, feed wiring, edge handling); the repositories contain the full UI, packaging, and CI. ## Which example maps to what | Example | Framework / updater library | faynoSync [updater type](../updaters/index.mdx) | How the SDK is used | |---------|-----------------------------|---------------------------------------|---------------------| | [Electron](./electron.mdx) | Electron + `electron-updater` (generic provider) | `electron-builder` (`*.yml`) | SDK `checkForUpdates` as an edge-first gate, then the feed directory is derived from `packageUrls` and handed to `electron-updater`. | | [Squirrel](./squirrel.mdx) | Electron native `autoUpdater` (Squirrel.Mac / Squirrel.Windows) | `squirrel_darwin` / `squirrel_windows` | SDK `resolveNativeFeed` resolves the feed edge-first (API fallback), then `setFeedURL`. | | [Tauri](./tauri.mdx) | Tauri + `@tauri-apps/plugin-updater` | `tauri` | SDK `checkForUpdates` as an edge-first gate; the native plugin does the signature-verified install. | | [Velopack](./velopack.mdx) | Velopack (Python, stock client) | `velopack` | **No SDK** — the stock Velopack `UpdateManager` reads faynoSync's native feed directly. | | [Sparkle](./sparkle.mdx) | Sparkle (native macOS AppKit, stock client) | `sparkle` | **No SDK** — the stock Sparkle `SPUStandardUpdaterController` reads faynoSync's materialized appcast directly. | ## Why the SDK sits in front of the native updater This applies to the Electron, Squirrel, and Tauri examples. (Velopack and Sparkle are different — see the [Velopack](./velopack.mdx) and [Sparkle](./sparkle.mdx) pages — because faynoSync serves each a native feed straight from the CDN, so there is no SDK in front.) Native updaters (electron-updater, Squirrel, tauri-plugin-updater) are good at downloading and installing, but they poll the API on every check and don't know about faynoSync's edge (CDN) responses. In those three examples the SDK runs **first**: - On the common **no-update** path, the SDK's edge-first `checkForUpdates` (or `resolveNativeFeed`) answers from the CDN and sends the optimized `/telemetry/beacon`, so the API and the native updater are never invoked. - Only when an update actually exists does the native updater start, using a feed URL the SDK resolved — pointing at the edge when it's warm, falling back to the API (which warms the edge) on a miss. Some of the per-example code looks unusual out of context (deriving a feed directory from a package URL, treating `200 { "status": "no_content" }` as "no update", stripping `/RELEASES`). Those are edge-delivery details the SDK normalizes — each page explains them where they appear. ## Prerequisites The three SDK apps read the same configuration (owner, app name, channel, `BASE_URL`, `EDGE_URL`, and an optional report key) from a `.env` file; see each repo's `.env.example`. The [Velopack](./velopack.mdx) app is a Python project configured through environment variables instead (feed base, API base, `TUF_ENABLED`, `ASK_API`, and so on), and the [Sparkle](./sparkle.mdx) app is a native macOS project configured through `Info.plist` keys (`SUFeedURL`/`SparkleFeedBaseURL`, `SUPublicEDKey`, `FaynoSync*`) — see each repo. All of them expect an app configured on your faynoSync instance with the matching channel/platform/arch and the relevant updater enabled on the platform. ## Related reading - [JavaScript SDK](../sdk/js/sdk.mdx) — `checkForUpdates`, `resolveNativeFeed`, `reportEvent`, and edge fallback behavior. - [Updaters Support](../updaters/index.mdx) — how each updater type shapes the server response. - [Edge delivery](../edge.mdx) — how faynoSync publishes cached JSON/feeds for CDN delivery. --- ## Electron Auto-Update Example # Electron Example (electron-updater) An Electron desktop app that checks for updates with the faynoSync [JavaScript SDK](../sdk/js/sdk) and downloads/installs them through [`electron-updater`](https://www.electron.build/auto-update) using its **generic provider**. This maps to the [`electron-builder`](../updaters) updater type, which produces `*.yml` feed metadata. **Repository:** [github.com/ku9nov/faynoSync-electron-example](https://github.com/ku9nov/faynoSync-electron-example) ## Integration pattern 1. The SDK's `checkForUpdates` runs first as an **edge-first gate** — it decides whether an update exists (and sends telemetry) before anything native starts. 2. If an update exists, the app derives the `electron-updater` **feed directory** from the SDK response and hands it to `autoUpdater.setFeedURL`. 3. `electron-updater` reads `latest*.yml` from that directory and downloads/installs. ## Creating the client ```js import { Client, systemPlatform, systemArch } from '@faynosync/sdk-js'; let client; function getClient() { if (!client) { client = new Client({ baseURL, edgeURL: edgeURL || undefined }); } return client; } ``` `edgeURL` is optional — when set, `checkForUpdates` tries the CDN first and falls back to the API automatically. ## The edge-first update check ```js const resp = await getClient().checkForUpdates({ owner, appName: app_name, version, channel, platform: systemPlatform(), arch: systemArch(), deviceId, }); if (resp.updateAvailable) { // start the background download (see below) } else { // already up to date } ``` `systemPlatform()` / `systemArch()` return the Node.js runtime values (`process.platform` / `process.arch`); pass whatever your faynoSync platform/arch config expects. ## Deriving the electron-updater feed directory `electron-updater`'s generic provider needs the **directory** that contains the `latest*.yml` metadata, not a single file. faynoSync returns that metadata file as a `yml` entry in `packageUrls`, so the app strips the filename to get the directory: ```js function feedDirFromPackageUrls(packageUrls) { const yml = (packageUrls || []).find((p) => p.package === 'yml'); if (!yml || !yml.url) return null; return yml.url.substring(0, yml.url.lastIndexOf('/')); } ``` Then it configures `electron-updater` and starts the download: ```js import { autoUpdater } from 'electron-updater'; const feedUrl = feedDirFromPackageUrls(resp.packageUrls); if (feedUrl) { autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl }); autoUpdater.checkForUpdates(); } ``` :::note Why derive the directory instead of using `updateUrl` The SDK's `checkForUpdates` returns direct artifact URLs (`updateUrl`, `packageUrls`) for the *manual* flow. `electron-updater` doesn't consume those directly — it wants the folder holding `latest.yml` / `latest-mac.yml`. The `yml` package URL points at that metadata file, so its parent directory is the feed URL. This keeps a single faynoSync response usable by both a manual "pick a package" UI and the automatic `electron-updater` flow. ::: :::info Keep the faynoSync display fields The `electron-updater` `*.yml` feed only describes the artifact — it does **not** carry faynoSync's `changelog`, `critical`, or `isIntermediateRequired` flags. That's why the app pulls only the `yml` **URL** out of `packageUrls` to locate the feed directory, but keeps reading those display fields from the SDK's `checkForUpdates` response. If you pointed `electron-updater` at the feed directly and dropped the SDK call, you'd lose the changelog, the critical-update styling, and the intermediate-update signal in the UI. ```js const meta = { changelog: resp.changelog, critical: resp.critical, isIntermediateRequired: resp.isIntermediateRequired, source: resp.source, }; ``` ::: `electron-updater` only runs in a packaged app; in dev mode the example falls back to a manual download list built from `packageUrls`. See [`src/main/updater.js`](https://github.com/ku9nov/faynoSync-electron-example/blob/main/src/main/updater.js) and [`src/main/autoUpdate.js`](https://github.com/ku9nov/faynoSync-electron-example/blob/main/src/main/autoUpdate.js) for the full flow. ## Related reading - [JavaScript SDK](../sdk/js/sdk) — `checkForUpdates`, `packageUrls`, and edge fallback. - [Squirrel Example](./squirrel) — native Electron `autoUpdater` with `resolveNativeFeed` instead of the generic provider. - [Updaters Support](../updaters) — the `electron-builder` updater and its `*.yml` output. - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) — step-by-step tutorial for this setup. --- ## Sparkle Auto-Update Example (macOS, no SDK) # Sparkle Example (macOS, no SDK) A minimal native **macOS AppKit** app that auto-updates through the **stock [Sparkle](../updaters/sparkle.mdx) client** — no faynoSync SDK, no per-language glue. Sparkle fetches a faynoSync-materialized appcast, compares versions client-side, and installs the newest signed archive. **Repository:** [github.com/ku9nov/faynoSync-sparkle-example](https://github.com/ku9nov/faynoSync-sparkle-example) Like the [Velopack](./velopack.mdx) example — and unlike the [Electron](./electron.mdx), [Squirrel](./squirrel.mdx), and [Tauri](./tauri.mdx) examples that run the JavaScript SDK as an edge-first gate — this app uses **no SDK at all**. faynoSync hosts Sparkle's native appcast, so `SPUStandardUpdaterController` talks to it as it would to any static host. That is the whole point of the [`sparkle` updater](../updaters/sparkle.mdx). ## Integration pattern 1. `SPUStandardUpdaterController` is started with a delegate; Sparkle checks on its schedule (`SUScheduledCheckInterval`) or on demand. 2. The `feedURLString` delegate composes the appcast URL from config, so one build can target any `owner/app/platform/arch/channel`. 3. Sparkle downloads the appcast, verifies each archive's `edSignature` against `SUPublicEDKey`, and installs. ## The stock client — zero SDK The updater is the unmodified Sparkle `SPUStandardUpdaterController`. faynoSync is just the URL the delegate points it at: ```swift import Sparkle final class UpdaterManager: NSObject, SPUUpdaterDelegate { private(set) var controller: SPUStandardUpdaterController! override init() { super.init() controller = SPUStandardUpdaterController( startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) } // Compose SUFeedURL from config instead of hardcoding one channel/arch in Info.plist. func feedURLString(for updater: SPUUpdater) -> String? { Config.appcastURL // {feedBase}/sparkle/{owner}/{app}/{platform}/{arch}/appcast.{channel}.xml } } ``` `Config.appcastURL` builds the exact key faynoSync [materializes](../updaters/sparkle.mdx#the-materialized-feed), reading `owner`, `app`, `channel`, and the feed base from `Info.plist` and detecting `arch` at compile time. See [`Sources/UpdaterManager.swift`](https://github.com/ku9nov/faynoSync-sparkle-example/blob/main/Sources/UpdaterManager.swift) and [`Sources/Config.swift`](https://github.com/ku9nov/faynoSync-sparkle-example/blob/main/Sources/Config.swift). ## Injecting deviceId and localVersion Sparkle can't set HTTP headers on the appcast fetch, so the app appends faynoSync's canonical parameters (`deviceId`, `localVersion`, and the rest) to `SUFeedURL` as **query parameters** via the `feedParameters` delegate: ```swift func feedParameters(for updater: SPUUpdater, sendingSystemProfile: Bool) -> [[String: String]] { func p(_ k: String, _ v: String) -> [String: String] { ["key": k, "value": v, "displayKey": k, "displayValue": v] } return [ p("deviceId", DeviceID.current), p("localVersion", Config.version), p("id", Config.appName), p("channel", Config.channel), p("platform", Config.platform), p("arch", Config.arch), ] } ``` Against the **static materialized appcast these parameters are ignored** (object storage doesn't read query strings). They are wired up ahead of the deferred [dynamic sparkle route](../updaters/sparkle.mdx#feature-mapping), which will use them for per-client intermediate/rollout selection. `DeviceID.current` is a UUID persisted under Application Support (see [`Sources/DeviceID.swift`](https://github.com/ku9nov/faynoSync-sparkle-example/blob/main/Sources/DeviceID.swift)). ## Build, sign, publish The repo drives everything through a `Makefile` over Sparkle's own CLI tools (`generate_keys`, `generate_appcast`, `sign_update`) — the private Ed25519 key is generated into the macOS login Keychain and never leaves the machine: ```bash make keys # generate Ed25519 keys; paste SUPublicEDKey into Resources/Info.plist make build # build + sign + zip the .app into build/dist make appcast # sign archives and (re)generate appcast.{channel}.xml ``` Then upload the generated appcast plus its archives to faynoSync with `updater: sparkle` — via the [CLI](../cli.mdx) (`--file` repeated per artifact, no `--signature`, since the `edSignature` is already in the appcast): ```bash faynosync upload \ --app faynosyncSparkleExample --version 1.0.3 --channel nightly \ --platform darwin --arch arm64 --updater sparkle \ --file build/dist/darwin/arm64/nightly/appcast.nightly.xml \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.3.zip \ --publish ``` faynoSync [materializes](../updaters/sparkle.mdx#the-materialized-feed) the appcast at `sparkle/{owner}/{app}/{platform}/{arch}/appcast.{channel}.xml`, overlaying `publish`, `critical`, `changelog`, and `pubDate` while preserving every `edSignature` byte-for-byte. The app's `SparkleFeedBaseURL` in `Info.plist` points at that public bucket, so the next check picks up the new version. ## Failure reports (optional) There is no SDK, so the app talks to faynoSync's [reports](../api/reports/ingest) endpoint with plain HTTP. Set `FaynoSyncReportKey` in `Info.plist` and the app POSTs demo failure events (random type and reason, every 60 seconds) to `/reports/ingest` — fire-and-forget, never blocking the update. Replace them with your real failure events in your own app (see [`Sources/Reporter.swift`](https://github.com/ku9nov/faynoSync-sparkle-example/blob/main/Sources/Reporter.swift)). ## Related reading - [Self-Hosted Sparkle Update Server, step by step](/blog/self-hosted-sparkle-update-server) — tutorial walking through this example with the faynoSync CLI. - [Sparkle Updater](../updaters/sparkle.mdx) — the appcast contract, managed vs. pass-through fields, and the materialized feed. - [Velopack Example](./velopack.mdx) — the other no-SDK native updater example. - [Rollout](../rollout.mdx) — why native rollout passes through but faynoSync rollout doesn't apply to a materialized feed. - [Reports ingest API](../api/reports/ingest) — the endpoint used without an SDK. --- ## Squirrel.Mac & Squirrel.Windows Auto-Update Example # Squirrel Example (native autoUpdater) An Electron app that updates through the **native Electron `autoUpdater`** — Squirrel.Mac on macOS and Squirrel.Windows on Windows — with feed URLs resolved by the faynoSync [JavaScript SDK](../sdk/js/sdk). This maps to the [`squirrel_darwin` and `squirrel_windows`](../updaters) updater types. **Repository:** [github.com/ku9nov/faynoSync-squirrel-example](https://github.com/ku9nov/faynoSync-squirrel-example) ## Integration pattern 1. `checkForUpdates` runs as an **edge-first gate** to decide whether an update exists. 2. If it does, `resolveNativeFeed` resolves the correct Squirrel feed URL — edge-first, with automatic API fallback. 3. The resolved `feedURL` is passed to the native `autoUpdater.setFeedURL`, and Squirrel downloads/installs. ## Picking the updater per platform `squirrel_darwin` and `squirrel_windows` use different URL shapes, so the example selects the updater from the runtime platform: ```js import { systemPlatform, systemArch } from '@faynosync/sdk-js'; function nativeFeedOptions() { const platform = systemPlatform(); return { owner, appName: app_name, version, channel, platform, arch: systemArch(), updater: platform === 'darwin' ? 'squirrel_darwin' : 'squirrel_windows', }; } ``` ## Resolving the native feed `resolveNativeFeed` does the edge-first resolution and tells you whether to start Squirrel at all: ```js import { autoUpdater } from 'electron'; const feed = await getClient().resolveNativeFeed(nativeFeedOptions()); if (!feed.updateAvailable) { return; // nothing to do — Squirrel is never started } autoUpdater.setFeedURL({ url: feed.feedURL }); autoUpdater.checkForUpdates(); ``` `feed.source` tells you whether the feed came from `'edge'` or `'api'`. ## Why this goes through the SDK (edge details) Pointing the native `autoUpdater` straight at a faynoSync URL breaks in several non-obvious cases that `resolveNativeFeed` normalizes: - **`no_content` vs `204`.** Squirrel.Mac expects `200 { "url": "" }` or `204 No Content`. But the **edge** mirror returns `200 { "status": "no_content" }` (not a real `204`) when there's no update. The SDK reads that and returns `updateAvailable: false`, so you skip the native updater instead of feeding it a bad response. - **`404` on a cold edge.** Until the API has warmed a version's edge object, the edge returns `404`. The SDK falls back to the API (which serves the feed *and* warms the edge for next time), so the first client isn't stuck. - **`squirrel_windows` and `/RELEASES`.** Squirrel.Windows appends `/RELEASES` to the feed URL itself. The edge response is a redirect to the absolute `.../RELEASES` file, so the SDK strips the trailing `/RELEASES` and returns the **directory** as `feedURL` — exactly what `setFeedURL` needs. `feed.url` still carries the raw resolved resource (the `.zip` for macOS, the `RELEASES` URL for Windows). :::tip Debugging Squirrel.Windows 401/403 Squirrel.Windows fetches `RELEASES` and then the `.nupkg` deep in native code, so an auth error there is hard to trace. The example fetches `RELEASES` itself first and logs each `.nupkg` URL Squirrel will hit — that's where an unexpected `401`/`403` actually originates. See [`src/main/updater.js`](https://github.com/ku9nov/faynoSync-squirrel-example/blob/main/src/main/updater.js). ::: The native `autoUpdater` only runs in a packaged app and only on macOS/Windows; the example falls back to opening `resp.updateUrl` otherwise. ## Related reading - [JavaScript SDK](../sdk/js/sdk) — `resolveNativeFeed`, `NativeFeedResult`, and native updater feeds. - [Electron Example](./electron) — `electron-updater` generic provider instead of native Squirrel. - [Updaters Support](../updaters) — `squirrel_darwin` / `squirrel_windows` and the `RELEASES` output. --- ## Tauri Auto-Update Example # Tauri Example (tauri-plugin-updater) A Tauri desktop app that uses the faynoSync [JavaScript SDK](../sdk/js/sdk) for an edge-first update **gate**, then lets [`@tauri-apps/plugin-updater`](https://v2.tauri.app/plugin/updater/) perform the signature-verified download and install. This maps to the [`tauri`](../updaters) updater type (which requires the `signature` field). **Repository:** [github.com/ku9nov/faynoSync-tauri-example](https://github.com/ku9nov/faynoSync-tauri-example) ## Integration pattern 1. The SDK's `checkForUpdates` runs first as an **edge-first gate**. On the no-update path it answers from the CDN, so the native updater's endpoint is never hit. 2. Only when the gate reports an update does the app call the plugin's `check()`, which downloads and installs with Tauri's built-in signature verification. 3. The native updater endpoints themselves are configured edge-first in `tauri.conf.json`. ## Creating the client with Tauri's fetch Tauri apps run in a WebView, so network calls go through Tauri's HTTP plugin, not the Node.js `fetch`. The SDK accepts a custom `fetch`, so the example injects `@tauri-apps/plugin-http`: ```ts import { Client } from '@faynosync/sdk-js'; import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; let client: Client | null = null; function getClient(): Client { if (!client) { client = new Client({ baseURL, edgeURL: edgeURL || undefined, fetch: tauriFetch as typeof globalThis.fetch, }); } return client; } ``` This is the main reason the SDK exposes a `fetch` option — it lets the same typed client run in any runtime that provides a `fetch`-compatible function. ## The edge-first gate ```ts import { check } from '@tauri-apps/plugin-updater'; import { getVersion } from '@tauri-apps/api/app'; const currentVersion = await getVersion(); const resp = await getClient().checkForUpdates({ owner, appName, version: currentVersion, channel, platform: target(), // maps Tauri's OS name to faynoSync's platform value arch: archName(), deviceId, }); if (!resp.updateAvailable) { return; // up to date — the native updater is never started } // Update exists -> hand off to the native, signature-verified updater const update = await check(); if (update) { await update.downloadAndInstall(/* progress callback */); } ``` :::note Why gate before `check()` `check()` polls the native updater endpoint and performs signature verification on every call. Running the SDK's edge-first `checkForUpdates` first means the common no-update path is answered from the CDN (and sends the optimized `/telemetry/beacon`), so `check()` — and the API request behind it — only runs when there's actually something to install. The SDK response also carries `critical` and `changelog`, which the flat Tauri updater response doesn't, so the example uses it to enrich the update UI. ::: `target()` maps Tauri's OS names (`macos`, `windows`) to the platform values faynoSync expects (`darwin`, `windows`), so the gate and the native endpoint resolve to the same artifacts. See [`src/updater.ts`](https://github.com/ku9nov/faynoSync-tauri-example/blob/main/src/updater.ts). ## Related reading - [JavaScript SDK](../sdk/js/sdk) — `checkForUpdates`, the custom `fetch` option, and edge fallback. - [Squirrel Example](./squirrel) — native feed resolution with `resolveNativeFeed`. - [Updaters Support](../updaters) — the `tauri` updater and its `signature` requirement. --- ## Velopack Auto-Update Example (Python) # Velopack Example (Python, no SDK) A minimal Python desktop app that consumes updates from a faynoSync [Velopack](../updaters/velopack.mdx) feed using the **stock Velopack client** — no faynoSync SDK, no per-language glue. It reads the feed, optionally binds the artifacts to faynoSync's TUF root of trust, then downloads and applies the update in place. **Repository:** [github.com/ku9nov/faynoSync-velopack-p3-example](https://github.com/ku9nov/faynoSync-velopack-p3-example) Unlike the [Electron](./electron.mdx), [Squirrel](./squirrel.mdx), and [Tauri](./tauri.mdx) examples — which run the faynoSync JavaScript SDK as an edge-first gate in front of a native updater — this one uses **no SDK at all**. faynoSync speaks Velopack's native feed protocol, so the stock `UpdateManager` talks to it as it would to any static host. That is the whole point of the [`velopack` updater](../updaters/velopack.mdx). ## Integration pattern 1. Point the stock Velopack `UpdateManager` at a faynoSync `UPDATE_URL`. 2. `check_for_updates()` fetches `releases.{channel}.json` and compares versions client-side. 3. Optionally verify each artifact against TUF metadata before applying. 4. `download_updates()` → `apply_updates_and_exit()`. ## The stock client — zero SDK The client is the unmodified Velopack `UpdateManager` over an `HttpSource`. faynoSync is just the URL it points at: ```python import velopack opts = velopack.UpdateOptions(AllowVersionDowngrade=True, MaximumDeltasBeforeFallback=10) um = velopack.UpdateManager(velopack.HttpSource(url), opts) info = um.check_for_updates() if info is None: log.info("Already on the latest version (%s).", VERSION) return # ... optional TUF verification (below) ... um.download_updates(info) um.apply_updates_and_exit(info) # CLI-style: apply and exit, re-run to launch the new version ``` `AllowVersionDowngrade=True` is what lets the app roll back when you unpublish a version on faynoSync — the feed drops that version and the client resolves the next published one as the target. See [`app.py`](https://github.com/ku9nov/faynoSync-velopack-p3-example/blob/main/app.py). ## Feed URL: CDN by default, API for intermediate faynoSync serves the Velopack feed two ways, and the app only changes which base it points at. By default it reads the **static feed from the CDN**; set `ASK_API=true` to hit the **dynamic API** instead (needed for [required intermediate builds](../updaters/velopack.mdx#intermediate-builds), which the static object can't compute per client): ```python def build_update_url(): override = os.environ.get("UPDATE_URL") if override: return override base = API_BASE if ASK_API else BASE return f"{base}/velopack/{OWNER}/{APP}/{detect_platform()}/{detect_arch()}" # client → GET {url}/releases.{channel}.json ``` `detect_platform()` / `detect_arch()` map Python's OS/machine names to the platform and arch values faynoSync expects, so the URL resolves to the right per-`(platform, arch)` feed. The channel is baked into the package at `vpk pack --channel` and appears only in the feed file name. ## TUF verification (optional) When `TUF_ENABLED=true`, each artifact's SHA256/size is bound to a TUF-signed target before applying. Any mismatch — or an unsigned target once TUF is on — aborts the update: ```python if TUF_ENABLED: assets = [info.TargetFullRelease] + (info.DeltasToTarget or []) try: tuf_verify.verify_assets(assets, METADATA_URL, TARGETS_URL, OWNER, APP, detect_platform(), detect_arch(), TRUST_DIR) except tuf_verify.VerificationError as e: log.error("TUF verification FAILED, not applying update: %s", e) return ``` See [`tuf_verify.py`](https://github.com/ku9nov/faynoSync-velopack-p3-example/blob/main/tuf_verify.py). :::warning TOFU caveat On first run with no local `root.json`, the example fetches the initial TUF root over the network (trust-on-first-use). There is **no pinned/embedded root** — fine for this internal example, but a production client must ship the root with the app. See the [TUF docs](../api/tuf/tuf-introduction). ::: ## Telemetry and reports Because there is no SDK, the app talks to faynoSync's [telemetry beacon](../api/info/telemetry_beacon) and [reports](../api/reports/ingest) endpoints with plain HTTP — both are fire-and-forget and never block or fail the update: - `TELEMETRY_ENABLED=true` → `send_beacon(...)` posts to `/telemetry/beacon` on each check ([`telemetry.py`](https://github.com/ku9nov/faynoSync-velopack-p3-example/blob/main/telemetry.py)). - `REPORTS_ENABLED=true` with a `FAYNOSYNC_REPORT_KEY` → update failures POST to `/reports/ingest` ([`report.py`](https://github.com/ku9nov/faynoSync-velopack-p3-example/blob/main/report.py)). The repo's `USAGE.md` walks the full end-to-end flow — publish, update (FULL then DELTA), downgrade, TUF, intermediate builds, telemetry, and reports. ## Related reading - [Velopack Updater](../updaters/velopack.mdx) — the feed protocol, delivery modes, and delta handling. - [Self-Hosted Velopack Update Server, step by step](/blog/self-hosted-velopack-update-server) — tutorial walking through this example with the faynoSync CLI. - [Required Intermediate Build](../intermediate_build.mdx) — the feature behind `ASK_API=true`. - [TUF](../api/tuf/tuf-introduction) — signing and verification. - [Telemetry beacon API](../api/info/telemetry_beacon) — the endpoint used without an SDK. --- ## Staged Rollout FaynoSync ships new versions to a **controlled percentage of your fleet** before everyone gets them — a staged (canary) rollout. The design keeps the server simple: the server only issues a target percentage and a seed, and the **client (SDK) decides** whether a given install is in the rollout. There is no per-device state on the server, so the edge/CDN and Redis caches stay device-independent. This page explains how the mechanism works, why it is deterministic and sticky, how the intermediate-version flow interacts with it, and how the dashboard measures real adoption **without any additional API** — everything is computed on the client from two endpoints you already have. ## Configuring a rollout A rollout is a property of a specific published version. Set the `rollout` integer (0–100) in the `data` JSON when you [upload a version](/docs/api/apps/upload) or [update an existing version](/docs/api/apps/update-specific-version-app): - `rollout: 100` (or unset) — full rollout, everyone eligible gets the update. - `rollout: 20` — only ~20% of eligible installs are offered this version. On first assignment the server generates a stable `rollout_seed` for the version and stores it alongside `rollout_percent`. The seed never changes for that version, which is what makes the rollout **sticky** (see below). To advance a staged rollout you simply raise the percentage on the same version (`20` → `50` → `100`); the seed stays put. ## What the API returns A client learns its rollout from the **JSON [`/checkVersion`](/docs/api/info/latest) response**, which includes a `rollout` object **only when the offered version's percentage is below 100**: ```json { "update_available": true, "critical": false, "update_url_dmg": "https://.../myapp-1.0.2.dmg", "rollout": { "percent": 20, "seed": "badadc23b08e3943" } } ``` If the version is at 100% the `rollout` field is omitted entirely, and the client treats it as a normal full update. In [edge/CDN mode](/docs/edge) the very same response body is served from a cached manifest, so the `rollout` object travels with it unchanged and the per-device split still happens in the SDK. ## Updater compatibility Because rollout rides in the **JSON response**, it works for any integration that reads that JSON before handing off to the updater — the default JSON flow, [Tauri](/docs/updaters/tauri), [Squirrel.Mac](/docs/updaters/squirrel-darwin), and [electron-builder](/docs/updaters/electron-builder) via the JS SDK. Updaters that instead consume a native feed format do **not** receive the `rollout` object today: - **[Squirrel.Windows](/docs/updaters/squirrel-windows)** — polls a `RELEASES` file, which has no field for rollout metadata, so it never sees `rollout`. - **[Velopack](/docs/updaters/velopack)** — reads its own feed format that does not carry rollout at the moment; upstream is actively evolving, so this may be added in the near future. - **[Sparkle](/docs/updaters/sparkle)** — reads a materialized appcast, one static file served to every client, so faynoSync's deviceId/percent rollout can't diverge per device. Sparkle's own native `sparkle:phasedRolloutInterval` (time-based, client-side) does pass through the feed verbatim. - **[electron-builder](/docs/updaters/electron-builder)** — the updater itself consumes `latest.yml`, but the JS SDK reads the JSON `/checkVersion` response (with `rollout`), applies the bucketing, and only then hands the updater the `.yml` URL. So rollout works here. In short, rollout can be applied **anywhere the integration reads the JSON response first and gates the update before handing off** to the native updater. Where it isn't available today is a property of that updater's feed format and how the SDK wires it — not a server limitation. ## How the SDK decides The SDK combines the install's device ID with the server-issued seed and maps it to a bucket in `[0, 99]`. The install takes the update when its bucket is below the offered percentage. This is the **reference algorithm every SDK must replicate byte-for-byte**: ```text bucket = sha256(device_id + ":" + seed) → first 8 bytes, big-endian uint64, % 100 update if bucket < rollout.percent ``` The `device_id` is the same value sent in the `X-Device-ID` header used by [telemetry](/docs/telemetry). It must be stable across restarts. ### Why it works - **Deterministic** — the same `device_id` + `seed` always yields the same bucket, so an install's decision is reproducible and needs no server memory. - **Sticky** — because the seed is fixed per version, an install that is in the 20% bucket stays in the bucket when you raise the percentage to 50%. Ramping a rollout only ever **adds** installs, never removes them. - **Cache-safe** — the server returns only `percent` + `seed`, identical for every device, so cached `/checkVersion` responses remain valid for all clients. The per-device split happens entirely in the SDK after the (cacheable) response is read. A small sampling margin of roughly **±2%** is inherent: for a finite fleet the fraction of device IDs that hash below the threshold varies slightly around the configured percentage. It tightens as the fleet grows. ## Interaction with required intermediate versions If a version is marked as a [required intermediate](/docs/intermediate_build), the server offers **the intermediate first, carrying the intermediate's own rollout percentage**. The rollout of the final version is only revealed once the install is already on the intermediate. Concretely, for a device on `1.0.0` with intermediate `1.0.1` and latest `1.0.2`: 1. `/checkVersion` from `1.0.0` offers `1.0.1` (with `1.0.1`'s rollout, if any). 2. Once the device reports `1.0.1`, `/checkVersion` offers `1.0.2` with `1.0.2`'s rollout. Chained rollouts multiply. If `1.0.1` is at 60% and `1.0.2` at 20%, the steady state is ~40% on `1.0.0`, ~48% on `1.0.1` (`60% × 80%`), and ~12% on `1.0.2` (`60% × 20%`) as a share of the whole fleet. This matters for how adoption is measured — see below. ## Tracking adoption on the dashboard **No API changes were needed to visualise rollout health.** The dashboard's "Rollout Health" panel is a pure client-side join of two endpoints you already have: - [`/search?app_name=…`](/docs/api/apps/search) returns each version with its `RolloutPercent` — this is the **configured target**, and identifies which versions are under a staged rollout (`RolloutPercent < 100`). - [`/telemetry`](/docs/telemetry) returns `versions.usage[].client_count` (installs currently reporting each version) and `summary.unique_clients` — this is the **observed** side. The underlying counters are written whenever an install reports its version, whether inline via `/checkVersion` or, in edge/CDN mode, via the [`/telemetry/beacon`](/docs/api/info/telemetry_beacon) endpoint — both update the same Redis keys, so adoption is measured the same way regardless of mode. ### The adoption formula The rollout percentage is defined relative to the **eligible pool at that rung** — the installs that are actually offered the version — not the whole fleet. To make the observed number comparable to the configured target, the dashboard uses a cumulative, eligible-pool ratio: ```text adoption(V) = clients(version >= V) / clients(version >= predecessor(V)) ``` where `predecessor(V)` is the highest version below `V` that installs are actually reporting. - **Numerator** — installs on `V` or newer. "Or newer" matters: an install that already moved past `V` (e.g. to a later version) has still adopted `V`, so it must stay counted. - **Denominator** — installs on the predecessor or newer, i.e. the pool eligible to move to `V`. Worked against the chained example above (`1.0.1` @ 60%, `1.0.2` @ 20%, fleet of 3000): | Version | Numerator `clients(>= V)` | Denominator `clients(>= predecessor)` | Adoption | Target | |---|---|---|---|---| | `1.0.1` | 1454 + 357 = 1811 | 1189 + 1454 + 357 = 3000 | **60.4%** | 60% | | `1.0.2` | 357 | 1454 + 357 = 1811 | **19.7%** | 20% | Both land on the configured target within the ±2% margin. ### Why not divide by all unique clients A naive `clients_exactly_on_V / unique_clients` does **not** line up with the target under chained rollouts, for two reasons: 1. The denominator (whole fleet) is larger than the eligible pool, so `1.0.2` would read ~12% instead of 20%. 2. "Exactly on `V`" drops installs that moved past `V`, so `1.0.1` would read ~48% instead of 60%. The cumulative eligible-pool formula corrects both. ### Per-app scoping `versions.usage` is keyed on the **version string only** and is aggregated across apps in an unfiltered `/telemetry` response. Two apps that share a version string (e.g. both have `1.0.2`) would otherwise borrow each other's counts. The dashboard therefore reads adoption from telemetry **scoped to a single app** (`/telemetry?apps=`), and only shows rollouts for apps that have traffic in the selected period. ## Caveats - **Trailing metric.** Telemetry buckets are per-day and an install only counts toward a version after it updates and checks in again. Adoption starts noisy and **converges** as eligible installs report; it is a progress signal, not an instantaneous truth. A weekly range smooths the daily reset. - **±2% bucketing margin.** Observed will not match the target exactly, especially on small fleets. Do not wire it to alerts at small deltas. - **Scope to compare.** To line observed up with a channel- or platform-scoped rollout, apply the matching filter on the dashboard so both `/search` and `/telemetry` are scoped the same way. - **Approximate attribution.** FaynoSync stores no per-device rollout assignment (by design — privacy-minimal, cache-safe), so adoption is derived from reported versions, not from a record of which installs were in the bucket. ## Related - [Telemetry System](/docs/telemetry) — the `/telemetry` data and `X-Device-ID` that adoption is built on. - [Required Intermediate Versions](/docs/intermediate_build) — how the intermediate step gates a rollout. - [Update a specific version](/docs/api/apps/update-specific-version-app) and [Upload](/docs/api/apps/upload) — where the `rollout` field is set. - [`/checkVersion`](/docs/api/info/latest) — the endpoint that issues `rollout` `{percent, seed}`. - [JavaScript SDK](/docs/sdk/js/sdk#staged-rollout) and [Go SDK](/docs/sdk/go/sdk#staged-rollout) — how the client-side bucketing is exposed and applied in each SDK. - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) — catch failing updates while a rollout is small. - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) — the telemetry data adoption tracking relies on. --- ## Required Intermediate Build # Required Intermediate Build Feature ## Overview The Required Intermediate Build feature is designed to enforce a specific update path for applications, ensuring that users update through intermediate versions when necessary. This is particularly useful when there are breaking changes or critical updates that require users to go through specific versions before reaching the latest version. ## How It Works ### 1. Setting Required Intermediate Build When uploading or updating an app version, you can mark it as a required intermediate step by setting the `intermediate` parameter to `true`. This version will then be required for users who are updating from an older version to a newer version. ### 2. Update Flow When a user checks for updates: 1. The system checks if there are any required intermediate versions between the user's current version and the latest version 2. If required intermediate versions exist, the system returns the first required version instead of the latest version 3. Users must update through these required versions in sequence ## Response Examples ### When Required Migration Step Exists ```json { "changelog": "Example changelog\n", "critical": true, "is_intermediate_required": true, "update_available": true, "update_url_deb": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.5.deb", "update_url_rpm": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.5.rpm" } ``` ### When No Required Migration Step Exists ```json { "changelog": "Example changelog\n", "critical": false, "update_available": true, "update_url_deb": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.8.deb", "update_url_rpm": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.8.rpm" } ``` ## Use Cases 1. **Breaking Changes**: When a new version introduces breaking changes that require specific migration steps 2. **Database Migrations**: When database schema changes require specific update paths 3. **Security Updates**: When critical security updates need to be applied in a specific order 4. **Feature Dependencies**: When new features depend on changes introduced in intermediate versions ## Best Practices 1. Always document why a version is marked as a required intermediate step 2. Keep the number of required intermediate steps to a minimum 3. Ensure that required intermediate versions are stable and well-tested 4. Include clear changelog entries explaining the migration requirements 5. Consider the impact on users when planning Required Intermediate Build ## Limitations 1. The system only enforces the first required intermediate version in the update path 2. Users must update through required versions sequentially 3. The feature requires proper versioning following semantic versioning principles --- ## Telemetry System The telemetry system in FaynoSync provides detailed analytics and insights about your application's usage, helping you understand your user base and make informed decisions about updates and maintenance. You can enable statistics by defining the variable `ENABLE_TELEMETRY=true` in the `.env` file. When it is `false`, faynoSync records no client telemetry: `/checkVersion` does not count requests and `/telemetry/beacon` returns `403`. ## Overview The telemetry system collects and aggregates data about your application's usage, including: - Total requests - Unique clients - Version distribution - Platform usage - Architecture distribution - Channel usage - Daily statistics ## Dashboard Visualization and Analysis The telemetry dashboard provides powerful visualization and analysis tools that help you gain valuable insights into your user base: ### Client Analytics - Accurate client counting using the `X-Device-ID` header to prevent duplicate counting - Real-time tracking of active users across different platforms - Historical trends showing user growth and engagement ### Version Distribution Analysis - Detailed breakdown of application versions in use - Version adoption rates across different platforms - Identification of outdated versions requiring attention ### Platform and Architecture Insights - Platform popularity metrics to understand user preferences - Architecture distribution to optimize resource allocation - Cross-platform version update rates to identify platform-specific issues ### Business Intelligence - Data-driven decision making for product development - Identification of high-priority platforms and architectures - Understanding of user behavior and preferences - Strategic insights for business growth and product optimization ## How It Works ### Data Collection Telemetry data is collected automatically when clients check for updates using the `/checkVersion` API endpoint. The system tracks: - Device ID (via `X-Device-ID` header) - Application name - Platform - Channel - Architecture - Application version ### Data Storage All telemetry data is stored in Redis with a 30-day retention period. The data is organized by: - Administrator - Application - Date - Various metrics (versions, platforms, channels, etc.) ## Access Control ### Administrator Access Each administrator has access to telemetry data only for their own resources. This ensures data privacy and proper separation of concerns. ### Team User Access Team users have access to their administrator's telemetry data, but with some limitations: - They can view all statistics - They can only filter/sort data for resources they have access to - Access is verified through the API to ensure proper authorization ## Device ID Requirements ### Importance of Device ID The `X-Device-ID` header is crucial for accurate telemetry data. It helps: - Track unique devices - Provide accurate user counts - Generate meaningful statistics ### Implementation Guidelines 1. Generate a unique device ID for each installation 2. Store the ID persistently on the device 3. Include it in all requests to the `/checkVersion` endpoint 4. Use the same ID across app restarts ### Security Considerations ⚠️ **Important**: The `X-Device-ID` header should: - NOT contain any personal information - NOT include IP addresses - NOT include any sensitive data - Be used solely for device identification ## API Usage ### Collecting Telemetry Telemetry is automatically collected when using the `/checkVersion` endpoint. Ensure you include the `X-Device-ID` header: ```http GET /checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=x64 X-Device-ID: unique-device-identifier ``` ### Viewing Telemetry Data Access telemetry data through the dashboard or API: ```http GET /telemetry?range=week ``` Available query parameters: - `date`: Specific date (YYYY-MM-DD) - `range`: Time range ("week" or "month") - `apps`: Filter by specific apps - `channels`: Filter by channels - `platforms`: Filter by platforms - `architectures`: Filter by architectures ## Best Practices 1. **Device ID Generation** - Use UUID v4 or similar for device IDs - Store IDs in secure, persistent storage - Never change the ID for an existing installation 2. **Data Privacy** - Never include personal information in device IDs - Follow data protection regulations - Regularly review collected data 3. **Implementation** - Implement telemetry collection early in development - Test with various device configurations - Monitor data quality and accuracy ## Example Response ```json { "date": "2025-06-09", "date_range": [ "2025-06-09", "2025-06-10", "2025-06-11", "2025-06-12", "2025-06-13", "2025-06-14", "2025-06-15", "2025-06-16" ], "admin": "ku9n", "summary": { "total_requests": 1765, "unique_clients": 1751, "clients_using_latest_version": 167, "clients_outdated": 1584, "total_active_apps": 5 }, "versions": { "used_versions_count": 12, "known_versions": [ "0.0.0.1", "0.0.0.10", "0.0.0.2", "0.0.0.3", "0.0.0.4", "0.0.0.5", "0.0.0.6", "0.0.0.7", "0.0.0.8", "0.0.0.9", "0.0.2.0", "0.0.2.1" ], "usage": [ { "version": "0.0.0.2", "client_count": 149 }, { "version": "0.0.0.3", "client_count": 149 }, { "version": "0.0.0.4", "client_count": 120 }, { "version": "0.0.0.5", "client_count": 145 }, { "version": "0.0.0.6", "client_count": 154 }, { "version": "0.0.0.7", "client_count": 125 }, { "version": "0.0.0.8", "client_count": 141 }, { "version": "0.0.0.9", "client_count": 158 }, { "version": "0.0.2.1", "client_count": 167 }, { "version": "0.0.0.1", "client_count": 139 }, { "version": "0.0.0.10", "client_count": 147 }, { "version": "0.0.2.0", "client_count": 157 } ] }, "platforms": [ { "platform": "universal", "client_count": 224 }, { "platform": "linux", "client_count": 215 }, { "platform": "browser", "client_count": 213 }, { "platform": "darwin", "client_count": 207 }, { "platform": "windows", "client_count": 229 }, { "platform": "android", "client_count": 244 } ], "architectures": [ { "arch": "chrome", "client_count": 213 }, { "arch": "edge", "client_count": 211 }, { "arch": "universal", "client_count": 200 }, { "arch": "arm64", "client_count": 230 }, { "arch": "firefox", "client_count": 227 }, { "arch": "amd64", "client_count": 231 } ], "channels": [ { "channel": "nightly", "client_count": 435 }, { "channel": "beta", "client_count": 446 }, { "channel": "stable", "client_count": 448 }, { "channel": "alpha", "client_count": 422 } ], "daily_stats": [ { "date": "2025-06-09", "total_requests": 100, "unique_clients": 100, "clients_using_latest_version": 12, "clients_outdated": 88 }, { "date": "2025-06-10", "total_requests": 150, "unique_clients": 150, "clients_using_latest_version": 14, "clients_outdated": 136 }, { "date": "2025-06-11", "total_requests": 200, "unique_clients": 200, "clients_using_latest_version": 17, "clients_outdated": 183 }, { "date": "2025-06-12", "total_requests": 250, "unique_clients": 250, "clients_using_latest_version": 28, "clients_outdated": 222 }, { "date": "2025-06-13", "total_requests": 300, "unique_clients": 300, "clients_using_latest_version": 32, "clients_outdated": 268 }, { "date": "2025-06-14", "total_requests": 350, "unique_clients": 350, "clients_using_latest_version": 26, "clients_outdated": 324 }, { "date": "2025-06-15", "total_requests": 400, "unique_clients": 400, "clients_using_latest_version": 37, "clients_outdated": 363 }, { "date": "2025-06-16", "total_requests": 15, "unique_clients": 1, "clients_using_latest_version": 1, "clients_outdated": 0 } ] } ``` ## Limitations 1. Data is retained for 30 days 2. Statistics are only collected when `X-Device-ID` is provided 3. Team users have limited filtering capabilities 4. Data is aggregated daily ## Related reading - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) --- ## Team Based Authorization # Team Based Authorization Matrix **Team Based Authorization Matrix** — a system that enables fine-grained, team-level access control. This mechanism allows administrators to create team users, assign specific permissions, and strictly isolate data between different administrators. ## Core Concepts ### Administrator - A unique user (admin usernames must be unique). - Owns a private set of team users. - Has access **only to their own users and resources**. - Can create, update, and delete their own team users. - Can set a password (including auto-generation via UI), change the username, password, and permissions of a user — **only if that user belongs to them**. ### Team User - Also a unique user (usernames must not be duplicated). - Belongs to a specific administrator and is **isolated** from others. - **Has no access** to any user-related API ([user management endpoints](./api/team/users-introduction)). - Can only create, edit, or delete **resources that belong to their administrator**. - Any resource created by a team user is automatically owned by their administrator. ### Access Permissions Each team user can be granted specific permissions, defined separately for each type of resource. #### Supported Permissions: - Create - Edit - Delete - Upload (only for `applications`) - Download (only for `applications`) #### Resource Types: - Applications - Channels - Platforms - Architectures When a resource is created by a team user who has the Create permission for that resource type, it will be **automatically added to the team user's list of allowed resources**. ### Resource Visibility and Ownership - Team users can **only access resources they’ve been explicitly granted access to**. - Uploads ([`POST /upload`](./api/apps/upload), [Upload Init](./api/apps/upload-init), [Upload Complete](./api/apps/upload-complete)) need the Upload permission, and the app, channel, platform and architecture of the upload must all be in the team user's allowed lists. Otherwise the request returns `403` before any file is stored. Before v2.4.0 `POST /upload` did not check these lists. - Team users cannot see or manage resources from other administrators. - **Administrators can view and manage all resources created by their team users**. - Duplicate resource names (`applications`, `channels`, `platforms`, `architectures`) **are allowed across administrators**, but not within the same administrator's scope. ### API Changes To prevent collisions from duplicated resource names across different administrators, the following endpoints now require an additional mandatory query parameter: `owner`. #### Updated Endpoints: - [`GET /checkVersion`](./api/info/latest) - [`GET /apps/latest`](./api/info/fetch-latest-build) `owner` is the administrator's account name. It ensures that the correct scope is used when resolving resource names. ### Expected Behavior Action Team User Administrator Create resources ✅ (within admin scope) ✅ Edit resources ✅ (only own admin’s) ✅ Delete resources ✅ (only own admin’s) ✅ Upload artifacts ✅ (with Upload permission, into allowed apps, channels, platforms and architectures only) ✅ Manage users ❌ ✅ Access user API ❌ ✅ Access other admins' data ❌ ❌ ## Related documentation - [Team users introduction](./api/team/users-introduction) — managing team users under an administrator. - [Update admin permissions](./api/team/updateAdmin) — change a team user's username, password, and permissions. - [Login](./api/auth/login) — authenticate as an administrator or team user. - [Whoami](./api/info/whoami) — resolve the current authenticated identity and scope. - [Team-Based Authorization in faynoSync — Manage Your Team Like a Pro](https://faynosync.com/blog/team-based-auth) — a walkthrough of team roles and permissions. - [Private Apps in faynoSync — Keep Your Software Secure](https://faynosync.com/blog/private-apps) — keep builds of private apps behind authentication. --- ## SDK Overview Official faynoSync SDKs are client libraries for checking application updates against your faynoSync instance. They were created so you can improve performance and reduce load on the API server **without global changes to the core update API**. SDKs add a typed transport layer, predictable client behavior, and first-class support for **edge** delivery patterns on top of the same `/checkVersion` contract. ## Why use an SDK? | Benefit | What it means in practice | |---------|---------------------------| | **Server stability** | Optimize hot paths (for example, edge JSON + API fallback) in the client instead of forking or extending the API server for every rollout strategy. | | **Edge support** | Use cached static responses from CDN/object storage when `cdn` is enabled on an app, with automatic fallback to the live API. | | **Typed contracts** | Structured options and responses instead of ad-hoc query maps and manual JSON parsing. | | **Release workflows** | Combine faynoSync with SDK-driven checks to treat your instance as a **release manager** while keeping faynoSync's dynamic, flexible update-server model (custom channels, platforms, architectures, and artifacts). | ## Edge and the API server When application CDN caching is enabled, faynoSync can publish version-scoped JSON responses for edge delivery. **That flow is intended to work correctly with an official SDK**: the client tries edge first, falls back to `/checkVersion` when needed, and can use complementary endpoints such as `/telemetry/beacon` when the live check is skipped. Calling `/checkVersion` directly still works, but you do not get the same edge-first orchestration, source semantics, or SDK-optimized telemetry paths out of the box. ## Available SDKs | SDK | Status | Documentation | |-----|--------|---------------| | Go | Available | [Go SDK](./go/sdk) | | JavaScript / TypeScript | Available | [JavaScript SDK](./js/sdk) | Choose the SDK that matches your runtime. All SDKs share the same goals: typed update checks, optional edge acceleration, and room to evolve client behavior without breaking the core faynoSync API. --- ## Go SDK Production-oriented Go SDK for checking application updates with faynoSync. This package is a small typed transport and developer experience layer. It does not implement update installation, platform normalization, metadata verification, caching, or business rules. :::info Edge delivery requires an SDK Edge response caching and the edge-first fallback flow are designed to work correctly when clients use an official faynoSync SDK (this Go SDK or the [JS SDK](../js/sdk)). Raw `/checkVersion` calls do not provide the same edge orchestration, `Source` semantics, or SDK-optimized telemetry paths. See the [SDK overview](../intro) for context. ::: ## Installation ```sh go get github.com/ku9nov/faynosync-sdk-go ``` ## Quick Start ```go package main import ( "context" "fmt" "log" faynosync "github.com/ku9nov/faynosync-sdk-go" ) func main() { client := faynosync.NewClient(faynosync.Config{ BaseURL: "https://api.example.com", }) resp, err := client.CheckForUpdates(context.Background(), faynosync.CheckOptions{ Owner: "admin", AppName: "test", Version: "0.0.0.5", Channel: "nightly", Platform: "darwin", Arch: "arm64", }) if err != nil { log.Fatal(err) } if resp.UpdateAvailable { if resp.UpdateURL != "" { fmt.Printf("Update is available: %s\n", resp.UpdateURL) } for _, packageURL := range resp.PackageURLs { fmt.Printf("%s update is available: %s\n", packageURL.Package, packageURL.URL) } } } ``` ## Client Configuration ```go client := faynosync.NewClient(faynosync.Config{ BaseURL: "https://api.example.com", EdgeURL: "https://cdn.example.com", HTTPClient: &http.Client{ Timeout: 10 * time.Second, }, }) ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `BaseURL` | string | Yes | Base faynoSync API URL used for update checks. | | `EdgeURL` | string | No | Optional edge endpoint used first for static JSON responses. | | `HTTPClient` | `*http.Client` | No | Custom HTTP client for timeouts, transports, proxy, and pooling policies. | Notes: - `BaseURL` is required. - If `HTTPClient` is omitted, the SDK creates a default client with timeout. - The client is safe for concurrent use. ## CheckForUpdates Contract `CheckForUpdates` sends a context-aware request and returns a typed `UpdateResponse`. ### CheckOptions | Field | Type | Required | Description | |-------|------|----------|-------------| | `Owner` | string | Yes | Application owner username. | | `AppName` | string | Yes | Application name. | | `Version` | string | Yes | Current client version. | | `Channel` | string | Yes | Release channel value used by your faynoSync setup. | | `Platform` | string | Yes | Platform value used by your faynoSync setup. | | `Arch` | string | Yes | Architecture value used by your faynoSync setup. | | `DeviceID` | string | No | Device identifier sent as `X-Device-ID` header when provided. Required to participate in [staged rollouts](#staged-rollout). | | `DownloadToken` | string | No | [Download token](../../api/apps/download-tokens-regenerate) of a private app in `strict` mode, sent as the `X-Download-Token` header. Requires SDK v0.4.0 or later. | For a `strict` private app, a check without `DownloadToken` gets the same answer as an app that does not exist. When `DownloadToken` is set, the SDK skips `EdgeURL` and asks the API directly, because private apps are never published to the edge. If your application downloads the artifact itself, send the same header to [`/download`](../../api/apps/download) and use `faynosync.StripDownloadTokenOnRedirect` as the `http.Client`'s `CheckRedirect`, so the token is not forwarded to the storage host. ### Request Mapping Base API endpoint: ```text GET /checkVersion?app_name=test&version=0.0.0.5&channel=nightly&platform=darwin&arch=arm64&owner=admin&updater=manual X-Device-ID: optional-device-id ``` Query parameters are built from the named fields on `CheckOptions` (`Owner`, `AppName`, `Version`, and so on). You pass a struct with compile-time types instead of a generic key-value map such as `map[string]string`, which keeps the API explicit and easier to validate. ## Response Contract ### Core Response Fields | Field | Type | Description | |-------|------|-------------| | `UpdateAvailable` | bool | Whether an update is available for the request scope. | | `UpdateURL` | string | Direct binary update URL (single artifact flow). | | `PackageURLs` | `[]PackageURL` | Package-specific update URLs extracted from dynamic response keys. | | `Changelog` | string | Optional release notes or markdown changelog. | | `Critical` | bool | Indicates a critical update. | | `IsIntermediateRequired` | bool | Indicates whether an intermediate update is required. | | `PossibleRollback` | bool | Indicates rollback metadata from server response. | | `Source` | enum | Response source: `SourceEdge` or `SourceAPI`. | | `Rollout` | `*RolloutInfo` | Present (non-nil) only when the server offered a staged rollout for the version. See [Staged Rollout](#staged-rollout). | ### Dynamic Package URL Mapping faynoSync can return package-specific URL fields with dynamic suffixes. For example: - `update_url_deb` - `update_url_rpm` - `update_url_msi` Other package types use the same `update_url_` pattern (for example, `update_url_appimage` or `update_url_pkg`). The SDK maps these dynamic fields into a typed collection: ```go for _, packageURL := range resp.PackageURLs { fmt.Println(packageURL.Package, packageURL.URL) } ``` ### Source Semantics | Value | Meaning | |-------|---------| | `SourceEdge` | The response was served from the edge JSON endpoint. | | `SourceAPI` | The response was served by the BaseURL API after direct call or fallback. | ## Staged Rollout faynoSync can ship a version to a controlled percentage of the fleet first (a staged/canary rollout). The server stays stateless — it only issues a target percentage and a stable seed — and the **SDK decides client-side** whether a given install is included. See [Staged Rollout](../../rollout) for the full design. When the offered version's rollout is below 100%, `/checkVersion` includes a `rollout` object: ```json { "update_available": true, "update_url": "https://downloads.example.com/app", "rollout": { "percent": 20, "seed": "badadc23b08e3943" } } ``` At 100% the field is omitted and the SDK treats the response as a normal full update. The decision is deterministic and sticky, using the reference algorithm shared by every faynoSync SDK: ```text bucket = sha256(deviceID + ":" + seed) → first 8 bytes, big-endian uint64, % 100 included if bucket < rollout.percent ``` When the install is **not** in the bucket, the SDK forces `UpdateAvailable` to `false` **and clears `UpdateURL`/`PackageURLs`**, so a caller that checks the URLs instead of `UpdateAvailable` still cannot pull an update the device was not offered. A normal `if resp.UpdateAvailable` gate is all you need; the `Rollout` field exposes the decision for logging: ```go resp, err := client.CheckForUpdates(ctx, faynosync.CheckOptions{ /* ... */ DeviceID: "stable-device-id"}) if err != nil { log.Fatal(err) } if resp.Rollout != nil { fmt.Println(resp.Rollout.Percent, resp.Rollout.Bucket, resp.Rollout.Eligible) } ``` ### RolloutInfo | Field | Type | Description | |-------|------|-------------| | `Percent` | int | The rollout target percentage the server issued for the version. | | `Seed` | string | Stable per-version seed used to bucket the device. | | `Bucket` | `*int` | Deterministic bucket in `[0, 99]`, or `nil` when no `DeviceID` was supplied. | | `Eligible` | bool | Whether the install is inside the rollout. When `false`, the SDK has already gated the response. | `DeviceID` is required to participate: it must be the same stable value used for [telemetry](../../telemetry) (`X-Device-ID`). Without it the bucket cannot be computed, so the install stays out of the rollout (`Eligible: false`, `Bucket: nil`) until a `DeviceID` is provided. Raising the percentage on the same version only ever **adds** installs — an install in the 20% bucket stays in when you move to 50%. A malformed `rollout` object (missing/non-numeric `percent`, or missing/empty `seed`) is ignored and treated as no rollout at all — the full update is served. Rollout works identically in [edge/CDN mode](#edge-fallback-behavior), since the same JSON body is served from the cached manifest and the split happens in the SDK after the (cacheable) response is read. The `faynosync.RolloutBucket(deviceID, seed)` helper is exported if you need to compute a bucket yourself. ## Edge Fallback Behavior Edge delivery only works as intended when update checks go through an official faynoSync SDK. The server can publish cached JSON for CDN/object storage, but the client must implement edge-first requests, API fallback, and optional telemetry flows that the SDK provides. When `EdgeURL` is configured, the SDK first requests: ```text GET /responses/{owner}/{app_name}/{channel}/{platform}/{arch}/manual/{version}.json ``` Example (`manual` is the updater the SDK checks with): ```text GET /responses/admin/test/nightly/darwin/arm64/manual/0.0.0.5.json ``` Empty `Channel`, `Platform` and `Arch` are left out of the path, the `manual` segment is dropped when `Platform` is empty, and `-` in the version becomes `.`, matching the object key the server writes. ### Fallback Decision Matrix | Edge Result | Fallback to BaseURL API | Final `Source` | |-------------|--------------------------|----------------| | HTTP 200 + valid JSON | No | `SourceEdge` | | Network error | Yes | `SourceAPI` when API succeeds | | Timeout | Yes | `SourceAPI` when API succeeds | | Invalid JSON | Yes | `SourceAPI` when API succeeds | | HTTP 404 | Yes | `SourceAPI` when API succeeds | | Any non-200 status | Yes | `SourceAPI` when API succeeds | If both edge and API fail, `CheckForUpdates` returns an error. ## Platform, Channel, and Architecture Values faynoSync supports custom values for platform, channel, and architecture. The SDK never normalizes or remaps them. Examples of values that are **not** automatically transformed: - `macos` to `darwin` - `osx` to `darwin` - `stable` to `default` Whatever you set in `CheckOptions.Channel`, `CheckOptions.Platform`, and `CheckOptions.Arch` is sent as-is. ## Optional System Helpers The SDK provides optional helpers: ```go platform := faynosync.SystemPlatform() // runtime.GOOS arch := faynosync.SystemArch() // runtime.GOARCH ``` These helpers are never applied automatically. Use them only when runtime values match your faynoSync configuration. ## Error Handling The SDK validates required fields and returns sentinel errors: ```go resp, err := client.CheckForUpdates(ctx, opts) if err != nil { switch { case errors.Is(err, faynosync.ErrMissingBaseURL): // Configure Config.BaseURL. case errors.Is(err, faynosync.ErrMissingOwner): // Set CheckOptions.Owner. case errors.Is(err, faynosync.ErrMissingAppName): // Set CheckOptions.AppName. case errors.Is(err, faynosync.ErrMissingVersion): // Set CheckOptions.Version. case errors.Is(err, faynosync.ErrRequestFailed): // Inspect wrapped endpoint failure. default: // Handle any other error. } } ``` Request failures preserve underlying causes for `errors.Is` and `errors.As`: ```go var endpointErr *faynosync.EndpointError if errors.As(err, &endpointErr) { fmt.Println(endpointErr.URL) fmt.Println(endpointErr.StatusCode) } ``` ## Examples Runnable examples live in the [faynosync-sdk-go](https://github.com/ku9nov/faynosync-sdk-go) repository: | Example | Description | Source | |---------|-------------|--------| | Basic | Minimal update check against `BaseURL` | [examples/basic/main.go](https://github.com/ku9nov/faynosync-sdk-go/blob/main/examples/basic/main.go) | | Edge fallback | `EdgeURL` first, then API fallback | [examples/edge-fallback/main.go](https://github.com/ku9nov/faynosync-sdk-go/blob/main/examples/edge-fallback/main.go) | | Custom HTTP client | Custom timeouts, transport, and pooling | [examples/custom-http-client/main.go](https://github.com/ku9nov/faynosync-sdk-go/blob/main/examples/custom-http-client/main.go) | ## Security Scope This SDK version performs update-check transport requests and typed response decoding only. --- ## JavaScript SDK Production-oriented JavaScript/TypeScript SDK for checking application updates with faynoSync. This package is a small typed transport and developer experience layer. It does not implement update installation, platform normalization, metadata verification, caching, or business rules. :::info Edge delivery requires an SDK Edge response caching and the edge-first fallback flow are designed to work correctly when clients use an official faynoSync SDK (this JS SDK or the [Go SDK](../go/sdk)). Raw `/checkVersion` calls do not provide the same edge orchestration, `source` semantics, or SDK-optimized telemetry paths. See the [SDK overview](../intro) for context. ::: ## Requirements Node.js 18 or later. The SDK uses the built-in global `fetch` and `AbortSignal.timeout`. ## Installation ```sh npm install @faynosync/sdk-js ``` ## Quick Start ```ts import { Client } from '@faynosync/sdk-js'; const client = new Client({ baseURL: 'https://api.example.com', }); const resp = await client.checkForUpdates({ owner: 'admin', appName: 'test', version: '0.0.0.5', channel: 'nightly', platform: 'darwin', arch: 'arm64', }); if (resp.updateAvailable) { if (resp.updateUrl !== '') { console.log('Update is available:', resp.updateUrl); } for (const pkg of resp.packageUrls) { console.log(`${pkg.package} update is available:`, pkg.url); } } ``` ## Client Configuration ```ts import { Client } from '@faynosync/sdk-js'; const client = new Client({ baseURL: 'https://api.example.com', edgeURL: 'https://cdn.example.com', timeoutMs: 10_000, fetch: customFetch, }); ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `baseURL` | `string` | Yes | Base faynoSync API URL used for update checks. Must be an absolute URL. | | `edgeURL` | `string` | No | Optional edge endpoint tried first for static JSON responses. | | `timeoutMs` | `number` | No | Per-request timeout in milliseconds. Defaults to `30000`. | | `fetch` | `typeof fetch` | No | Custom `fetch` implementation for proxies, pooling, or logging. | Notes: - `baseURL` is required and is validated as an absolute URL. - If `fetch` is omitted, the SDK uses `globalThis.fetch` (Node.js 18+ built-in). - The same `timeoutMs` is applied to every request. - The client is safe for concurrent use. ## checkForUpdates Contract `checkForUpdates` sends a request and resolves to a typed `UpdateResponse`. An optional `AbortSignal` can be passed as the second argument to cancel the request. ```ts const controller = new AbortController(); setTimeout(() => controller.abort(), 5_000); const resp = await client.checkForUpdates(opts, controller.signal); ``` The request also respects `timeoutMs`: whichever fires first — your signal or the timeout — aborts the request. ### CheckOptions | Field | Type | Required | Description | |-------|------|----------|-------------| | `owner` | `string` | Yes | Application owner username. | | `appName` | `string` | Yes | Application name. | | `version` | `string` | Yes | Current client version. | | `channel` | `string` | Conditional | Release channel value used by your faynoSync setup. | | `platform` | `string` | Conditional | Platform value used by your faynoSync setup. | | `arch` | `string` | Conditional | Architecture value used by your faynoSync setup. | | `deviceId` | `string` | No | Device identifier. Sent as the `X-Device-ID` header, triggers a telemetry beacon after a successful edge response, and is required to participate in [staged rollouts](#staged-rollout). | | `downloadToken` | `string` | No | [Download token](../../api/apps/download-tokens-regenerate) of a private app in `strict` mode, sent as the `X-Download-Token` header. Requires SDK 0.7.0 or later. | For a `strict` private app, a check without `downloadToken` gets the same answer as an app that does not exist. When `downloadToken` is set, the SDK skips `edgeURL` and asks the API directly, because private apps are never published to the edge. If your application downloads the artifact itself, send the same header (exported as `DOWNLOAD_TOKEN_HEADER`) to [`/download`](../../api/apps/download), request it with `redirect: 'manual'`, and fetch the `Location` URL without the header, so the token is not forwarded to the storage host. The SDK only validates `owner`, `appName`, and `version` as required; omitted fields are sent as empty query values. `channel`, `platform`, and `arch` are not validated client-side, but if the target app in your faynoSync instance defines channels, platforms, or architectures, you must provide the matching values or the check will not resolve to the correct release. ### Request Mapping Base API endpoint: ```text GET /checkVersion?app_name=test&version=0.0.0.5&channel=nightly&platform=darwin&arch=arm64&owner=admin&updater=manual X-Device-ID: optional-device-id ``` The SDK's own update check always sends `updater=manual`. Framework-native updaters use different `updater` values — see [Native Updater Feeds](#native-updater-feeds). Query parameters are built from the named fields on `CheckOptions`. You pass a typed object instead of a generic key-value map, which keeps the API explicit and easy to validate. ## Response Contract `checkForUpdates` decodes the server response into a typed `UpdateResponse`. ### Core Response Fields | Field | Type | Description | |-------|------|-------------| | `updateAvailable` | `boolean` | Whether an update is available for the request scope. | | `updateUrl` | `string` | Direct binary update URL (single artifact flow). Empty string when not present. | | `packageUrls` | `readonly PackageUpdateURL[]` | Package-specific update URLs extracted from dynamic response keys, sorted alphabetically by package name. | | `changelog` | `string` | Optional release notes or markdown changelog. | | `critical` | `boolean` | Indicates a critical update. | | `isIntermediateRequired` | `boolean` | Indicates whether an intermediate update is required. | | `possibleRollback` | `boolean` | Indicates rollback metadata from the server response. | | `source` | `UpdateSource` | Where the response came from: `'edge'`, `'api'`, or `'unknown'`. | | `rollout` | `RolloutInfo` (optional) | Present only when the server offered a staged rollout for the version. See [Staged Rollout](#staged-rollout). | A `PackageUpdateURL` has two fields: ```ts interface PackageUpdateURL { readonly package: string; // e.g. 'deb', 'rpm', 'msi' readonly url: string; } ``` ### Dynamic Package URL Mapping faynoSync can return package-specific URL fields with dynamic suffixes, for example: - `update_url_deb` - `update_url_rpm` - `update_url_msi` Other package types use the same `update_url_` pattern (for example, `update_url_appimage` or `update_url_pkg`). The SDK maps every `update_url_*` string field into the typed `packageUrls` collection: ```ts for (const pkg of resp.packageUrls) { console.log(pkg.package, pkg.url); } ``` ### Source Semantics | Value | Meaning | |-------|---------| | `'edge'` | The response was served from the edge JSON endpoint. | | `'api'` | The response was served by the `baseURL` API, after a direct call or fallback. | | `'unknown'` | Default before the source is resolved; not expected on a returned response. | ## Staged Rollout faynoSync can ship a version to a controlled percentage of the fleet first (a staged/canary rollout). The server stays stateless — it only issues a target percentage and a stable seed — and the **SDK decides client-side** whether a given install is included. See [Staged Rollout](../../rollout) for the full design. When the offered version's rollout is below 100%, `/checkVersion` includes a `rollout` object: ```json { "update_available": true, "update_url": "https://downloads.example.com/app", "rollout": { "percent": 20, "seed": "badadc23b08e3943" } } ``` At 100% the field is omitted and the SDK treats the response as a normal full update. The decision is deterministic and sticky, using the reference algorithm shared by every faynoSync SDK: ```text bucket = sha256(deviceId + ":" + seed) → first 8 bytes, big-endian uint64, % 100 included if bucket < rollout.percent ``` When the install is **not** in the bucket, the SDK forces `updateAvailable` to `false` **and blanks `updateUrl`/`packageUrls`**, so a caller that checks the URLs instead of `updateAvailable` still cannot pull an update the device was not offered. A normal `if (resp.updateAvailable)` gate is all you need; the `rollout` object exposes the decision for logging: ```ts const resp = await client.checkForUpdates({ ...opts, deviceId: 'stable-device-id' }); if (resp.rollout) { console.log(resp.rollout.percent, resp.rollout.bucket, resp.rollout.eligible); } ``` ### RolloutInfo | Field | Type | Description | |-------|------|-------------| | `percent` | `number` | The rollout target percentage the server issued for the version. | | `seed` | `string` | Stable per-version seed used to bucket the device. | | `bucket` | `number \| null` | Deterministic bucket in `[0, 99]`, or `null` when no `deviceId` was supplied. | | `eligible` | `boolean` | Whether the install is inside the rollout. When `false`, the SDK has already gated the response. | `deviceId` is required to participate: it must be the same stable value used for [telemetry](../../telemetry) (`X-Device-ID`). Without it the bucket cannot be computed, so the install stays out of the rollout (`eligible: false`, `bucket: null`) until a `deviceId` is provided. Raising the percentage on the same version only ever **adds** installs — an install in the 20% bucket stays in when you move to 50%. A malformed `rollout` object (missing/non-numeric `percent`, or missing/empty `seed`) is ignored and treated as no rollout at all — the full update is served. Rollout works identically in [edge/CDN mode](#edge-fallback-behavior), since the same JSON body is served from the cached manifest and the split happens in the SDK after the (cacheable) response is read. The `rolloutBucket(deviceId, seed)` helper is exported if you need to compute a bucket yourself. ## Edge Fallback Behavior Edge delivery only works as intended when update checks go through an official faynoSync SDK. The server can publish cached JSON for CDN/object storage, but the client must implement edge-first requests, API fallback, and the optional telemetry flow that the SDK provides. When `edgeURL` is configured, the SDK first requests: ```text GET /responses/{owner}/{appName}/{channel}/{platform}/{arch}/{updater}/{version}.json ``` Example (`updater` is `manual` for the SDK's own check): ```text GET /responses/admin/test/nightly/darwin/arm64/manual/0.0.0.5.json ``` In the edge path, `-` in the version is normalized to `.` (so `2.0.0-4` resolves to `2.0.0.4.json`), matching the Base API behavior. Empty `channel`, `platform` and `arch` are left out of the path, and the updater segment is dropped when `platform` is empty, matching the object key the server writes. ### Fallback Decision Matrix | Edge Result | Fallback to `baseURL` API | Final `source` | |-------------|----------------------------|----------------| | HTTP 200 + valid JSON | No | `'edge'` | | Network error | Yes | `'api'` when API succeeds | | Timeout | Yes | `'api'` when API succeeds | | Invalid JSON | Yes | `'api'` when API succeeds | | HTTP 404 | Yes | `'api'` when API succeeds | | Any non-200 status | Yes | `'api'` when API succeeds | If the request was aborted, the SDK does not fall back. If both edge and API fail, `checkForUpdates` rejects with a `CheckError`. ### Edge Telemetry When `deviceId` is set and an edge response succeeds, the SDK sends a telemetry beacon to `GET /telemetry/beacon` (with `is_latest` derived from `updateAvailable`). Telemetry failures are swallowed and never affect the update-check result. ## Native Updater Feeds Framework-native updaters (Squirrel.Mac, Squirrel.Windows) poll faynoSync themselves with a different `updater` value and URL shape than the SDK's `manual` check. The SDK owns that wire format so you only pick the updater. Supported values are exported as `NATIVE_UPDATERS`: ```ts import { NATIVE_UPDATERS } from '@faynosync/sdk-js'; // ['squirrel_darwin', 'squirrel_windows'] ``` `NativeFeedOptions` extends `CheckOptions` with a required `updater` field. For native feeds `owner`, `appName`, `version`, `platform`, and `arch` are all required; an unknown `updater` throws `UnsupportedUpdaterError`. ### resolveNativeFeed (recommended) `resolveNativeFeed` resolves the feed edge-first (falling back to the API) and tells you whether to invoke the native updater at all. When an edge response exists, the returned `feedURL` points at the CDN, so the native updater reads it directly and the API is never hit. ```ts const feed = await client.resolveNativeFeed({ owner: 'admin', appName: 'test', version: '0.0.1', channel: 'nightly', platform: 'darwin', arch: 'arm64', updater: 'squirrel_darwin', // or 'squirrel_windows' }); if (feed.updateAvailable) { autoUpdater.setFeedURL({ url: feed.feedURL }); autoUpdater.checkForUpdates(); } ``` It resolves to a typed `NativeFeedResult`: | Field | Type | Description | |-------|------|-------------| | `updateAvailable` | `boolean` | Whether to invoke the native updater. `false` lets you skip it entirely. | | `feedURL` | `string` | URL to pass to `setFeedURL`: the edge object when the edge served the feed, otherwise the API endpoint. | | `source` | `UpdateSource` | `'edge'` or `'api'`. | | `url` | `string` (optional) | The raw resolved resource (the `.zip` for darwin, the `RELEASES` URL for windows). | Why go through the SDK: Squirrel.Mac expects `200 { "url": "" }` or `204 No Content`, but the edge mirror returns `200 { "status": "no_content" }` (not `204`) when there is no update, and `404` until the API has warmed that version's edge object. The SDK reads the edge response, returns `updateAvailable: false` on `no_content` so you skip the native updater, and falls back to the API (which warms the edge) on a miss. Pointing the native updater straight at the edge URL yourself would break on those cases. `feedURL` differs by framework: - `squirrel_darwin` reads the JSON feed directly, so `feedURL` is the edge object (or the API `/checkVersion` URL on a miss). - `squirrel_windows` reads `feedURL/RELEASES`. The SDK resolves the edge redirect response, strips the trailing `/RELEASES`, and returns the **directory** as `feedURL` — pointing Squirrel.Windows at the CDN where `RELEASES` and the `.nupkg` live. On an edge miss it falls back to the API `/update/...` base (which serves `RELEASES` and warms the edge). ### buildNativeFeedURL (low-level) Builds just the feed URL — no request, no edge fallback — when you want to wire the native updater yourself: ```ts const url = client.buildNativeFeedURL({ owner: 'admin', appName: 'test', version: '0.0.1', channel: 'nightly', platform: 'darwin', arch: 'arm64', updater: 'squirrel_darwin', }); ``` | Updater | Resulting URL | |---------|---------------| | `squirrel_darwin` | `GET {baseURL}/checkVersion?...&updater=squirrel_darwin` | | `squirrel_windows` | `{baseURL}/update/{owner}/{app}/{channel}/{platform}/{arch}/{version}` (Squirrel.Windows appends `/RELEASES` itself) | ## Reports `reportEvent` posts a failure or diagnostic report to `POST /reports/ingest`. The client stays stateless and app-agnostic, so `reportKey` and `deviceId` are passed per call (like `checkForUpdates`), not stored in `Config`. ```ts const resp = await client.reportEvent({ reportKey: 'rpk_...', // sent as Authorization: Bearer deviceId: 'device-1', // required, sent as X-Device-ID appName: 'test', version: '0.0.0.5', channel: 'nightly', platform: 'darwin', arch: 'arm64', event: { type: 'crash', reason: 'segfault.signal-11' }, details: { stack: '...', exitCode: 11 }, // optional raw debug object }); console.log(resp.status, resp.groupHash, resp.storedDetails); ``` ### ReportOptions | Field | Type | Required | Description | |-------|------|----------|-------------| | `reportKey` | `string` | Yes | Report ingestion key, sent as `Authorization: Bearer`. | | `deviceId` | `string` | Yes | Device identifier, sent as `X-Device-ID`. | | `appName` | `string` | Yes | Application name. | | `version` | `string` | Yes | Client version. | | `channel` | `string` | Yes | Release channel. | | `platform` | `string` | Yes | Platform value. | | `arch` | `string` | Yes | Architecture value. | | `event` | `ReportEvent` | Yes | `{ type, reason }` describing the event. | | `details` | `Record` | No | Optional raw debug object. | `event.type` must be one of `crash`, `startup_failure`, `update_failure`, `install_failure`, or `rollback_failure`. `event.reason` must match `^[a-zA-Z0-9._-]{1,128}$`. All non-`details` fields are validated client-side. `details` is optional. When supplied, the SDK serializes it with `JSON.stringify`, gzip-compresses it, and base64-encodes the result. The request carries it as: ```json { "details": { "encoding": "gzip+base64", "content_type": "application/json", "payload": "" } } ``` When `details` is omitted, the field is left out of the request entirely. A successful request returns HTTP `202` mapped to a typed `ReportResponse`: | Field | Type | Description | |-------|------|-------------| | `status` | `string` | Server status string (e.g. `accepted`). | | `groupHash` | `string` | Grouping hash the server assigned to the report. | | `storedDetails` | `boolean` | Whether the server stored the `details` payload. | Any non-`202` response rejects with an `EndpointError` whose `source` is `'report'` and `statusCode` holds the HTTP status (e.g. `401`, `403`, `429`). Network and JSON failures are wrapped in `EndpointError` as well. An optional `AbortSignal` can be passed as the second argument to cancel the request. ## Platform, Channel, and Architecture Values faynoSync supports custom values for platform, channel, and architecture. The SDK never normalizes or remaps them. Examples of values that are **not** automatically transformed: - `macos` to `darwin` - `osx` to `darwin` - `stable` to `default` Whatever you set in `channel`, `platform`, and `arch` is sent as-is. ## Optional System Helpers The SDK provides optional helpers that return the current Node.js runtime values: ```ts import { systemPlatform, systemArch } from '@faynosync/sdk-js'; const platform = systemPlatform(); // process.platform — e.g. 'darwin', 'linux', 'win32' const arch = systemArch(); // process.arch — e.g. 'arm64', 'x64' ``` These helpers are never called automatically. Use them only when Node.js runtime values match your faynoSync configuration. ## Error Handling The SDK exports a typed error hierarchy: ```text FaynoSyncError ├── ValidationError // missing/invalid configuration or options │ └── UnsupportedUpdaterError // unknown native updater value └── RequestFailedError ├── EndpointError // a single edge, API, or report request failed └── CheckError // the overall check failed (wraps edge/api errors) ``` `UnsupportedUpdaterError` is thrown by `buildNativeFeedURL` / `resolveNativeFeed` for an unknown `updater`; narrow it with `instanceof` and read its `updater` field. Validation errors are exported as singleton instances — compare them with `===`. Request failures are class instances — narrow them with `instanceof`. ```ts import { Client, CheckError, EndpointError, ErrMissingBaseURL, ErrMissingOwner, ErrMissingAppName, ErrMissingVersion, } from '@faynosync/sdk-js'; try { const resp = await client.checkForUpdates(opts); } catch (err) { if (err === ErrMissingBaseURL) { // configure Client baseURL } else if (err === ErrMissingOwner) { // set opts.owner } else if (err === ErrMissingAppName) { // set opts.appName } else if (err === ErrMissingVersion) { // set opts.version } else if (err instanceof CheckError) { // every edge/api attempt failed console.error('edge error:', err.edgeError?.message); console.error('api error:', err.apiError?.message); if (err.apiError instanceof EndpointError) { console.error('url:', err.apiError.endpointUrl); console.error('status:', err.apiError.statusCode); } } } ``` A failed update check always rejects with a `CheckError`. Its `edgeError` and `apiError` fields hold the underlying `EndpointError` for each attempt (the edge error is absent when `edgeURL` is not configured). Each `EndpointError` carries the `source`, `endpointUrl`, optional `statusCode`, and a `cause` for transport-level failures. ### Validation Error Reference | Error | Trigger | |-------|---------| | `ErrMissingBaseURL` | `baseURL` is empty. | | `ErrInvalidBaseURL` | `baseURL` is not a valid absolute URL. | | `ErrInvalidEdgeURL` | `edgeURL` is set but is not a valid absolute URL. | | `ErrMissingOwner` | `opts.owner` is empty. | | `ErrMissingAppName` | `opts.appName` is empty. | | `ErrMissingVersion` | `opts.version` is empty. | | `ErrMissingChannel` | `opts.channel` is empty (native feeds and reports). | | `ErrMissingPlatform` | `opts.platform` is empty (native feeds and reports). | | `ErrMissingArch` | `opts.arch` is empty (native feeds and reports). | | `ErrMissingReportKey` | `reportKey` is empty. | | `ErrMissingDeviceId` | `deviceId` is empty (reports). | | `ErrInvalidEventType` | `event.type` is not a supported report event type. | | `ErrInvalidReason` | `event.reason` does not match `^[a-zA-Z0-9._-]{1,128}$`. | ## Examples Runnable examples live in the [faynosync-sdk-js](https://github.com/ku9nov/faynosync-sdk-js) repository: | Example | Description | Source | |---------|-------------|--------| | Basic | Minimal update check using runtime platform/arch detection | [examples/basic](https://github.com/ku9nov/faynosync-sdk-js/blob/main/examples/basic/index.ts) | | Edge fallback | `edgeURL` configured with `deviceId` telemetry | [examples/edge-fallback](https://github.com/ku9nov/faynosync-sdk-js/blob/main/examples/edge-fallback/index.ts) | | Custom fetch | Custom `fetch` function and `timeoutMs` | [examples/custom-fetch](https://github.com/ku9nov/faynosync-sdk-js/blob/main/examples/custom-fetch/index.ts) | Run any example with: ```sh npx ts-node examples/basic/index.ts ``` For full desktop integrations wiring this SDK to real native updaters, see the [Example Apps](../../examples) — Electron, Squirrel, and Tauri reference implementations. ## Security Scope This SDK version performs transport requests — update checks, native updater feed resolution, and failure/diagnostic report ingestion — with typed response decoding only. It does not verify TUF metadata, signatures, thresholds, expiration, rollback protection, or cache safety. Applications that need secure update metadata verification must perform that verification in the appropriate faynoSync component or a future SDK layer that explicitly implements it. No signature, threshold, expiration, rollback, freeze, root-of-trust, or cache protection is weakened by this transport-only SDK. --- ## CLI # faynoSync CLI `faynoSync-cli` is a command line tool focused on uploading new application versions to faynoSync in a native and predictable way. The main problem it solves is **inconsistent shell escaping in some CI environments**. Instead of relying on CI-specific quoting behavior, the CLI provides a unified upload flow and stable changelog input modes, so the same command behaves the same way locally and across CI runners. ## Why use the CLI? | Benefit | What it means in practice | |---------|---------------------------| | **Predictable uploads** | A single, unified `upload` command instead of hand-crafted `curl` calls that break on CI-specific quoting. | | **Stable changelog input** | Three mutually exclusive modes (`--changelog`, `--changelog-file`, `--changelog-stdin`) so Markdown and special characters survive shell escaping. | | **Config + env split** | `server` and `owner` live in a config file and can be overridden per-run by environment variables, while the token stays in the environment only. | | **CI friendly** | Designed to drop into pipelines: token from environment, server/account overridable per job. | ## Install and configure Build the binary and initialize a config: ```bash go build -o faynosync-cli . ./faynosync-cli --log-level info init ``` `init` creates `~/.faynosync/config.yaml` and prompts for `server`, `owner`, and `tuf`: ```yaml server: https://example.com owner: example tuf: false ``` View or update config fields: ```bash faynosync config view faynosync config set server https://updates.example.com faynosync config set owner myteam ``` If a value is omitted, `config set` prompts for it. ### Runtime settings priority - `FAYNOSYNC_TOKEN` is **required** and loaded only from the environment. It is sent as `Authorization: Bearer `. - `server` is loaded from config and can be overridden by `FAYNOSYNC_URL`. - `owner` is loaded from config and can be overridden by `FAYNOSYNC_ACCOUNT`. ## Commands Global flag: - `--log-level ` where level is `trace|debug|info|warn|error|fatal|panic` (default: `info`) | Command | Purpose | |---------|---------| | `faynosync init` | Create `~/.faynosync/config.yaml` and prompt for `server`, `owner`, `tuf`. | | `faynosync config view` | Print the current config. | | `faynosync config set [value]` | Update a config field (prompts if value is omitted). | | `faynosync upload [flags]` | Upload one or more files to `/upload` as `multipart/form-data`. | ### `upload` flags - `--app ` - `--file ` (repeatable, at least one required) - `--version ` - `--channel ` - `--platform ` - `--arch ` - `--updater ` — `manual|velopack|squirrel_darwin|squirrel_windows|electron-builder|tauri|sparkle` (validated locally) - `--signature ` — Tauri base64 signature - `--publish[=true|false]` - `--critical[=true|false]` - `--intermediate[=true|false]` - `--changelog ` - `--changelog-file ` - `--changelog-stdin` :::warning Changelog modes are mutually exclusive Use only one of `--changelog`, `--changelog-file`, or `--changelog-stdin`. For Markdown with special symbols, prefer `--changelog-file` or `--changelog-stdin`. ::: ### Uploading for a specific updater The `--updater` flag selects the [updater type](./updaters/index.mdx) the upload targets, so artifacts land in the correct isolated folder and the server generates the right feed files (`RELEASES`, `*.yml`, `releases.{channel}.json`, `appcast.{channel}.xml`). The CLI validates only that the value is a known updater type — all other rules are enforced server-side. When omitted, the platform's default updater is used. The `--signature` flag passes the base64 signature required by the [Tauri updater](./updaters/tauri.mdx). Read it straight from the `.sig` file Tauri produces next to the artifact: ```bash faynosync upload \ --app myapp \ --file ./myapp.app.tar.gz \ --version 1.0.0 \ --channel stable \ --platform darwin \ --arch amd64 \ --updater tauri \ --signature "$(cat ./myapp.app.tar.gz.sig)" \ --publish ``` ## Upload examples ```bash faynosync upload \ --app test \ --file ./test.apk \ --version 1.2.3 \ --channel stable \ --platform android \ --arch universal \ --publish \ --critical \ --intermediate \ --changelog "Bugfixes" # Multiple files in one upload faynosync upload --file ./test.rpm --file ./test.deb --app myapp --publish=true # Changelog from a file faynosync upload --file ./test.rpm --app myapp --changelog-file ./CHANGELOG.md # Changelog piped via stdin cat ./CHANGELOG.md | faynosync upload --file ./test.rpm --app myapp --changelog-stdin ``` For changelogs with shell-sensitive characters, use a quoted heredoc delimiter (`'EOF'`) so the shell does not attempt parameter expansion before the here-doc is formed: ```bash faynosync upload \ --app=cli \ --file=./faynoSync-cli \ --version=0.0.0.1 \ --channel=nightly \ --platform=linux \ --arch=amd64 \ --publish \ --critical \ --intermediate \ --changelog-stdin <<'EOF' # Changes - fixed ! bug - added ${feature} EOF ``` ## CI / GitHub Actions The CLI ships as a composite GitHub Action, published on the [GitHub Marketplace](https://github.com/marketplace/actions/faynosync-upload). It downloads the prebuilt CLI binary matching the runner OS/arch, then runs `faynosync upload` — so **no Go toolchain is needed on the runner**. ```yaml - name: Upload to faynoSync uses: ku9nov/faynoSync-cli@v1 with: app: myapp file: | ./dist/myapp.deb ./dist/myapp.rpm version: 1.2.3 channel: stable platform: linux arch: amd64 publish: true changelog-file: ./CHANGELOG.md env: FAYNOSYNC_TOKEN: ${{ secrets.FAYNOSYNC_TOKEN }} FAYNOSYNC_URL: ${{ secrets.FAYNOSYNC_URL }} FAYNOSYNC_ACCOUNT: ${{ secrets.FAYNOSYNC_ACCOUNT }} ``` Each input maps to the matching `upload` flag: | Input | Maps to | Notes | |-------|---------|-------| | `app` | `--app` | Required. | | `file` | `--file` | One path per line — each becomes a separate `--file`. | | `version` | `--version` | | | `channel` | `--channel` | | | `platform` / `arch` | `--platform` / `--arch` | | | `updater` | `--updater` | See [updater types](./updaters/index.mdx). | | `signature` | `--signature` | Tauri base64 signature. | | `publish` / `critical` / `intermediate` | `--publish` / `--critical` / `--intermediate` | Add the flag when `true`. | | `changelog` / `changelog-file` | `--changelog` / `--changelog-file` | Mutually exclusive. | | `log-level` | `--log-level` | Default `info`. | | `cli-version` | — | Which CLI release to download; defaults to the tag the action is pinned to. | Auth comes from the environment: `FAYNOSYNC_TOKEN` is required, and `FAYNOSYNC_URL` / `FAYNOSYNC_ACCOUNT` override the `server` / `owner` a config file would supply. Keep all three in `secrets`. :::tip Pin to a floating major Use `@v1` to receive patches automatically, or an exact release like `@v1.0.0` to pin. A full example workflow lives in [`examples/github-actions`](https://github.com/ku9nov/faynoSync-cli/tree/main/examples/github-actions). ::: ## CI / Jenkins For Jenkins there is a ready-to-use Shared Library step, [`faynosyncUpload`](https://github.com/ku9nov/faynoSync-cli/tree/main/examples/jenkins), that wraps `faynosync upload`. It writes the changelog to a file (avoiding CI shell-escaping issues), runs the upload under a credentials binding, and marks the build `unstable` if the response does not contain `Upload completed`. ```groovy @Library('faynosync-shared') _ faynosyncUpload( app: 'myapp', version: "1.2.3.${env.BUILD_NUMBER}", channel: 'stable', file: './dist/myapp', // String or List of paths platform: 'linux', arch: 'amd64', changelog: "Branch: ${env.BRANCH_NAME}", credentialsId: 'faynosync-token-myapp', publish: true, ) ``` The step reads the token from a Jenkins *Secret text* credential (via `credentialsId`) and exposes it to the CLI as `FAYNOSYNC_TOKEN`. Setup instructions and the full parameter list are in the [Jenkins example](https://github.com/ku9nov/faynoSync-cli/tree/main/examples/jenkins). --- ## Edge and S3 Response Cache faynoSync can serve repeated version checks from object storage at the edge instead of hitting the API for every client. The design keeps the existing `/checkVersion` contract intact while moving the hot read path to **SDK-driven, edge-first delivery** backed by **S3 (or S3-compatible) response manifests**. This page describes how that data plane works, how it relates to [Performance Mode](./performance) and [Telemetry](./telemetry), and why an [official SDK](./sdk/intro) is part of the story. :::tip Recommended for production For any internet-facing instance, enable edge mode for public apps: set `S3_BUCKET_NAME_CDN`, turn on `cdn` for each public app, and set `EdgeURL` in the SDK. Steady-state update checks then never reach the API. See [Production Deployment](./getting-started/production-deploy#recommended-serve-update-checks-from-the-edge) for the checklist. ::: ## Why this exists Even a well-tuned API eventually faces limits: connection churn, validation work, and database pressure scale with every client check. Pushing that load into clustering, replication, and heavier ops is not always what you want for an update server. Edge mode changes the economics of the hot path. After warm-up, origin load is driven by **distinct cache keys** (version, channel, platform, architecture, and related dimensions), not by raw client count. One hundred thousand clients on five client versions should produce on the order of **five** origin calls per dimension set after caches are warm—not one hundred thousand API round trips. In practice, keys multiply across your matrix: ``` N ≈ client_versions × channel × platform × arch × updater × package ``` Example: five client versions across five dimension combinations is on the order of **twenty-five** origin calls to build coverage—not one hundred thousand direct checks. ## Two supported modes faynoSync deliberately supports both paths. You do not have to migrate every client at once. | Mode | How clients check | Best for | |------|-------------------|----------| | **Dynamic API** | `GET /checkVersion` on the API origin | Existing integrations, scripts, and tools that call the API directly | | **Edge + S3 cache** | Official SDK with `EdgeURL` → cached manifests on object storage | High-volume desktop, server, and auto-update clients that can use SDK edge-first behavior | Dynamic API behavior is unchanged. Edge mode adds a parallel, cache-friendly read path without forking the core update API. ## How edge mode fits together ### What gets enabled Edge response caching is **per application**, via the `cdn` flag when you create or update an app (see [Create Application](./api/apps/create) and [Update application](./api/apps/update-app)). Private apps cannot enable `cdn`: manifests are public objects and would expose a private app's versions and links. The API rejects the combination with `400`. Supporting infrastructure: - A **public** object storage bucket for response manifests (metadata only—binaries stay on the existing artifact flow). Configure `S3_BUCKET_NAME_CDN` as described in [Environment Variables Overview](./getting-started/env-overview). - An **official faynoSync SDK** configured with `BaseURL` (API origin) and optionally `EdgeURL` (edge/CDN base). Edge-first orchestration, fallback, cache fill, and telemetry integration are built for SDK clients—not for ad-hoc `/checkVersion` callers alone. See the [Go SDK](./sdk/go/sdk), [JavaScript SDK](./sdk/js/sdk) and [SDK overview](./sdk/intro). Edge caching is **independent** from `PERFORMANCE_MODE`. Performance Mode uses Redis at the API layer; edge mode stores full JSON responses in object storage. You can run one, the other, or both. ### Request flow (conceptual) ```mermaid SDK Client (`BaseURL` + optional `EdgeURL`) ↓ If `EdgeURL` is set, SDK checks edge/S3 first ↓ ├─ HIT → return cached response to client └─ MISS → call Origin API (`/checkVersion`) via `BaseURL` ↓ API returns normal response ↓ API writes the response to the S3 cache path in the background ↓ Future checks → edge/S3 HIT ``` When `EdgeURL` is set, the SDK asks the edge path first. - **HIT** — A manifest object exists for the request dimensions. The SDK returns the cached JSON body to the application. - **MISS** — No manifest (or not valid). The SDK calls `/checkVersion` on `BaseURL` and returns that response. The API writes the same response to `S3_BUCKET_NAME_CDN` in the background, so later checks become HITs. Clients never need write access to the bucket. The API remains the source of truth. Edge storage is a derived, disposable cache of responses the API already knows how to produce. ### Manifest layout and content Each cached file is the **full faynoSync JSON response** for that client context—the same fields the Dynamic API would return (`update_available`, package-specific `update_url_*` keys, `changelog`, `critical`, `is_intermediate_required`, `possible_rollback`, and so on). Objects are stored at: ```text /responses/{owner}/{app_name}/{channel}/{platform}/{arch}/{updater}/{client_version}.json ``` Empty dimensions are left out of the path. The official SDKs check with `updater=manual`, so their manifests live under `.../{arch}/manual/{client_version}.json`. There is intentionally **no `latest.json` on the CDN**. Version comparison stays on the server (or in precomputed per–client-version manifests). A single “latest” object at the edge would push comparison logic to clients and break faynoSync’s model of server-side update decisions. Cached objects use standard cache headers (for example, `Cache-Control: public, max-age=60, must-revalidate`) so CDNs and browsers can revalidate frequently while still serving hot paths cheaply. ### Publish, unpublish, and cache invalidation When you upload, publish, unpublish or delete **any** version for an app, faynoSync **deletes response manifests** for that application’s scope on object storage. A manifest write that was already in flight when the invalidation started is dropped, so a stale response cannot reappear after the sweep. The next checks for affected dimensions are MISSes; the SDK falls back to `/checkVersion`, the API repopulates S3, and subsequent traffic HITs again. The manifest is written in the background after the API has answered, so a request arriving within the next fraction of a second may still MISS. Deleting a manifest removes it from the bucket, not from caches in front of it. A CDN, or Google Cloud Storage's built-in cache for public objects, can keep serving a deleted or overwritten manifest until its `max-age` (60 seconds) runs out. Plan for up to a minute between a publish and every client seeing it. You do not need a separate warm-up job for every new channel or architecture combination—rare keys fill naturally through the fallback path the first time something asks for them. ### Updater-specific behavior For default JSON update checks, the cached body is the ordinary faynoSync response. “No update” responses are cached under the same key rules as “update available” responses. Updater-specific contracts (for example Electron Builder, Tauri, or Squirrel on Windows) still receive the payloads the API already produces; only the **lookup path** changes from “always origin” to “edge first, origin on MISS.” The [JavaScript SDK](./sdk/js/sdk) maps cached JSON to updater-facing HTTP semantics (such as `204 No Content` or redirect-style responses) where those integrations require it. ## Performance Mode vs edge cache | Aspect | [Performance Mode](./performance) | Edge + S3 cache | |--------|-------------------------------------|-----------------| | Where it caches | Redis at the API | Object storage manifests at the edge | | Who benefits | Any client calling `/checkVersion` | SDK clients with `EdgeURL` and app `cdn` enabled | | Cache key | API request signature | Per-dimension manifest path | | Invalidation | Redis TTL / cache policies | Publish/unpublish clears app manifest scope | | Coupling | `PERFORMANCE_MODE=true` | App `cdn` + SDK + `S3_BUCKET_NAME_CDN` | Use Performance Mode to shave latency and DB load on the origin API. Use edge mode when check volume is dominated by many clients repeating the same dimension keys and you want the origin to see MISS traffic only, not every poll. ## Telemetry in edge flows Today, much telemetry is collected inside `/checkVersion` when clients send `X-Device-ID`. In edge mode, many checks never reach that endpoint, so statistics would silently drop if nothing changed. ### Before and after **Classic path (API-only clients):** ```text Client → (optional X-Device-ID) telemetry inline → /checkVersion ``` **Edge path (SDK clients):** ```text Client → edge manifest HIT (no /checkVersion) → (optional X-Device-ID) → /telemetry/beacon on API ``` SDK-integrated clients that skip `/checkVersion` should use [`/telemetry/beacon`](./api/info/telemetry_beacon) so device and dimension stats stay accurate. If a client still calls `/checkVersion` on every check, it does not need a separate beacon call. See [Telemetry System](./telemetry) for dashboard behavior, retention, and `ENABLE_TELEMETRY`. ### How the beacon stays fast `/checkVersion` still performs meaningful validation even when Performance Mode is on. The beacon endpoint is optimized for volume: it avoids per-request database lookups against full `apps_meta` documents. Instead, the API maintains a compact **in-memory allow-list index** built from **names only** when metadata changes: - owners - applications - channels - platforms - architectures - versions (for index structure—not used to reject reported client versions) Changelog, artifacts, and other heavy fields stay out of memory. On each relevant change (app created, channel added, version published, and so on), the service rebuilds the index from a projection and **atomically swaps** the in-memory pointer handlers use. **Beacon validation** checks that `(owner, app, channel, platform, arch)` exists in the index. Reported **client version is recorded but not required** to match the allow-list—clients may still report a version removed in the admin UI. Invalid dimension combinations are rejected cheaply (success responses remain lightweight, typically `204 No Content`). Team-user scenarios are out of scope for this public beacon path. Counters still flow to Redis the same way as today’s telemetry pipeline, so dashboards remain meaningful under edge load. Multi-instance deployments can add periodic index reload later; single-node setups rely on rebuild-on-change. ### Internal benchmarks (local development) These numbers were measured on a **local Mac** during development. Production servers will report different absolute values depending on hardware, network, Redis, and deployment size. The comparison still reflects the **design goal**: validate dimensions from an in-memory snapshot instead of hitting the database on every beacon. **What was measured:** elapsed time from handler start until the statistics persistence step is invoked (average over many requests). | Approach | Average time to persistence | |----------|----------------------------| | Previous path (DB-backed validation per request) | ~20.8 ms | | Beacon path (in-memory allow-list snapshot) | ~0.15 ms | **Load test:** `wrk` with 4 threads, 100 connections, 30 seconds, same local machine. | Approach | Requests/sec | Avg latency | |----------|--------------|-------------| | Previous path | ~359 | ~287 ms | | Beacon path | ~1,192 | ~88 ms | Under this setup, the beacon path handled roughly **3×** more requests per second with lower average latency. Treat these figures as order-of-magnitude guidance, not a SLA or capacity guarantee. ## Operational goals 1. **SDK-first** — No separate CDN application is required; clients implement edge-first behavior through the SDK. 2. **Per-app control** — Turn cache mode on or off per application with `cdn`. 3. **Stable cache keys** — Deterministic manifest paths per owner, app, channel, platform, arch, and client version. 4. **Safe lifecycle** — Publish and unpublish clear cached manifests for the app scope. 5. **Hot-path offload** — Edge serves repeated checks; API serves MISS, control plane, and fallback fill. ## Related documentation - [SDK overview](./sdk/intro) — Why SDKs exist and how edge fits the product model - [Production Deployment](./getting-started/production-deploy) — Recommended edge setup for internet-facing instances - [Go SDK](./sdk/go/sdk) — `EdgeURL`, fallback matrix, and `Source` semantics - [JavaScript SDK](./sdk/js/sdk) — `edgeURL`, fallback, and updater feed mapping - [Telemetry beacon API](./api/info/telemetry_beacon) — Endpoint contract for edge-aware clients - [Performance Mode](./performance) — Redis caching on the origin API - [Telemetry System](./telemetry) — Analytics, `ENABLE_TELEMETRY`, and dashboards - [Environment Variables Overview](./getting-started/env-overview) — `S3_BUCKET_NAME_CDN` and storage setup - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) — why edge caching changes the load economics. - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) — the Redis layer edge mode builds on. --- ## Performance Mode Performance Mode is an important feature that optimizes the performance of our API by reducing latency on frequently repeated requests. This mode uses Redis to cache the results of requests, allowing us to store responses for commonly used queries and avoid redundant database calls. ## Why Is This Needed Enabling Performance Mode allows us to: * Reduce database load: Using the cache helps avoid unnecessary queries to MongoDB. * Increase API response speed: Cached responses are returned significantly faster than executing queries against the database. * Optimize resource usage: Lowering the number of database queries can also reduce resource consumption and improve overall system performance. ## How Performance Mode Works * **Environment Variable Check:** Performance Mode is enabled if the `.env` file defines the variable `PERFORMANCE_MODE=true`. * **Response Caching:** When a request hits the API, it first checks Redis for cached data for that request. If data is found, it is returned to the client. * **Executing the Request:** If no cached data is found, a query is executed against the database. The result is stored in Redis for future use. ## Expected Behavior **Performance Mode Enabled:** - API requests check Redis for cached responses. - If cache exists, a quick response is returned from the cache. - If no cache exists, data is fetched from the database and cached for later use. **Performance Mode Disabled:** - The API always executes queries against the database without checking the cache. This may lead to increased response times, especially for frequently repeated requests. ## Advanced Performance Optimization with Nginx For even better performance, you can configure Nginx to provide additional caching layer for the `/checkVersion` endpoint. This is particularly useful because this endpoint performs database checks for channels, platforms, and architectures, which can create additional load. ### Nginx Configuration This is the same cache as in [Production Deployment](/docs/getting-started/production-deploy#8-nginx-and-tls), plus optional rate limiting. Add the following to the `http` block (files in `sites-enabled` or `conf.d` are already included inside it): ```nginx # Enables caching for the /checkVersion endpoint proxy_cache_path /var/cache/nginx-faynosync levels=1:2 keys_zone=checkversion_cache:10m inactive=60s max_size=100m; # Optional: enable rate limiting (used in /checkVersion block below) limit_req_zone $binary_remote_addr zone=req_limit:10m rate=10r/s; ``` And in your `server` block: ```nginx # Optimized location for version check with caching location = /checkVersion { proxy_pass http://127.0.0.1:9000; # Enable microcaching for 60 seconds proxy_cache checkversion_cache; proxy_cache_valid 200 60s; proxy_cache_key "$scheme$request_method$host$request_uri"; # Use stale cache if backend is slow or returns error proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; # Lock to prevent multiple upstream requests for the same key proxy_cache_lock on; proxy_cache_lock_timeout 65s; proxy_cache_background_update on; # Add custom header to inspect cache hits/misses add_header X-Cache-Status $upstream_cache_status; # Optional: enable rate limiting to prevent abuse # Uncomment if needed # limit_req zone=req_limit burst=20 nodelay; # Standard proxy headers proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Timeouts proxy_connect_timeout 60; proxy_send_timeout 60; proxy_read_timeout 60; send_timeout 60; } ``` ### Benefits of Nginx Caching * **Reduced Database Load:** Nginx cache prevents requests from reaching the backend for frequently accessed data * **Faster Response Times:** Cached responses are served directly by Nginx without backend processing * **Better Resource Utilization:** Reduces the number of database queries for channel, platform, and architecture validation * **Graceful Degradation:** Uses stale cache if the backend is slow or returns errors ### Cache Monitoring You can monitor cache performance by checking the `X-Cache-Status` header in responses: - `HIT`: Response served from Nginx cache - `MISS`: Response fetched from backend - `UPDATING`: Cache is being updated in background - `STALE`: Stale cache used due to backend issues ## Checking Performance You can test the performance of the API using the following command: ```bash start_time=$(date +%s); for i in {1..1000}; do response=$(curl -s -w "Connect Time: %{time_connect}s, Start Transfer Time: %{time_starttransfer}s, Total Time: %{time_total}s\n" -o /dev/stdout --location 'http://localhost:9000/checkVersion?app_name=myapp&version=0.0.3&channel=nightly&platform=null&arch=null'); echo "$response"; done; end_time=$(date +%s); total_time=$((end_time - start_time)); echo "Total execution time: $total_time seconds" ``` :::tip Note on Functionality Performance Mode caches the responses of [`/checkVersion`](./api/info/latest) and [`/apps/latest`](./api/info/fetch-latest-build). Responses of private apps are never cached. Cached entries expire after 24 hours. Before that, the cache of an app's channel is cleared whenever a version in that channel is uploaded or updated with an explicit `publish` value, or deleted — the next request caches the fresh answer. [`/health`](./api/info/health) pings MongoDB and Redis and reports `unhealthy` if either is unreachable, whether or not Performance Mode is enabled. ::: ## Conclusion Performance Mode is a vital tool for enhancing the speed and efficiency of our API. By caching the results of requests, we can reduce database load and provide faster data access for our users. It is recommended to activate Performance Mode in environments expecting high API load. For maximum performance, consider implementing both Redis caching (Performance Mode) and Nginx caching for a multi-layer caching strategy that provides optimal response times and resource utilization. ## Related reading - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) --- ## Presigned Uploads With a regular [`POST /upload`](/docs/api/apps/upload) every file passes through faynoSync and the reverse proxy in front of it. Sometimes the proxy gets in the way of large uploads — body size limits or timeouts end the request before faynoSync is done. If that is your case, use presigned uploads instead. Presigned uploads remove faynoSync from the data path. The uploader asks faynoSync for short-lived **presigned PUT URLs**, sends the bytes **directly to object storage**, and then tells faynoSync to finish the version. Only two small JSON/form requests go through the API; the artifacts never do. The flow needs nothing beyond `curl`, `jq` and `openssl`, so it drops into any CI system without installing a dedicated client. ## How it works ``` 1. uploader computes md5 of each file (plus sha256/sha512 for TUF apps) 2. uploader -> API POST /upload/init version metadata + file manifest (+ feed files inline) API validates everything, returns one presigned PUT URL per file 3. uploader -> storage PUT each file directly, with the headers returned by init storage rejects the upload if the bytes do not match the signed MD5 4. uploader -> API POST /upload/complete {upload_id} API checks size and MD5 reported by storage, moves the files to their final keys, creates the version ``` Everything that can reject an upload — authentication, [CI/CD token](/docs/api/tokens/tokens-introduction) scope, unknown app, channel, platform or architecture, [updater](/docs/updaters) file rules, [private app](/docs/api/apps/create) restrictions, an artifact that already exists for this version — is checked in **init**, before a single byte is transferred. You never upload 500 MB only to be told the version already exists. `complete` takes **only the `upload_id`**. The version metadata, file names and hashes are the ones recorded at init, so they cannot be changed between the two calls. The result is exactly what `POST /upload` would have produced: same storage layout, same links, same cache invalidation and updater feed regeneration, same Slack notification, same response. ## Feed files are sent inline Updater feed files are small, and faynoSync needs their **content** to validate the upload (for example, Sparkle checks that every archive has a matching `` in the appcast). They are therefore attached to the `init` request as regular `file` form parts, and faynoSync stores them itself. All other files go through presigned URLs. | Updater | Feed files (sent inline to init) | |---------|----------------------------------| | [`velopack`](/docs/updaters/velopack) | `releases.{channel}.json` | | [`sparkle`](/docs/updaters/sparkle) | `appcast*.xml` | | [`squirrel_windows`](/docs/updaters/squirrel-windows) | `RELEASES` | | [`electron-builder`](/docs/updaters/electron-builder) | `*.yml`, `*.yaml` | | `manual`, [`tauri`](/docs/updaters/tauri), [`squirrel_darwin`](/docs/updaters/squirrel-darwin), no updater | none | A feed file listed in the presigned manifest, or a non-feed file attached inline, is rejected with `400`. Each inline feed file may be at most 10 MiB. ## Complete example with curl The script below uploads a Velopack release: the `-full.nupkg` and the installer go directly to storage, `releases.stable.json` goes inline. For an app without an updater, drop the `updater` field and the inline `-F file=@...` part. ```bash #!/usr/bin/env bash set -euo pipefail API="https://faynosync.example.com" TOKEN="$FAYNOSYNC_TOKEN" FILES=(MyApp-1.4.0-full.nupkg MyApp-win-Setup.exe) FEED=releases.stable.json digest() { openssl "$1" -r "$2" | cut -d' ' -f1; } manifest=$(for f in "${FILES[@]}"; do jq -nc --arg n "$(basename "$f")" \ --arg md5 "$(digest md5 "$f")" \ --arg sha256 "$(digest sha256 "$f")" \ --arg sha512 "$(digest sha512 "$f")" \ --argjson length "$(wc -c < "$f" | tr -d ' ')" \ '{name:$n, md5:$md5, sha256:$sha256, sha512:$sha512, length:$length}' done | jq -sc .) data='{"app_name":"myapp","version":"1.4.0","channel":"stable","platform":"windows","arch":"amd64","updater":"velopack","publish":true,"changelog":"### Changelog\n\n- New feature"}' # 1. init: validation happens here, before any transfer init=$(curl -sS --fail-with-body -X POST "$API/upload/init" \ -H "Authorization: Bearer $TOKEN" \ --form-string "data=$data" \ --form-string "files=$manifest" \ -F "file=@$FEED") upload_id=$(jq -r .upload_id <<<"$init") # 2. PUT every file directly to storage with the headers returned by init for f in "${FILES[@]}"; do name=$(basename "$f") url=$(jq -r --arg n "$name" '.files[] | select(.name==$n) | .url' <<<"$init") headers=() while IFS= read -r h; do headers+=(-H "$h"); done < <( jq -r --arg n "$name" '.files[] | select(.name==$n) | .headers | to_entries[] | "\(.key): \(.value)"' <<<"$init") curl -sS --fail-with-body -X PUT "${headers[@]}" --upload-file "$f" "$url" > /dev/null done # 3. complete: verifies what storage received and creates the version curl -sS --fail-with-body -X POST "$API/upload/complete" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"upload_id\":\"$upload_id\"}" ``` Notes on the script: - Only `md5` is required in the manifest. `sha256`, `sha512` and `length` are optional — see [Upload Init](/docs/api/apps/upload-init#files-manifest). The script sends all of them, which is what a [TUF](/docs/api/tuf/publish)-enabled app needs. - Hash each file **once** and derive all digests from the same bytes. The three `openssl` calls above read the same unchanged file; do not rebuild or re-sign an artifact between hashing and uploading. - Send the returned headers **verbatim**. They are part of the signature; a missing `Content-MD5` or a different `Content-Length` makes storage reject the PUT. - The PUTs are independent and can run in parallel. Storage checks the URL expiry when a PUT **starts**, so a long transfer may finish after `expires_at`, but a file whose PUT starts later is rejected with `403`. If sequential uploads of large files over a slow link take longer than 30 minutes in total, run the PUTs in parallel or raise [`PRESIGNED_UPLOAD_URL_TTL`](/docs/getting-started/env-overview#presigned-uploads). - Use the same `data` JSON as for [`POST /upload`](/docs/api/apps/upload); every field it accepts works here too. Field-level reference: [Upload Init](/docs/api/apps/upload-init) and [Upload Complete](/docs/api/apps/upload-complete). ## Multiple platforms in one version One `init` uploads files for one platform and architecture, exactly like `POST /upload`. To ship a version for several platforms, run one `init`/`complete` per platform and architecture with the same `version` and `channel`. The first `complete` creates the version; the others add their artifacts to it. The jobs can run in parallel — concurrent completes of the same version end up in a single version, and no artifact is lost. - `publish`, `critical`, `changelog`, `rollout` and `intermediate` are taken from the upload that creates the version. Later uploads only add artifacts. A common pattern is to upload every platform with `"publish": false` and then publish once with [`POST /apps/update`](/docs/api/apps/update-specific-version-app), which takes no files. - The channel of a version cannot be changed. Adding artifacts to an existing version with a different `channel` is rejected by `init` with `409`. ## Storage provider differences faynoSync never trusts the numbers the uploader sends. What storage can confirm on its own differs between providers, so the verification is placed where each provider can support it. These results were measured against live buckets. | Provider | Tampered PUT | Wrong length | SHA-256 by storage | TUF verification cost | |----------|--------------|--------------|--------------------|-----------------------| | Garage | `400 InvalidDigest` | `403` | yes, on copy | none | | AWS S3 | `400 BadDigest` | `403` | yes, on copy | none | | DigitalOcean Spaces | `400 BadDigest` | `403` | no | one object read | | Google Cloud Storage | `400 BadDigest` | pinned by MD5 | no | one object read | - **Tampered PUT** — the uploaded bytes do not match the MD5 signed into the URL; storage refuses the upload. - **Wrong length** — applies when `length` was declared: it is signed as `Content-Length`, and a different body size fails the signature check. - **SHA-256 by storage** — whether storage computes the SHA-256 itself during the server-side copy in `complete`. DigitalOcean Spaces accepts the request but silently ignores it. - **TUF verification cost** — what [TUF publish](#tuf-hashes-are-verified-before-signing) pays to check uploader-supplied hashes: nothing when storage has the SHA-256, otherwise one internal read of the object through faynoSync. MinIO does not support presigned uploads in faynoSync; both endpoints return `501 Not Implemented` and you keep using [`POST /upload`](/docs/api/apps/upload). On GCS, presigned URLs are signed with `GCS_SERVICE_ACCOUNT_EMAIL` and `GCS_PRIVATE_KEY` (see [environment variables](/docs/getting-started/env-overview)), the same credentials used for private downloads. ### The upload endpoint must be reachable A presigned URL points at the **storage API endpoint**, not at faynoSync. The machine that runs the PUT must be able to reach it: for AWS and DigitalOcean that is the public regional endpoint; for a self-hosted Garage it is the S3 API address from `S3_API_ENDPOINT`, which is often an internal address in local setups. ## TUF: hashes are verified before signing With `POST /upload` faynoSync hashes the file itself, so the SHA-256/SHA-512 stored for an artifact always describe the stored bytes. With presigned uploads the hashes come from the uploader, and storage only confirms the MD5. The SHA-256 and SHA-512 are therefore marked **unverified** and checked against the stored bytes right before they are signed: - [`POST /tuf/v1/artifacts/publish`](/docs/api/tuf/publish) verifies every unverified artifact of the version before adding it to TUF targets. On Garage and AWS it reads the SHA-256 that storage computed; on DigitalOcean Spaces and GCS it streams the object once through faynoSync and hashes it (no temporary file; memory use does not grow with file size). - If any artifact does not match, **nothing in the version is signed**, and the [task](/docs/api/tuf/check_tasks) fails with the list of mismatching objects. - Once an artifact passes, it is marked verified, so a repeated publish does not re-read it. - An artifact uploaded **without** `sha256`/`sha512` has no hashes to sign, so TUF publish skips it and TUF clients cannot download it. That is the uploader's choice; for TUF apps, always send `sha256` (and `sha512`). - Feed files sent inline, and everything uploaded with `POST /upload`, are hashed by faynoSync and are never re-verified. Apps that do not use TUF never read these hashes and pay nothing for them. ## Staging, retries and cleanup Presigned URLs never point at a final artifact key. Files land under a staging prefix, `pending//`, and `complete` copies them to their final location server side — the bytes do not pass through faynoSync. A leaked or reused URL therefore cannot overwrite a published artifact that TUF metadata already references. | Item | Lifetime | |------|----------| | Presigned PUT URL | 30 minutes by default, set with [`PRESIGNED_UPLOAD_URL_TTL`](/docs/getting-started/env-overview#presigned-uploads) (`expires_at` in the init response) | | Pending upload record | URL lifetime + 90 minutes (2 hours by default), then deleted automatically | | Staged objects | deleted by `complete`; abandoned ones stay until your lifecycle rule removes them | If `complete` returns `422` because a file is missing or does not match, the upload stays open: re-run the PUT (while its URL is valid) and call `complete` again. Once `complete` succeeds, the same `upload_id` returns `404`, so a retried CI step can never create the version twice. ### Clean up abandoned uploads with a lifecycle rule faynoSync deletes staged objects when `complete` succeeds. It cannot delete what it never hears about again: an upload that was initialized and never completed, a `complete` that kept failing until the pending record expired, or a cleanup that failed after a successful `complete` (logged as `failed to delete staged objects`). Feed files are written to `pending/` already during `init`, so even an upload that never sent a single PUT can leave objects behind. Nothing inside faynoSync removes these objects. Configure a storage **lifecycle rule** that expires the `pending/` prefix in **both** buckets, `S3_BUCKET_NAME` and `S3_BUCKET_NAME_PRIVATE`. One day is a safe age with the default settings: a pending upload lives 2 hours, so the rule can never remove an upload that can still be completed. If you raise `PRESIGNED_UPLOAD_URL_TTL`, keep the rule age above that value plus 90 minutes, rounded up to whole days. Providers apply lifecycle rules asynchronously, so expect objects to disappear some time after they turn one day old, not at an exact moment. :::warning Existing lifecycle rules are replaced Every command below **replaces** the bucket's whole lifecycle configuration. Read the current configuration first, and if the bucket already has rules, add the `pending/` rule to them instead of applying it on its own. ::: #### AWS S3 and Garage Both accept the same rule through the S3 API. Garage requires the prefix inside `Filter` (the deprecated top-level `Prefix` in a rule is not supported), which also works on AWS. Save the rule as `pending-lifecycle.json`: ```json { "Rules": [ { "ID": "expire-faynosync-pending-uploads", "Filter": { "Prefix": "pending/" }, "Status": "Enabled", "Expiration": { "Days": 1 } } ] } ``` AWS S3: ```bash aws s3api get-bucket-lifecycle-configuration --bucket your-public-bucket aws s3api put-bucket-lifecycle-configuration --bucket your-public-bucket \ --lifecycle-configuration file://pending-lifecycle.json ``` Garage: the same commands with `--endpoint-url` pointing at the Garage S3 API (the `S3_API_ENDPOINT` of your instance) and a key that owns the bucket: ```bash aws s3api get-bucket-lifecycle-configuration --endpoint-url "$S3_API_ENDPOINT" --bucket your-public-bucket aws s3api put-bucket-lifecycle-configuration --endpoint-url "$S3_API_ENDPOINT" --bucket your-public-bucket \ --lifecycle-configuration file://pending-lifecycle.json ``` Repeat for the private bucket. #### DigitalOcean Spaces With [s3cmd](https://s3tools.org/s3cmd) configured for your Spaces region: ```bash s3cmd getlifecycle s3://your-public-space s3cmd expire --expiry-days=1 --expiry-prefix=pending/ s3://your-public-space ``` Repeat for the private Space. #### Google Cloud Storage GCS matches the prefix with the `matchesPrefix` condition. Save the rule as `pending-lifecycle.json`: ```json { "rule": [ { "action": { "type": "Delete" }, "condition": { "age": 1, "matchesPrefix": ["pending/"] } } ] } ``` ```bash gsutil lifecycle get gs://your-public-bucket gcloud storage buckets update gs://your-public-bucket --lifecycle-file=pending-lifecycle.json ``` Repeat for the private bucket. ## Private apps Presigned uploads work for [private apps](/docs/api/apps/create). Files are staged and stored in the private bucket, and artifact links point to [`/download`](/docs/api/apps/download) exactly as with a regular upload. The same updater restrictions apply: `velopack`, `sparkle`, `electron-builder` and `squirrel_windows` are rejected for private apps. If an app is switched between private and public while an upload is open, `complete` returns `409` and the upload has to be started again. ## Security notes - A presigned URL is a bearer credential for a single staging key until it expires. Do not print it in CI logs (avoid `curl -v` on the PUT step). - Storage accepts only bytes that match the MD5 in the signature, and `complete` re-reads size and MD5 from storage before anything is created. The artifact length is always the size reported by storage. - Only the user who called `init` can call `complete` for that `upload_id`, and a CI/CD token must still have access to the app. - Hashes supplied by the uploader are never signed into TUF targets without being checked against the stored bytes. ## Limits - One file per PUT, up to 5 GiB. - Inline feed files up to 10 MiB each. - Presigned uploads create versions or add artifacts through `init`/`complete`, the counterpart of [`POST /upload`](/docs/api/apps/upload). [`POST /apps/update`](/docs/api/apps/update-specific-version-app) still takes files as multipart only. --- ## Getting Started import GettingStartedCardList from '@site/src/components/GettingStartedCardList'; # Getting Started Welcome to FaynoSync! Follow these simple steps to get your environment up and running in minutes. Each section below will guide you through the essential setup and configuration for a smooth start. --- ## Environment Variables Overview To configure `FaynoSync`, you need to set the following environment variables. These settings ensure that your application communicates correctly with storage services, databases, and APIs. --- ## 📋 Quick Reference | Category | Variables | |----------|-----------| | **Core** | `API_URL`, `STORAGE_DRIVER`, `PORT` | | **Storage** | `S3_*`, `GCS_*`, `MINIO_*` (deprecated) | | **Database** | `MONGODB_URL`, `MONGODB_URL_TESTS` | | **Security** | `API_KEY`, `JWT_SECRET`, `TRUSTED_PROXIES` | | **Features** | `PERFORMANCE_MODE`, `ENABLE_TELEMETRY`, `MAX_BULK_DELETE_VERSIONS` | | **TUF** | `TUF_ENABLED`, `ONLINE_KEY_DIR` | | **Reports** | `REPORTS_ENABLED`, `REPORTS_*` | | **Redis** | `REDIS_*` | | **Slack** | `SLACK_*` | --- ## 🔧 Required Environment Variables ### Core Configuration
API_URL Public URL to this API. **Example:** `API_URL=https://faynosync.example.com`
ENABLE_PRIVATE_APP_DOWNLOADING Deprecated. Access to private apps is now set per app with `download_mode` (see [create app](../api/apps/create) and [download](../api/apps/download)). This variable only chooses the default mode: `true` gives new private apps `unlisted` (anyone with the link can download), `false` gives them `strict` (a [download token](../api/apps/download-tokens-regenerate) or a JWT with access is required, on update checks as well as downloads). On upgrade to v2.3.0, existing private apps get their `download_mode` from this value once. With `false`, their clients must start sending `X-Download-Token`, or the apps must be switched to `unlisted` through [update app](../api/apps/update-app). **Example:** `ENABLE_PRIVATE_APP_DOWNLOADING=false`
STORAGE_DRIVER Specifies the storage driver to use. **Available options:** - **minio**: Deprecated. Kept for backward compatibility with existing setups. - **aws**: For using `AWS S3` or S3-compatible providers such as local Garage. - **digitalocean**: For using DigitalOcean Spaces as your object storage solution. - **gcp**: For using Google Cloud Storage as your object storage solution. **Example:** `STORAGE_DRIVER=aws`
ALLOWED_CORS Comma-separated origins allowed to call the API from a browser. The dashboard served by the API at `/dashboard/` is same-origin and needs no entry; set this only for browser clients on another origin, such as a dashboard build hosted elsewhere or a browser extension. **Example:** `ALLOWED_CORS=https://dashboard.example.com`
DASHBOARD_ENABLED Serve the admin dashboard at `/dashboard/`. Default: `true`. Set to `false` to turn it off; `/` then no longer redirects to it. **Example:** `DASHBOARD_ENABLED=true`
DASHBOARD_TUF_METADATA_URL Optional. Pre-fills the TUF metadata URL in the dashboard's key generation form. The dashboard reads it from `/dashboard/config.json`. **Example:** `DASHBOARD_TUF_METADATA_URL=https://s3.example.com/tuf_metadata`
PORT The port on which the auto updater service will listen. By default, the service listens on port 9000. **Example:** `PORT=9000`
### Database Configuration
MONGODB_URL The MongoDB connection string used by `FaynoSync`. This string contains information such as the host, database name, and authentication details. **Example:** `MONGODB_URL=mongodb://root:password@127.0.0.1/faynosync_db?authSource=admin`
MONGODB_URL_TESTS The MongoDB connection string used by `FaynoSync` for tests running. This string contains information such as the host, database name, and authentication details. **Example:** `MONGODB_URL_TESTS=mongodb://root:password@127.0.0.1/faynosync_db_tests?authSource=admin`
### Security Configuration
API_KEY This key is used for authentication during sign-up and other API interactions. Generate this key using `openssl rand -base64 16`. If `API_KEY` is empty, the `/signup` endpoint is disabled. Remove it once your admin accounts exist. The repository's env files use the public placeholder `insecure-dev-api-key`. The API logs a warning when `API_KEY` starts with `insecure-dev-` and refuses to start with it when `GIN_MODE=release`. **Example:** `API_KEY=generated_api_key`
JWT_SECRET A secret key used for signing and verifying JWT (JSON Web Tokens) used by the application for secure communication. Generate this key using `openssl rand -base64 32`. The API refuses to start when `JWT_SECRET` is empty or shorter than 32 bytes. It also logs a warning when the value starts with `insecure-dev-` (the public placeholder in the repository's env files) and refuses to start with it when `GIN_MODE=release`. Changing the secret signs out all users. **Example:** `JWT_SECRET=your_jwt_secret`
TRUSTED_PROXIES Comma-separated list of IPs or CIDRs of reverse proxies and load balancers allowed to pass the client IP in `X-Forwarded-For` / `X-Real-IP`. The client IP is used by the per-IP rate limits on [`/login`](/docs/api/auth/login) and [`/signup`](/docs/api/auth/signup). When unset, every proxy is trusted, so any client can spoof its IP with an `X-Forwarded-For` header and bypass the per-IP limits (the per-username login limit still applies). List every proxy the request passes through, as faynoSync sees it. The client IP is the rightmost `X-Forwarded-For` entry that is not a trusted proxy. | Deployment | Value | |------------|-------| | API exposed directly, no proxy | `127.0.0.1` (any address no client connects from) | | nginx on the same host | `127.0.0.1,::1` | | Proxy in another Docker container | the Docker network subnet, e.g. `172.18.0.0/16` (container IPs change on recreate) | | Cloud load balancer (e.g. AWS ALB) | the VPC or load balancer subnet CIDR | | CDN → proxy → API | the proxy subnet plus the CDN's published IP ranges | If a proxy is missing from the list, every request appears to come from that proxy, all clients share one rate-limit bucket, and legitimate users get `429`. Check the client IP in the server access log after a login to verify the value. A proxy in front of faynoSync may also drop `X-Forwarded-For` it receives from an untrusted upstream (Traefik does this unless `forwardedHeaders.trustedIPs` is set), so the whole chain must forward the header. An invalid value, including a trailing comma, stops the server on startup. **Example:** `TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12`
### Performance & Features
PERFORMANCE_MODE This setting enables performance optimizations within the application when set to true. Use this for high-load environments where efficiency is a priority. **Example:** `PERFORMANCE_MODE=true`
ENABLE_TELEMETRY This setting enables statistics when set to true. When `false`, `/checkVersion` records no client telemetry and `/telemetry/beacon` returns `403`. **Example:** `ENABLE_TELEMETRY=true`
MAX_BULK_DELETE_VERSIONS Maximum number of version ids accepted by one [`DELETE /apps/delete`](/docs/api/apps/delete-specific-version-app) request. Defaults to `10` when unset or not a positive number. **Example:** `MAX_BULK_DELETE_VERSIONS=10`
### TUF Configuration
TUF_ENABLED Set to `true` to enable TUF (The Update Framework) functionality. This enables secure software update management with metadata signing, artifact publishing, and root key rotation capabilities. **Example:** `TUF_ENABLED=true`
ONLINE_KEY_DIR Directory path where online signing keys are stored. These keys are used for online metadata signing operations in TUF. **Example:** `ONLINE_KEY_DIR=/private_keys`
### Reports Configuration Report ingestion is disabled by default. When enabled, it exposes a public `POST /reports/ingest` endpoint (authenticated by per-app report keys) and the JWT-protected read API. Rate limits are kept in Redis, and detail blobs are stored in the private bucket (`S3_BUCKET_NAME_PRIVATE`) using the existing `STORAGE_DRIVER` / `S3_*` configuration. See [Reports Management](/docs/api/reports/reports-introduction) for the full API.
REPORTS_ENABLED Set to `true` to enable the report ingestion endpoint and the reports read API. When `false`, none of the report routes are registered. **Default:** `false` **Example:** `REPORTS_ENABLED=true`
REPORTS_MAX_BODY_BYTES Maximum accepted request body size for `POST /reports/ingest`, in bytes. Requests over this limit return `413 Payload Too Large`. **Default:** `262144` (256 KB) **Example:** `REPORTS_MAX_BODY_BYTES=262144`
REPORTS_MAX_DETAILS_COMPRESSED_BYTES Maximum size of the compressed (`gzip+base64`-decoded) details payload, in bytes. Larger payloads return `413`. **Default:** `131072` (128 KB) **Example:** `REPORTS_MAX_DETAILS_COMPRESSED_BYTES=131072`
REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES Maximum size of the details payload after gzip decompression, in bytes. Enforced with a limited reader (zip-bomb guard); the client-declared size is never trusted. Exceeding it returns `413`. **Default:** `1048576` (1 MB) **Example:** `REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES=1048576`
REPORTS_BLOB_RETENTION_DAYS How long detail blobs are retained. Each `report_blobs` document gets an `expiresAt` of `createdAt + REPORTS_BLOB_RETENTION_DAYS` and is removed by a TTL index. Changing this only affects future blobs. **Default:** `30` **Example:** `REPORTS_BLOB_RETENTION_DAYS=30`
REPORTS_MAX_BLOBS_PER_GROUP Maximum number of detail blobs kept per report group. When a new blob is stored beyond this limit, the oldest blobs (and their storage objects) are removed. **Default:** `10` **Example:** `REPORTS_MAX_BLOBS_PER_GROUP=10`
REPORTS_STORAGE_PREFIX Key prefix used for report detail blobs in the private bucket (for example `reports///...`). **Default:** `reports` **Example:** `REPORTS_STORAGE_PREFIX=reports`
REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE Per-report-key rate limit for ingestion, in requests per minute. Hitting any rate limit returns `429 Too Many Requests`. (Per-device and per-group limits are fixed and not configurable.) **Default:** `100` **Example:** `REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE=100`
### Redis Configuration Redis is required. The API connects on startup, regardless of which features are enabled, and exits if Redis is unreachable.
REDIS_HOST The hostname or IP address of the Redis server. If not specified, it defaults to `localhost`. **Example:** `REDIS_HOST=your_redis_host`
REDIS_PORT The port number on which the Redis server is listening. The default value is 6379. **Example:** `REDIS_PORT=6379`
REDIS_PASSWORD The password for authenticating with the Redis server. Leave this empty if no password is set for your Redis instance. **Example:** `REDIS_PASSWORD=your_redis_password`
REDIS_DB Specifies which Redis database number to use. The default is 0. **Example:** `REDIS_DB=0`
### Slack Integration
SLACK_ENABLE Specifies whether Slack integration is enabled. If `SLACK_ENABLE=false`, no messages will be sent to Slack. The default is `false`. **Example:** `SLACK_ENABLE=true`
SLACK_BOT_TOKEN The authentication token for the Slack bot used to access the API. It should be in the format xoxb-*. **Example:** `SLACK_BOT_TOKEN=xoxb-123456789012-1234567890123-abcdef123456`
SLACK_CHANNEL Specifies the name of the Slack channel where messages will be sent. **Example:** `SLACK_CHANNEL=test-channel`
SLACK_NOTIFICATION_TTL Configures how long Redis-backed Slack notification state is kept for a given app version. Slack notifications reuse a single mutable message per app version across artifact create, update, and delete flows. **Default:** `24h` **Example:** `SLACK_NOTIFICATION_TTL=24h`
--- ## 🗄️ Storage Configuration The storage client is created once per process and reused. Storage credentials (`S3_ACCESS_KEY` / `S3_SECRET_KEY`, `GCS_CREDENTIALS_FILE`) and `STORAGE_DRIVER` are read at that point, so restart faynoSync after rotating a key. On GCS, the bucket's uniform bucket-level access setting is also read once per bucket; restart after changing it. ### MinIO (Deprecated) MinIO is deprecated for new setups and is kept for backward compatibility only. For local development, use Garage with the `aws` storage driver and `S3_*` compatibility options from the AWS S3 section below. For legacy MinIO deployments only: | Variable | Description | Example | |----------|-------------|---------| | `STORAGE_DRIVER` | Set to `minio` for MinIO storage (deprecated). | `STORAGE_DRIVER=minio` | | `MINIO_SECURE` | Specifies whether to use secure connections (HTTPS) for MinIO. Set to `false` for local development. | `MINIO_SECURE=false` | | `S3_ACCESS_KEY` | Your MinIO access key ID. | `S3_ACCESS_KEY=your_minio_access_key` | | `S3_SECRET_KEY` | Your MinIO secret access key. | `S3_SECRET_KEY=your_minio_secret_key` | | `S3_BUCKET_NAME_PRIVATE` | The name of your MinIO bucket for private storage. | `S3_BUCKET_NAME_PRIVATE=cb-faynosync-s3-private` | | `S3_BUCKET_NAME` | The name of your MinIO bucket for public storage. | `S3_BUCKET_NAME=cb-faynosync-s3-public` | | `S3_BUCKET_NAME_CDN` | Bucket used for API response caching. You can reuse `S3_BUCKET_NAME` or use a dedicated bucket for flexible cache settings. | `S3_BUCKET_NAME_CDN=cb-faynosync-s3-public` | | `S3_ENDPOINT_PRIVATE` | The MinIO endpoint URL. | `S3_ENDPOINT_PRIVATE=localhost:9010` | | `S3_ENDPOINT` | The public MinIO endpoint URL. | `S3_ENDPOINT=localhost:9010` | ### AWS S3 (Amazon Simple Storage Service) For using AWS S3 as your object storage solution: | Variable | Description | Example | |----------|-------------|---------| | `STORAGE_DRIVER` | Set to `aws` for AWS S3 storage. | `STORAGE_DRIVER=aws` | | `S3_ACCESS_KEY` | Your AWS access key ID. | `S3_ACCESS_KEY=your_aws_access_key` | | `S3_SECRET_KEY` | Your AWS secret access key. | `S3_SECRET_KEY=your_aws_secret_key` | | `S3_REGION` | The AWS region in which your S3 bucket is located. | `S3_REGION=us-east-1` | | `S3_BUCKET_NAME_PRIVATE` | The name of your AWS S3 bucket for private storage. | `S3_BUCKET_NAME_PRIVATE=your-private-bucket` | | `S3_BUCKET_NAME` | The name of your AWS S3 bucket for public storage. | `S3_BUCKET_NAME=your-public-bucket` | | `S3_BUCKET_NAME_CDN` | Bucket used for API response caching. You can reuse `S3_BUCKET_NAME` or use a dedicated bucket for flexible cache settings. | `S3_BUCKET_NAME_CDN=your-public-bucket` | | `S3_ENDPOINT_PRIVATE` | The AWS S3 endpoint URL. | `S3_ENDPOINT_PRIVATE=https://your-bucket-name.s3.amazonaws.com` | | `S3_ENDPOINT` | The public AWS S3 endpoint URL. | `S3_ENDPOINT=https://your-public-bucket.s3.amazonaws.com` | | `S3_API_ENDPOINT` | Optional custom S3 API endpoint for S3-compatible providers such as local Garage. Leave empty for managed AWS S3. | `S3_API_ENDPOINT=http://localhost:3900` | | `S3_FORCE_PATH_STYLE` | Optional. Set to `true` for providers that require path-style requests, such as local Garage. | `S3_FORCE_PATH_STYLE=true` | | `S3_DISABLE_OBJECT_ACL` | Optional. Set to `true` for providers that do not support `public-read` object ACLs, such as local Garage. | `S3_DISABLE_OBJECT_ACL=true` | ### DigitalOcean Spaces (S3-compatible Storage from DigitalOcean) For using DigitalOcean Spaces as your object storage solution: | Variable | Description | Example | |----------|-------------|---------| | `STORAGE_DRIVER` | Set to `digitalocean` for DigitalOcean Spaces storage. | `STORAGE_DRIVER=digitalocean` | | `S3_ACCESS_KEY` | Your DigitalOcean Spaces access key. | `S3_ACCESS_KEY=DO00EXAMPLEKEY` | | `S3_SECRET_KEY` | Your DigitalOcean Spaces secret key. | `S3_SECRET_KEY=EXAMPLE_SECRET_KEY` | | `S3_REGION` | The DigitalOcean region where your Space is located. | `S3_REGION=nyc3` | | `S3_BUCKET_NAME_PRIVATE` | The name of your DigitalOcean Space for private storage. | `S3_BUCKET_NAME_PRIVATE=your-private-space` | | `S3_BUCKET_NAME` | The name of your DigitalOcean Space for public storage. | `S3_BUCKET_NAME=your-public-space` | | `S3_BUCKET_NAME_CDN` | Bucket used for API response caching. You can reuse `S3_BUCKET_NAME` or use a dedicated bucket for flexible cache settings. | `S3_BUCKET_NAME_CDN=your-public-space` | | `S3_ENDPOINT_PRIVATE` | The DigitalOcean Spaces endpoint URL. | `S3_ENDPOINT_PRIVATE=nyc3.digitaloceanspaces.com` | ### Google Cloud Storage (GCP) For using Google Cloud Storage as your object storage solution: | Variable | Description | Example | |----------|-------------|---------| | `STORAGE_DRIVER` | Set to `gcp` for Google Cloud Storage. | `STORAGE_DRIVER=gcp` | | `GCS_CREDENTIALS_FILE` | Path to Google Cloud service account credentials JSON file. | `GCS_CREDENTIALS_FILE=/path/to/credentials.json` | | `GCS_SERVICE_ACCOUNT_EMAIL` | GCS service account email for presigned URL generation. | `GCS_SERVICE_ACCOUNT_EMAIL=your-service-account@project.iam.gserviceaccount.com` | | `GCS_PRIVATE_KEY` | GCS private key for presigned URL generation. | `GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----\n` | | `S3_BUCKET_NAME_PRIVATE` | The name of your GCS bucket for private storage. | `S3_BUCKET_NAME_PRIVATE=your-private-gcs-bucket` | | `S3_BUCKET_NAME` | The name of your GCS bucket for public storage. | `S3_BUCKET_NAME=your-public-gcs-bucket` | | `S3_BUCKET_NAME_CDN` | Bucket used for API response caching. You can reuse `S3_BUCKET_NAME` or use a dedicated bucket for flexible cache settings. | `S3_BUCKET_NAME_CDN=your-public-gcs-bucket` | ### Presigned Uploads Applies to every storage driver that supports [presigned uploads](/docs/presigned-uploads) (AWS S3, DigitalOcean Spaces, Garage, GCS). | Variable | Description | Example | |----------|-------------|---------| | `PRESIGNED_UPLOAD_URL_TTL` | Lifetime of the presigned PUT URLs returned by [Upload Init](/docs/api/apps/upload-init), as a Go duration. Default `30m`, maximum `168h` (7 days, the signed URL limit of S3-compatible storage, applied to every driver). An invalid or out-of-range value falls back to `30m`. The pending upload record lives 90 minutes longer than the URLs. | `PRESIGNED_UPLOAD_URL_TTL=2h` | --- ## ⚙️ Setting Up Environment Variables You can set these environment variables in a `.env` file in the root directory of the application. Here's an example of what your `.env` file might look like: ```bash # ============================================================================= # MINIO (deprecated, kept for backward compatibility) # ============================================================================= # STORAGE_DRIVER=minio # MINIO_SECURE=false S3_BUCKET_NAME_PRIVATE=cb-faynosync-s3-private S3_ENDPOINT_PRIVATE=localhost:9010 S3_ACCESS_KEY= S3_SECRET_KEY= S3_BUCKET_NAME=cb-faynosync-s3-public S3_BUCKET_NAME_CDN=cb-faynosync-s3-public S3_ENDPOINT=localhost:9010 # ============================================================================= # AWS S3 (Amazon Simple Storage Service) # ============================================================================= STORAGE_DRIVER=aws #S3_ACCESS_KEY= #S3_SECRET_KEY= #S3_REGION= # S3_BUCKET_NAME_PRIVATE= # S3_BUCKET_NAME= # S3_BUCKET_NAME_CDN= # can be the same as S3_BUCKET_NAME, or a dedicated cache bucket #S3_ENDPOINT_PRIVATE=https://.s3.amazonaws.com # for example in us-east-1 #S3_ENDPOINT=https://.s3.amazonaws.com # for example in us-east-1 #S3_API_ENDPOINT=http://localhost:3900 # optional for S3-compatible providers (Garage, etc.) #S3_FORCE_PATH_STYLE=true # optional for providers that require path-style requests #S3_DISABLE_OBJECT_ACL=true # optional for providers without public-read ACL support # ============================================================================= # DIGITALOCEAN SPACES (S3-compatible storage from DigitalOcean) # ============================================================================= # STORAGE_DRIVER=digitalocean # S3_ENDPOINT_PRIVATE=nyc3.digitaloceanspaces.com # S3_REGION=nyc3 # S3_ACCESS_KEY=DO00EXAMPLEKEY # S3_SECRET_KEY=EXAMPLE_SECRET_KEY # S3_BUCKET_NAME_PRIVATE= # S3_BUCKET_NAME= # S3_BUCKET_NAME_CDN= # can be the same as S3_BUCKET_NAME, or a dedicated cache bucket # ============================================================================= # GOOGLE CLOUD STORAGE (GCP) # ============================================================================= # STORAGE_DRIVER=gcp # GCS_CREDENTIALS_FILE=/path/to/credentials.json # GCS_SERVICE_ACCOUNT_EMAIL=your-service-account@project.iam.gserviceaccount.com # GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----\n # S3_BUCKET_NAME_PRIVATE= # S3_BUCKET_NAME= # S3_BUCKET_NAME_CDN= # can be the same as S3_BUCKET_NAME, or a dedicated cache bucket # ============================================================================= # Other Configuration # ============================================================================= DASHBOARD_ENABLED=true PORT=9000 MONGODB_URL=mongodb://root:password@127.0.0.1/faynosync_db?authSource=admin API_KEY=generated_api_key API_URL=http://localhost:9000 ENABLE_PRIVATE_APP_DOWNLOADING=false JWT_SECRET=your_jwt_secret MONGODB_URL_TESTS=mongodb://root:password@127.0.0.1/faynosync_db_tests?authSource=admin PERFORMANCE_MODE=true REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 SLACK_ENABLE=false SLACK_BOT_TOKEN=xoxb-* SLACK_CHANNEL=test-channel SLACK_NOTIFICATION_TTL=24h ENABLE_TELEMETRY=true MAX_BULK_DELETE_VERSIONS=10 # ============================================================================= # TUF (The Update Framework) # ============================================================================= TUF_ENABLED=true ONLINE_KEY_DIR=/private_keys # ============================================================================= # Reports (client failure report ingestion) # ============================================================================= REPORTS_ENABLED=false REPORTS_MAX_BODY_BYTES=262144 REPORTS_MAX_DETAILS_COMPRESSED_BYTES=131072 REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES=1048576 REPORTS_BLOB_RETENTION_DAYS=30 REPORTS_MAX_BLOBS_PER_GROUP=10 REPORTS_STORAGE_PREFIX=reports REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE=100 ``` For local development, copy `.env.example` from the repository root: it has every variable with a short description and working local values. Ensure that your environment variables are properly set before running the application to avoid misconfigurations. --- ## Local Setup import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Local Setup This guide starts faynoSync on your machine so you can test application updates end to end: upload a build, publish it, and point your app's updater at it. No cloud account is needed — object storage is [Garage](https://garagehq.deuxfleurs.fr/), a lightweight S3-compatible server, preconfigured in the repository. There are two ways to run it, and only a few steps differ between them — those steps have a tab for each: - **Docker Compose**: the API, MongoDB, Redis and Garage all run as containers. Nothing to install except Docker. Choose this to try faynoSync or to test your app's updates against it. - **From source**: MongoDB, Redis and Garage run in Docker Compose, while the API and the dashboard run from source with `go run` and `yarn dev`. Choose this to work on faynoSync itself. If you already run MongoDB, Redis and S3-compatible storage locally, you can use them instead of the containers. Everything runs from the repository's own `docker-compose.yaml` and `.env.example`, so the configuration always matches the current release. To deploy faynoSync on a server, see [Production Deployment](./production-deploy). ## Requirements - [Docker Engine](https://docs.docker.com/engine/install/) with [Docker Compose](https://docs.docker.com/compose/install/) - Git - Free ports: `9000`, `27017`, `6379`, `3900`–`3904`, `3909` - [Docker Engine](https://docs.docker.com/engine/install/) with [Docker Compose](https://docs.docker.com/compose/install/) — for MongoDB, Redis and Garage - Git - [Go](https://go.dev/doc/install) 1.26 or newer - [Node.js](https://nodejs.org/) 20 or newer and [Yarn](https://classic.yarnpkg.com/en/docs/install) — for the dashboard - Free ports: `9000`, `3000`, `27017`, `6379`, `3900`–`3904`, `3909` ## 1. Clone the Repository ```bash git clone https://github.com/ku9nov/faynoSync.git cd faynoSync ``` ## 2. Start the Services ```bash docker compose up --build -d ``` The first start builds the API image from source and bootstraps Garage (layout, access key, buckets, public website access). It takes a few minutes; later starts are fast. The API starts once MongoDB, Redis and Garage report healthy. Start only the dependencies: ```bash docker compose up -d db cache s3 webui ``` Naming the services starts only them, so the `backend` container does not start and port `9000` stays free for the API you run from source. Garage creates its buckets and access key on first start — wait until every service reports `healthy`: ```bash docker compose ps ``` Then create the environment file: ```bash cp .env.example .env ``` `.env.example` already holds working values for these containers (`localhost` for MongoDB and Redis, `127.0.0.1:3900` for the Garage S3 API). The API reads `.env` from its working directory; environment variables take precedence. :::tip Using your own MongoDB, Redis and S3 storage If you already run these locally, skip the compose command and point `.env` at them: `MONGODB_URL` (and `MONGODB_URL_TESTS` for the tests), `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, and the storage variables — `STORAGE_DRIVER`, `S3_API_ENDPOINT`, the access keys and bucket names. Create a public and a private bucket yourself; objects in the public bucket must be publicly readable. faynoSync is tested with MongoDB 7.0 and 8.0 and Redis 8.10. Every variable is described in [Environment Configuration](./env-overview). ::: ## 3. Apply Database Migrations ```bash docker compose exec -T backend /usr/bin/faynoSync migrate up ``` ```bash go run faynoSync.go migrate up ``` `go run faynoSync.go migrate down` rolls them back. Run this again after pulling a newer version of the repository. ## 4. Start the API and Dashboard Nothing to do: the `backend` container is already running. The API is at http://localhost:9000 and serves the dashboard at http://localhost:9000/dashboard/. Start the API: ```bash go run faynoSync.go --loglevel=debug ``` It listens on http://localhost:9000. There is no hot reload for Go code: stop it and run the command again after a change. In a second terminal, start the dashboard: ```bash cd dashboard yarn install yarn dev ``` The dashboard runs on http://localhost:3000/dashboard/ with hot reload. The Vite dev server proxies every request outside `/dashboard/` to the API on port `9000`, so the dashboard and the API share one origin, as in production — no CORS configuration is needed. It also sends the same Content Security Policy as the API, so a resource blocked in production is blocked here too. The API also serves a dashboard at http://localhost:9000/dashboard/, but that one is embedded into the binary at compile time from `server/dashboard/ui/dist` — the output of the last `yarn build`, not your current sources. Without a build it returns `503`. To check the production build, run `yarn build` in `dashboard/` and restart the API. ## 5. Create the Admin Account Open the dashboard signup page — http://localhost:9000/dashboard/signup with Docker Compose, http://localhost:3000/dashboard/signup from source — and register. In the **Secret Key** field enter the `API_KEY` from `.env.example` — for the bundled configuration that is `insecure-dev-api-key`. Or do the same through the API: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "password", "api_key": "insecure-dev-api-key"}' \ http://localhost:9000/signup ``` ## 6. Check for an Update After you [create an app](/docs/api/apps/create) and [upload a version](/docs/api/apps/upload), any client can ask for updates: ```bash curl "http://localhost:9000/checkVersion?app_name=myapp&version=0.0.1&owner=admin" ``` Response formats are described in [Check Latest Version](/docs/api/info/latest). ## Services and Ports | Service | Compose service | URL / port | Notes | |---------|-----------------|------------|-------| | FaynoSync API | `backend` | http://localhost:9000 | Container with Docker Compose, `go run` from source | | Admin Dashboard | `backend` | http://localhost:9000/dashboard/ | Served by the API | | Dashboard dev server | — | http://localhost:3000/dashboard/ | From source only (`yarn dev`) | | MongoDB | `db` | `localhost:27017` | | | Redis | `cache` | `localhost:6379` | Required: response cache, telemetry counters, TUF state, rate limits | | Garage S3 API | `s3` | http://localhost:3900 | Access key and secret in `.env.example` | | Garage website endpoint | `s3` | http://localhost:3902 | Serves public artifact links | | Garage admin UI | `webui` | http://localhost:3909 | User `admin`, password `BjjctVsoSg4FKkT81VKt18` | Public artifact links have the form `http://cb-faynosync-s3-public.web.garage.localhost:3902/`. Browsers and `curl` resolve any `*.localhost` name to your machine, so these links open without editing `/etc/hosts`. The configuration lives in the repository: - `.env.example` — API environment (storage, Redis, telemetry, reports, TUF); Docker Compose reads it directly and overrides only the service hostnames in the `backend` service of `docker-compose.yaml` - `docker-compose/configs/garage.toml` — Garage configuration - `docker-compose/scripts/garage-entrypoint.sh` — Garage bootstrap See [Environment Configuration](./env-overview) for every variable. The credentials in these files are for local testing only — never reuse them on a machine reachable from a network. `API_KEY` and `JWT_SECRET` use `insecure-dev-*` placeholders: the API logs a warning about them on every start and refuses to start with them when `GIN_MODE=release`. The bundled configuration runs the API with performance mode, telemetry, reports and TUF enabled, so every feature is available locally. ## Running Tests The integration tests need MongoDB, Redis and Garage running. Run the whole suite — the tests share state and depend on each other, so running a single test with `-run` is not supported. The API image ships the test binary. Wait until Garage finishes its bootstrap (`docker compose logs s3` shows `Garage bootstrap completed successfully.`), then run: ```bash docker compose exec -T backend /usr/bin/faynoSync_tests ``` From the repository root: ```bash go test ``` Unit tests: ```bash go test ./server/... -race ``` ## Logs ```bash docker compose logs -f # all services docker compose logs -f backend # API only ``` The API logs to the terminal where `go run` runs; `--loglevel` accepts `debug`, `info`, `warn` and `error`. For the dependencies: ```bash docker compose logs -f ``` ## Stopping Services ```bash docker compose down # stop, keep data docker compose down -v # stop and delete all data volumes ``` Stop the API and the dashboard with `Ctrl+C`, then the dependencies: ```bash docker compose down # stop, keep data docker compose down -v # stop and delete all data volumes ``` ## Troubleshooting - **Port is already allocated** — another service uses one of the ports above. Stop it, or change the host side of the port mapping in `docker-compose.yaml`. From source, port `9000` is taken when the API container is running: stop it with `docker compose stop backend`. - **`docker compose up` waits before starting the API** — the API starts only after MongoDB, Redis and Garage report healthy, and Garage turns healthy once its bootstrap (layout, key, buckets) has finished. Check progress with `docker compose ps` and `docker compose logs s3`. - **The API run from source cannot connect to MongoDB, Redis or S3** — check that `.env` exists in the repository root and the containers are `healthy`. - **`/dashboard/` on port 9000 returns `503` or shows an old UI** — from source, the API serves the dashboard embedded at compile time. Run `yarn build` in `dashboard/` and restart the API, or use the dev server on port `3000`. - **Rebuilding the API image is slow** — with Docker Compose, every API change needs `docker compose up --build`. To work on faynoSync itself, run it from source. ## Next Steps - [Environment Configuration](./env-overview) — all configuration variables - [Production Deployment](./production-deploy) — deploy faynoSync with managed storage - [Local Development with faynoSync — Choose Your Path](/blog/local-development) — which local setup to choose and why - [How to Setup Auto Update for Electron App](/blog/electron-auto-update) — a full tutorial built on a local faynoSync --- ## Logging Configuration ## Overview The API server uses `logrus` for logging, providing a flexible way to manage log levels based on the current environment or specific needs. You can control the logging level by setting the `--loglevel` flag when running the application. ## Supported Log Levels The following log levels are supported: * debug – Detailed information, typically for diagnosing issues. * info – General operational information about the application. * warn – Potentially harmful situations, but the application is still running. * error – Errors that prevent certain functionality but don't cause the system to stop. * fatal – Critical errors that cause the application to terminate. * panic – Logs panic messages before terminating. ## Command-Line Flag The log level is specified using the `--loglevel` flag in the command line. By default, the log level is set to `info` if not provided. **Syntax:** ```bash --loglevel= ``` ## Example Log Levels - **Development:** Use `debug` for detailed logs during development and testing. - **Production**: Use `warn` or `error` in production to reduce log noise and focus on critical issues. --- ## Production Deployment import LatestVersion from '@site/src/components/LatestVersion'; import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; # Production Deployment This guide deploys faynoSync on one Linux server: the API runs next to MongoDB and Redis, Nginx terminates TLS in front of it, and build artifacts live in your cloud object storage. One machine, one `.env`. There are two ways to run it, and only a few steps differ between them — those steps have a tab for each: - **Docker Compose** (recommended): the API, MongoDB and Redis run as containers from one compose file. - **Binary**: the release binary runs as a systemd service, and you bring your own MongoDB and Redis (installed on the server or managed). The binary includes the dashboard and the database migrations. If you only want to try faynoSync on your machine, use [Local Setup](./local-deploy) instead. ## What You Need - A Linux server - A domain name with a DNS `A` record pointing to the server, e.g. `faynosync.example.com` - Nginx and Certbot on the server (installed in [step 8](#8-nginx-and-tls)) - Object storage: **AWS S3**, **DigitalOcean Spaces** or **Google Cloud Storage**, with two buckets: - a **public** bucket for public applications (objects must be publicly readable) - a **private** bucket for private applications - recommended: a bucket for edge manifests (`S3_BUCKET_NAME_CDN`) — reuse the public bucket or create a dedicated one, see [Serve update checks from the edge](#recommended-serve-update-checks-from-the-edge) - [Docker Engine and the Compose plugin](https://docs.docker.com/engine/install/) - MongoDB and Redis reachable from the server — installed locally ([MongoDB](https://www.mongodb.com/docs/manual/installation/), [Redis](https://redis.io/docs/latest/operate/oss_and_stack/install/)) or managed. Redis is required: the API exits on startup without it. faynoSync is tested with MongoDB 7.0 and 8.0 and Redis 8.10. - systemd ## 1. Create the Project Directory ```bash mkdir faynosync && cd faynosync ``` All files below are created in this directory. ## 2. Generate Secrets Create `.env` with freshly generated secrets and restrict its permissions: ```bash cat > .env < ```bash cat > .env < The API refuses to start when `JWT_SECRET` is empty or shorter than 32 bytes. Do not copy `API_KEY` or `JWT_SECRET` from the repository's `.env.example`: their `insecure-dev-*` values are public, and in release mode the API refuses to start with them. ## 3. Configure the Instance Append the rest of the configuration to `.env` and replace the example values: ```bash cat >> .env <<'EOF' # Release to run (see the note below) FAYNOSYNC_VERSION=v2.7.0 # Public URL of the API API_URL=https://faynosync.example.com # Storage (AWS S3 example) STORAGE_DRIVER=aws S3_ACCESS_KEY=your_access_key S3_SECRET_KEY=your_secret_key S3_REGION=us-east-1 S3_BUCKET_NAME=your-public-bucket S3_BUCKET_NAME_PRIVATE=your-private-bucket S3_BUCKET_NAME_CDN=your-public-bucket S3_ENDPOINT=https://your-public-bucket.s3.amazonaws.com S3_ENDPOINT_PRIVATE=https://your-private-bucket.s3.amazonaws.com # Features PERFORMANCE_MODE=true ENABLE_TELEMETRY=true REPORTS_ENABLED=true TUF_ENABLED=true EOF ``` :::tip Set `FAYNOSYNC_VERSION` to the latest release: . Pinning a version instead of `latest` means an upgrade happens only when you change this line. ::: Then add the settings that depend on how you run faynoSync: ```bash cat >> .env <<'EOF' # Nginx on the host reaches the container through the Docker network gateway TRUSTED_PROXIES=172.16.0.0/12 ONLINE_KEY_DIR=/private_keys EOF ``` `MONGODB_URL`, `REDIS_HOST`, `REDIS_PORT` and `REDIS_PASSWORD` for the API are set by the compose file — do not add them. ```bash cat >> .env <<'EOF' # Nginx on the same host TRUSTED_PROXIES=127.0.0.1 REDIS_HOST=127.0.0.1 REDIS_PORT=6379 ONLINE_KEY_DIR=/opt/faynosync/private_keys EOF ``` Notes: - For DigitalOcean Spaces set `STORAGE_DRIVER=digitalocean`, `S3_REGION=fra1` and `S3_ENDPOINT=fra1.digitaloceanspaces.com` / `S3_ENDPOINT_PRIVATE=fra1.digitaloceanspaces.com` (your region). For GCP see `GCS_CREDENTIALS_FILE` in the [Environment Variables Overview](./env-overview). - Every other variable (Slack, reports limits, presigned uploads, private downloads) is described in the [Environment Variables Overview](./env-overview). ## 4. Install Download the compose file: ```bash curl -fsSLO https://raw.githubusercontent.com/ku9nov/faynoSync/main/deploy/docker-compose.yaml ``` It runs the API, MongoDB and Redis. You can [read it on GitHub](https://github.com/ku9nov/faynoSync/blob/main/deploy/docker-compose.yaml) before running it. What this gives you: - MongoDB and Redis are reachable only inside the compose network — no ports are published for them. - Compose reads `.env` on the host and passes it to the API as environment variables, so the file can stay `chmod 600`; values under `environment:` take precedence. - The API listens on `127.0.0.1:9000` only; the outside world reaches it through Nginx. - The API starts after MongoDB and Redis report healthy, and restarts if it crashes. - The image has a built-in healthcheck (`faynoSync healthcheck` probes `/health`), so `docker compose ps` shows whether the API is healthy. - Logs are rotated (5 × 10 MB per container) instead of growing until the disk is full. :::note If MongoDB 8.0 does not start on your host (`docker compose logs db`), change the `db` image to `mongo:7.0`: faynoSync is fully supported on it. Do this only before the first start — an existing 8.0 data volume cannot be opened by 7.0. ::: Download the archive for your architecture (`amd64` or `arm64`) from the release, verify it, and install it to `/opt/faynosync`: ```bash VERSION=v2.7.0 ARCH=amd64 BASE=https://github.com/ku9nov/faynoSync/releases/download/$VERSION curl -fsSLO "$BASE/faynoSync_${VERSION}_linux_${ARCH}.tar.gz" curl -fsSLO "$BASE/SHA256SUMS" sha256sum --check --ignore-missing SHA256SUMS tar xzf "faynoSync_${VERSION}_linux_${ARCH}.tar.gz" sudo useradd --system --home-dir /opt/faynosync --shell /usr/sbin/nologin faynosync sudo install -d -o faynosync -g faynosync -m 750 /opt/faynosync sudo install -o root -g root -m 755 "faynoSync_${VERSION}_linux_${ARCH}/faynoSync" /opt/faynosync/faynoSync sudo install -o faynosync -g faynosync -m 600 .env /opt/faynosync/.env ``` Create `/etc/systemd/system/faynosync.service`: ```ini [Unit] Description=faynoSync update server After=network-online.target Wants=network-online.target [Service] User=faynosync Group=faynosync WorkingDirectory=/opt/faynosync Environment=GIN_MODE=release ExecStart=/opt/faynosync/faynoSync Restart=on-failure NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true [Install] WantedBy=multi-user.target ``` What this gives you: - The API reads `/opt/faynosync/.env` from its working directory; only the `faynosync` user can read it, and the binary itself is owned by root. - `GIN_MODE=release` turns on the release-mode checks: the API refuses to start with the `insecure-dev-*` secrets from the repository. - The API restarts if it crashes and can write only to its private `/tmp`. :::warning The binary listens on port `9000` on all interfaces. Block it with your firewall so the outside world reaches the API only through Nginx, e.g. with `ufw`: `sudo ufw allow 22,80,443/tcp && sudo ufw enable`. ::: ## 5. Start ```bash docker compose up -d docker compose ps docker compose logs backend ``` ```bash sudo systemctl daemon-reload sudo systemctl enable --now faynosync sudo journalctl -u faynosync -f ``` The log should show: ``` level=info msg="Connected to MongoDB!" level=info msg="Redis connection is required. Connecting to Redis." level=info msg="Connected to Redis successfully" level=info msg="Telemetry allow-list reloaded after initializing app handler" ``` With Docker Compose the log also starts with `.env file not found, using system variables`. This is expected: Compose passes `.env` to the container as environment variables, not as a file, and the API reads them from the environment. ## 6. Apply Database Migrations The API does not run migrations on startup. Apply them once it is running: ```bash docker compose exec -T backend /usr/bin/faynoSync migrate up ``` ```bash sudo -u faynosync sh -c 'cd /opt/faynosync && exec ./faynoSync migrate up' ``` The command prints `Migrations completed`, or `No pending migrations to apply` when the schema is already current. Migrations create the indexes faynoSync relies on, including the unique index that rejects a second document for the same app version, so do not skip this step. Run it again after every upgrade (see [Upgrade](#upgrade)). If the command reports app versions stored in more than one document, no migration was applied: merge or remove the listed documents, then run it again. ## 7. Create the Admin Account Register the admin with the `API_KEY` from your `.env` (see [Sign Up](/docs/api/auth/signup)): ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "", "api_key": ""}' \ http://127.0.0.1:9000/signup ``` `/signup` stays open while `API_KEY` is set. Once your admin accounts exist, remove the `API_KEY` line from `.env` and restart the API to disable it: ```bash docker compose up -d backend ``` Use `up -d`, not `restart`: `restart` keeps the old environment. ```bash sudo systemctl restart faynosync ``` The log then shows `API_KEY is not set, /signup is disabled`. ## 8. Nginx and TLS The commands below are for Debian/Ubuntu. On other distributions, install Nginx, Certbot and the Certbot Nginx plugin with your package manager, put the config in `/etc/nginx/conf.d/faynosync.conf` and skip the `ln -s` step. Install Nginx and Certbot: ```bash sudo apt update sudo apt install nginx certbot python3-certbot-nginx ``` Create `/etc/nginx/sites-available/faynosync.conf`: ```nginx # Cache zone for /checkVersion (files in sites-enabled are included inside the http block) proxy_cache_path /var/cache/nginx-faynosync levels=1:2 keys_zone=checkversion_cache:10m inactive=60s max_size=100m; server { server_name faynosync.example.com; client_max_body_size 1000M; location / { proxy_pass http://127.0.0.1:9000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60; proxy_send_timeout 60; proxy_read_timeout 60; send_timeout 60; } # Optional: short-lived cache for update checks location = /checkVersion { proxy_pass http://127.0.0.1:9000; proxy_cache checkversion_cache; proxy_cache_valid 200 60s; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; proxy_cache_lock_timeout 65s; proxy_cache_background_update on; proxy_cache_key "$scheme$request_method$host$request_uri"; add_header X-Cache-Status $upstream_cache_status; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } access_log /var/log/nginx/faynosync.access.log; error_log /var/log/nginx/faynosync.error.log; listen 80; } ``` Enable the site, then let Certbot obtain a certificate and switch the server block to HTTPS: ```bash sudo ln -s /etc/nginx/sites-available/faynosync.conf /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx sudo certbot --nginx -d faynosync.example.com ``` Certbot renews the certificate automatically. Check the result: ```bash curl https://faynosync.example.com/health ``` For more caching and rate-limiting options see [Performance Mode](/docs/performance). ## Recommended: Serve Update Checks from the Edge For any internet-facing instance, serve steady-state update checks from object storage instead of the API. With [edge mode](/docs/edge), the API answers only the first check for each combination of app, channel, platform, arch and client version after a publish; every later check is a static object read. This is the cheapest way to handle client load, and it needs no API clustering. 1. **Bucket.** Set `S3_BUCKET_NAME_CDN` (see [What You Need](#what-you-need)). Manifests are written as public objects, so the bucket must allow public reads. Only the API writes to it. 2. **CDN (optional).** Put a CDN in front of that bucket. Manifests carry `Cache-Control: public, max-age=60, must-revalidate`, so clients see a publish within about a minute. 3. **Apps.** Enable `cdn` on each public app with [Create Application](/docs/api/apps/create) or [Update application](/docs/api/apps/update-app). Private apps cannot use it; the API rejects the combination with `400`. 4. **Clients.** Use an official SDK with the edge URL set to the bucket or the CDN in front of it: `EdgeURL` in the [Go SDK](/docs/sdk/go/sdk), `edgeURL` in the [JavaScript SDK](/docs/sdk/js/sdk). The SDK reads the manifest first and falls back to `/checkVersion` on a miss. 5. **Telemetry.** With `ENABLE_TELEMETRY=true`, pass a device ID to the SDK. Edge hits never reach `/checkVersion`, so the SDK reports them through [`/telemetry/beacon`](/docs/api/info/telemetry_beacon) instead. Clients that call `/checkVersion` directly keep working unchanged; they just don't benefit from the edge. For the design, cache keys and invalidation rules, see [Edge and S3 Response Cache](/docs/edge). ## TUF Signing Keys Not part of the first install: do this when you bootstrap TUF for an app. `TUF_ENABLED=true` only turns on the TUF API, and the API does not generate keys — you create them outside the server (e.g. with the script from the dashboard), then copy the online signing keys (targets, snapshot, timestamp and delegated roles) into `ONLINE_KEY_DIR`. Each file is named after its key ID. Root keys stay offline and never go on the server (see [TUF Management](/docs/api/tuf/tuf-introduction)). If you don't use TUF, set `TUF_ENABLED=false`. The key files must be readable by the user the API runs as: `./private_keys` is mounted read-only, and the API runs as UID `65532` inside the container: ```bash sudo chown -R 65532:65532 ./private_keys sudo chmod 700 ./private_keys && sudo chmod 600 ./private_keys/* ``` ```bash sudo install -d -o faynosync -g faynosync -m 700 /opt/faynosync/private_keys sudo install -o faynosync -g faynosync -m 600 /path/to/online-keys/* /opt/faynosync/private_keys/ ``` ## Dashboard The API serves the dashboard at `https://faynosync.example.com/dashboard/`; there is nothing to deploy separately. Optional settings: - `DASHBOARD_ENABLED=false` turns it off. - `DASHBOARD_TUF_METADATA_URL` pre-fills the TUF metadata URL in the key generation form. - `ALLOWED_CORS` is only needed if you also host a dashboard build on another origin. ## Upgrade Read the release's `CHANGELOG` entry before upgrading — breaking changes are listed first. 1. Set `FAYNOSYNC_VERSION` in `.env` to the new release. 2. Pull and recreate the API container: ```bash docker compose pull backend docker compose up -d ``` 3. Apply the migrations shipped with the new version: ```bash docker compose exec -T backend /usr/bin/faynoSync migrate up ``` 1. Download and verify the new archive as in [step 4](#4-install), then replace the binary and restart: ```bash sudo install -o root -g root -m 755 "faynoSync_${VERSION}_linux_${ARCH}/faynoSync" /opt/faynosync/faynoSync sudo systemctl restart faynosync ``` 2. Apply the migrations shipped with the new version: ```bash sudo -u faynosync sh -c 'cd /opt/faynosync && exec ./faynoSync migrate up' ``` Restarting takes the API offline for a few seconds; clients retry their next update check, and edge-served checks are not affected. ## Backups | What | Why | How | |------|-----|-----| | MongoDB | apps, versions, users, tokens, settings | `mongodump` (below) | | `.env` | secrets and configuration | copy to a secure location | | `ONLINE_KEY_DIR` | TUF online signing keys | secure offline copy; keep it separate from the database dumps | | Buckets | build artifacts, feeds, TUF metadata | enable versioning on the buckets | | Redis | cache, telemetry counters, TUF task and bootstrap state | Redis persistence (the compose file enables AOF); TUF bootstrap state can also be rebuilt with [Bootstrap Recovery](/docs/api/tuf/recovery) | Dump MongoDB to a compressed archive and restore it: ```bash docker compose exec -T db sh -c \ 'mongodump --username root --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --archive --gzip' \ > faynosync-$(date +%F).archive.gz ``` ```bash docker compose exec -T db sh -c \ 'mongorestore --username root --password "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --archive --gzip --drop' \ < faynosync-2026-01-01.archive.gz ``` With the [MongoDB Database Tools](https://www.mongodb.com/docs/database-tools/) installed and `MONGODB_URL` from your `.env`: ```bash mongodump --uri "$MONGODB_URL" --archive --gzip > faynosync-$(date +%F).archive.gz mongorestore --uri "$MONGODB_URL" --archive --gzip --drop < faynosync-2026-01-01.archive.gz ``` ## Security Checklist 1. Generate every secret (step 2); never reuse values from the repository. 2. Remove `API_KEY` once admin accounts exist (step 7). 3. Serve the API only over HTTPS (step 8). 4. Expose only ports 80 and 443; MongoDB, Redis and the API port stay on the host (with the binary, block port `9000` in the firewall). 5. Keep `TRUSTED_PROXIES` set to the addresses your proxy connects from. 6. Upgrade regularly and read the changelog. ## Troubleshooting - **`backend` stays in `Created`** (Compose) — MongoDB or Redis is not healthy yet. Check `docker compose ps` and `docker compose logs db cache`. - **`dependency db failed to start`** (Compose) — check `docker compose logs db`; if MongoDB 8.0 cannot start on your host, use `mongo:7.0` as described in [step 4](#4-install). - **The service exits right after start** (binary) — MongoDB or Redis is unreachable or `.env` is not readable by the `faynosync` user; check `sudo journalctl -u faynosync`. - **`JWT_SECRET is empty or shorter than 32 bytes`** or **`holds the public development value`** — regenerate the secret as in step 2. - **Storage access denied** — check the storage credentials and bucket permissions; the public bucket must allow public reads. - **Nginx 502** — the API is not running or not listening on port `9000`; check `docker compose logs backend` or `sudo journalctl -u faynosync`. ## Environment Variables Reference For every available environment variable, see the [Environment Variables Overview](/docs/getting-started/env-overview). --- ## Troubleshooting ## Handling CORS Restrictions for Chrome Extension Requests ### Problem Browsers enforce CORS policies to prevent unauthorized cross-origin requests. Requests made by Chrome extensions are blocked by default unless the server explicitly allows the extension's unique ID. Without proper configuration, these requests will fail due to CORS restrictions. ### Solution Each Chrome extension has a unique ID generated from its manifest file. By configuring the backend API to allow this specific extension ID in CORS settings, we can bypass the restriction while maintaining security. The extension ID is stable, meaning it rarely changes, so adding it to the allowed origins solves the issue unless the extension is updated. ### Server Configuration To enable CORS for the Chrome extension, the server’s `ALLOWED_CORS` environment variable was updated to include both the local development URL and the extension's unique ID: ```bash ALLOWED_CORS="http://localhost:5173,chrome-extension://fhdpldnibaocgjobfdkikidckofodicc" ``` This allows requests from both the development environment and the Chrome extension, ensuring functionality while keeping security in check. ## Updater Feed or TUF Metadata Shows the Old Version After Publish ### Problem Right after you publish or unpublish a version, a public URL still returns the previous content: a Velopack `releases.{channel}.json`, a Sparkle `appcast.{channel}.xml`, an [edge](/docs/edge) response manifest, or TUF `timestamp.json`. The API and the database already show the new state. ### Cause These files are regenerated on every publish, but they are served with `Cache-Control: public, max-age=60, must-revalidate`. Anything between the client and the bucket may serve its cached copy until that minute runs out. On Google Cloud Storage this happens even without a CDN: GCS serves public objects from Google's own cache. ### Solution Wait up to 60 seconds; no action is needed. To confirm that faynoSync wrote the new file, request it with a unique query string, which bypasses the cache on GCS: ```bash curl -s "https://storage.googleapis.com/your-public-bucket/velopack/admin/MyApp/windows/amd64/releases.stable.json?nocache=$(date +%s)" ``` If the fresh copy is correct, the delay is caching. If it is still old, check the server logs for the publish. --- ## Self-Hosted API for Automatic App Updates # faynoSync — the simple open-source update server you've been waiting for In today's fast-paced world of AI and digitalization, more and more people, companies, and startups are creating their own applications. Depending on the type of app, you might publish it on platforms like Google Play, the App Store, and so on. These services not only handle publishing but also provide automatic versioned updates to end users. But there's a small catch. {/* truncate */} Bureaucracy. Registrations. The 9 circles of hell you have to go through before your app finally goes live on those platforms. Just think — where do you actually download apps like **Discord**, **Brave Browser**, or **Docker Desktop**? Exactly. The list goes on. That's where **faynoSync** comes in. --- ## What is faynoSync? **faynoSync** is a lightweight, open-source API server that gives you full control over application updates. Whether it's desktop software, mobile apps, browser extensions, or custom binaries for metrics or monitoring — faynoSync helps you deliver updates easily, securely, and reliably. No external dependencies. Just pure control. --- ## Why faynoSync? Sure, each programming language or OS has its own update tools. But they're often non-transparent, overly complicated, or just a pain to integrate. **faynoSync is a universal solution** — it allows you to check for updates from any language, with a centralized API and minimal setup. --- ## What can faynoSync do? - 🔓 Create **public** or **private** applications - 🔐 Support **signed** and **unsigned** builds (perfect for development environments) - 👥 Manage team users and assign fine-grained permissions - 🚀 Publish, roll back, or deactivate specific releases - ⚠️ Mark applications as **critical** to notify users immediately - 🌍 Manage **channels** (dev, prod, nightly, stable), **platforms**, and **architectures** - 📝 Add, edit, and display **changelogs** so users know what's new - ⚡ Built with **Go** — fast, simple, and open-source And it's only the start; there will be more in the future. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Upload at least two versions of your application. 4. Check for updates with this simple request: 📡 [`/info/latest`](https://faynosync.com/docs/api/info/latest) --- This article is just an introduction. In the next posts, we'll explore the full functionality of faynoSync, integration examples, and development tips. ## Related reading - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) - [Local Development with faynoSync — Choose Your Path](https://faynosync.com/blog/local-development) - [Private Apps in faynoSync — Keep Your Software Secure](https://faynosync.com/blog/private-apps) - [Team-Based Authorization in faynoSync — Manage Your Team Like a Pro](https://faynosync.com/blog/team-based-auth) - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## Private Apps in faynoSync — Keep Your Software Secure # Private Apps in faynoSync — Keep Your Software Secure 🔒 When developing software, sometimes you need to keep things private. Whether it's internal tools, beta versions, or enterprise applications — not everything should be publicly accessible. That's why faynoSync comes with built-in support for private applications. This post covers what private apps are, how to create one, how downloads and update checks are gated, and how private apps combine with [team access control](https://faynosync.com/blog/team-based-auth). Every flag and endpoint here is verified against the [Create Application](https://faynosync.com/docs/api/apps/create) and [private download](https://faynosync.com/docs/api/apps/download) docs. {/* truncate */} --- ## What are Private Apps? 🤔 Private apps in faynoSync are applications that are stored in a separate, private S3 bucket. This gives you an extra layer of security and control over who can access your software. Once an app is marked as private, it stays private forever — this is a one-way decision to ensure consistency and security. --- ## How to Create a Private App? 🛠️ There are two ways to create a private app: ### 1. Using the Web Dashboard 🖥️ Simply check the "Private" option when creating your app through the dashboard interface. It's that simple! ### 2. Using the API 📡 When making a POST request to `/app/create`, include the `private` parameter. The endpoint uses `multipart/form-data`, so the JSON goes in a `data` form field: ```bash curl --location 'http://localhost:9000/app/create' \ --header 'Authorization: Bearer ' \ --form 'data="{\"app\":\"appName\", \"private\": true, \"download_mode\": \"strict\"}"' ``` ```json { "createAppResult.Created": "641459ffb8760d74164e7e3c" } ``` The `private` field sits alongside the other creation flags — `description`, a logo `file`, `tuf`, and `cdn` (the [edge cache](https://faynosync.com/blog/edge-s3-cache) toggle). Only `app` is required. A private app also takes `download_mode` (`unlisted` or `strict`, covered below), and it cannot enable `cdn`: edge manifests are public objects, so the API rejects that combination with `400`. > **Heads up:** `private` is **irreversible**. Once it's `true`, the app cannot be made public later. Decide before you create, not after. --- ## How Private Apps Work? 🔐 1. **Storage**: Private apps are stored in a separate S3 bucket (defined by `S3_BUCKET_NAME_PRIVATE` in your environment) 2. **Access Control**: Each private app has a `download_mode`, set on create and changeable on [update](https://faynosync.com/docs/api/apps/update-app): - `unlisted` — anyone who has an artifact link can download it and check for updates - `strict` — update checks and downloads need a **download token** for the app and channel, or a JWT of the owner or of a team user with access 3. **Updaters**: a private app works with the `manual`, `tauri` and `squirrel_darwin` updaters. Feed-based updaters (`velopack`, `sparkle`, `electron-builder`, `squirrel_windows`) need a publicly served feed and are rejected on upload. `ENABLE_PRIVATE_APP_DOWNLOADING` is deprecated: it only picks the default `download_mode` for new private apps (`true` → `unlisted`, `false` → `strict`). See the [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview) for the full storage configuration (the private bucket is configured per provider — MinIO, AWS, DigitalOcean Spaces, or GCS). --- ## Download tokens for strict apps A download token (`fnd_…`) is scoped to one app and one channel. Create or rotate it with [`POST /download-tokens/regenerate`](https://faynosync.com/docs/api/apps/download-tokens-regenerate): ```bash curl -X POST --location 'http://localhost:9000/download-tokens/regenerate' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{"app_id": "", "channel_id": ""}' ``` The value is returned once; only its hash is stored. Ship it with the build and send it as the `X-Download-Token` header on every update check and download: ```bash curl --location 'http://localhost:9000/checkVersion?app_name=appName&version=0.0.1&channel=stable&platform=linux&arch=amd64&owner=admin' \ --header 'X-Download-Token: fnd_' ``` Without a valid token, a `strict` app answers exactly as an app that does not exist — no version, no changelog, no links. The [Go SDK](https://faynosync.com/docs/sdk/go/sdk) sends the header for you through `CheckOptions.DownloadToken`. --- ## Downloading from a private bucket Artifacts in the private bucket aren't served by a plain public URL. You fetch them through `GET /download`, passing the object `key`: ```bash curl -X GET --location 'http://localhost:9000/download?key=secondapp-admin%2Fstable%2Flinux%2Famd64%2Fsecondapp-0.0.1.deb' \ --header 'X-Download-Token: fnd_' ``` You don't build the `key` yourself: the links in update-check responses of a private app already point to `/download?key=…`. What comes back depends on the credential: - **Download token, or an `unlisted` app** — the request is redirected to a signed URL of the file. - **JWT with access** — the response is a JSON `download_url`: a signed URL that expires after **15 minutes**. - **Anything else** — `404`, the same answer as for a key that does not exist. ```json { "download_url": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.1.deb?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=..." } ``` Short-lived signed URLs are the key property: even if a link leaks, it expires in minutes, so it can't be reshared as a permanent public download. Full contract in the [download docs](https://faynosync.com/docs/api/apps/download). --- ## Security Features 🛡️ Think of private apps in faynoSync as your software's VIP section! Here's what makes them special: - 🔒 **Separate Storage**: Your private apps live in their own secure S3 bucket, like having a private vault for your most valuable assets - 🔐 **One-Way Privacy**: Once you mark an app as private, it stays private forever. This might sound strict, but it's actually a good thing — it ensures your security settings can't be accidentally changed - 👥 **Smart Access Control**: You're in charge! Each private app picks `unlisted` or `strict`, and `strict` apps get per-channel download tokens you can rotate at any time - 🚫 **Download Protection**: In `strict` mode, update checks and downloads without a token or a JWT with access get the same answer as a nonexistent app — nothing leaks about versions or files --- ## Private apps and team access control Privacy and [team-based authorization](https://faynosync.com/blog/team-based-auth) stack cleanly. The private bucket decides **where** artifacts live and `download_mode` decides whether downloads need a credential; team permissions decide **who** on your team can manage and download them via the `apps.download` permission and their `allowed` apps and channels. Use `strict` plus scoped team users when you need both restricted distribution and internal least-privilege access. Creating download tokens requires the permission to edit apps. The same private bucket (`S3_BUCKET_NAME_PRIVATE`) is reused by [report ingestion](https://faynosync.com/blog/rollout-health-reports) for storing debug blobs — sensitive payloads never touch the public/CDN bucket, and are only retrievable through the same short-lived presigned-URL mechanism. --- ## Best Practices 💡 1. Use private apps for: - Internal tools and utilities - Beta versions of your software - Enterprise-specific applications - Software requiring license validation 2. Decide on `private` **before** creating the app — it can't be undone. 3. Use `download_mode: strict` when you need real access control, give each channel its own download token, and send it on update checks as well as downloads. 4. Treat signed URLs as ephemeral — generate them on demand rather than caching or sharing them; they expire by design. --- ## Common questions **Can I make a private app public later?** No. The `private` flag is irreversible by design. Create a new public app instead. **Why does `/download` return a URL instead of the file?** The request carried a JWT, so the API hands back a short-lived signed URL as JSON. Requests with a download token, and requests for `unlisted` apps, are redirected to the file instead. **Why does my strict app look like it doesn't exist?** The request has no valid `X-Download-Token` for that app and channel, or the JWT has no access. A denied request is answered exactly like a nonexistent app on purpose. **Where do private artifacts actually live?** In the bucket named by `S3_BUCKET_NAME_PRIVATE`, separate from your public artifact bucket, on whichever provider your `STORAGE_DRIVER` points to. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Upload at least two versions of your application. 4. Check for updates with this simple request: 📡 [`/info/latest`](https://faynosync.com/docs/api/info/latest) --- ## Related reading - [Team-Based Authorization in faynoSync — Manage Your Team Like a Pro](https://faynosync.com/blog/team-based-auth) - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) - [Create Application docs](https://faynosync.com/docs/api/apps/create) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## Team-Based Authorization in faynoSync — Manage Your Team Like a Pro # Team-Based Authorization in faynoSync — Manage Your Team Like a Pro 👥 Managing a team of developers? Need to control who can do what in your faynoSync instance? Starting from version **1.4.0**, faynoSync introduces a powerful **Team Based Authorization Matrix** that gives you complete control over your team's access and permissions. This is role-based access control (RBAC) built for an update server: one administrator owns an isolated set of team users, each team user gets granular permissions per resource type, and no administrator can ever see another's data. This post walks through the model end to end, with copy-paste API calls verified against the [Team Based Authorization docs](https://faynosync.com/docs/team_based_auth). {/* truncate */} --- ## What is Team-Based Authorization? 🤔 Think of it as your team's digital headquarters! The Team Based Authorization Matrix is a sophisticated system that lets you: - 👨‍💼 Create and manage team users - 🔐 Assign specific permissions - 🏢 Keep data isolated between different teams - 📊 Track who can do what Every team user belongs to exactly one administrator, and everything a team user creates is automatically owned by that administrator. There is no shared global namespace — isolation is the default, not an add-on. --- ## Meet the Players 🎭 ### The Administrator 👑 - You're the boss! Each admin is unique - You own your team of users - You can create, update, and delete your team members - You have full control over passwords and permissions - You can only access your own team's resources ### The Team User 👤 - A unique member of your team - Belongs only to you (their admin) - Can't manage other users — the entire user-management API is off-limits to them - Can only work with resources you've allowed - Everything they create belongs to you When a team user with the **Create** permission creates a resource, that resource is automatically added to their list of allowed resources — so they can immediately work with what they just made, and nothing else. --- ## What Can Your Team Do? 🛠️ Each team member can have different permissions for different types of resources: ### Resource Types: - 📱 Applications (`apps`) - 📊 Channels (`channels`) - 💻 Platforms (`platforms`) - 🏗️ Architectures (`archs`) ### Available Permissions: - ➕ Create - ✏️ Edit - 🗑️ Delete - ⬆️ Upload (apps only) - ⬇️ Download (apps only) ### Permissions reference `upload` and `download` apply **only** to `apps`; channels, platforms, and architectures expose just create/edit/delete. Each resource block also carries an `allowed` array — the list of specific resource IDs the user may act on. | Resource | create | edit | delete | upload | download | allowed | |----------|:------:|:----:|:------:|:------:|:--------:|:-------:| | `apps` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `channels` | ✅ | ✅ | ✅ | — | — | ✅ | | `platforms` | ✅ | ✅ | ✅ | — | — | ✅ | | `archs` | ✅ | ✅ | ✅ | — | — | ✅ | --- ## Smart Resource Management 🧠 - 🔒 Team users can only see and use resources they've been given access to - 🏢 Each admin's resources are completely separate - 👀 You (as admin) can see everything your team creates - 📝 Different teams can have resources with the same names That last point matters in practice: two different administrators can both own an app called `myapp`, a `stable` channel, or a `linux` platform without any collision — names are unique only **within** an administrator's scope, never globally. --- ## The `owner` query parameter Because resource names are no longer globally unique, the public read endpoints need to know **whose** `myapp` you mean. Two endpoints now require an `owner` query parameter — the administrator's account name: - `GET /checkVersion` - `GET /apps/latest` ```bash curl 'http://localhost:9000/checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=arm64&owner=admin' ``` If you build update-check URLs in your client or CI, make sure `owner` is part of them — otherwise the server can't resolve the right scope. See [Fetch Latest Version of App](https://faynosync.com/blog/fetch-latest-version) for how these links are constructed. --- ## How to Manage Your Team? 🎯 ### Option 1: Using the API #### Creating a Team User `POST /user/create` takes a username, a password, and a full `permissions` object. Here's a complete, valid request — `teamuser1` can create and edit apps and download their artifacts, but can't delete or upload: ```bash curl -X POST 'http://localhost:9000/user/create' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "username": "teamuser1", "password": "password123", "permissions": { "apps": { "create": true, "edit": true, "delete": false, "upload": false, "download": true, "allowed": [""] }, "channels": { "create": true, "edit": true, "delete": false, "allowed": [""] }, "platforms": { "create": true, "edit": true, "delete": false, "allowed": [""] }, "archs": { "create": true, "edit": true, "delete": false, "allowed": [""] } } }' ``` ```json { "message": "Team user created successfully" } ``` The `allowed` array holds the resource **IDs** the user can act on. Leaving it empty means "no pre-granted resources" — but with `create: true`, anything the user creates is added to `allowed` automatically. #### Updating Permissions `POST /user/update` replaces the user's credentials and permission set. Here we revoke `create` on apps but grant `delete` and `upload`, and widen the `allowed` list: ```bash curl -X POST 'http://localhost:9000/user/update' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "username": "teamuser1", "password": "password1234", "permissions": { "apps": { "create": false, "edit": true, "delete": true, "upload": true, "download": true, "allowed": ["", ""] }, "channels": { "create": true, "edit": true, "delete": true, "allowed": [""] }, "platforms": { "create": true, "edit": true, "delete": false, "allowed": [""] }, "archs": { "create": true, "edit": true, "delete": false, "allowed": [""] } } }' ``` ```json { "message": "Team user updated successfully" } ``` Permission and password changes take effect immediately. Full reference: [Create User](https://faynosync.com/docs/api/team/create) and [Update User](https://faynosync.com/docs/api/team/update). ### Option 2: Using the Web Dashboard 🖥️ Prefer a more visual approach? The faynoSync dashboard makes team management a breeze! #### For Administrators: 1. Click the **Settings** button in the dashboard 2. A modal window will open where you can: - 👥 Create new team users - 🔑 Set passwords (or auto-generate them) - ✅ Assign permissions with simple checkboxes - ✏️ Update usernames and passwords - 🗑️ Remove team users #### For Team Users: 1. Go to your **Profile** section 2. View your current permissions at a glance: - 📱 Which applications you can access - 📊 What actions you can perform - 🔒 What resources are available to you This visual interface makes it much easier to: - 🎯 Understand your permissions - 🔄 Manage team access - 📊 Track resource availability - ⚡ Make quick changes --- ## Expected behavior at a glance | Action | Team User | Administrator | |--------|-----------|---------------| | Create resources | ✅ (within admin scope) | ✅ | | Edit resources | ✅ (only own admin's) | ✅ | | Delete resources | ✅ (only own admin's) | ✅ | | Manage users | ❌ | ✅ | | Access user API | ❌ | ✅ | | Access other admins' data | ❌ | ❌ | --- ## How team permissions reach the rest of faynoSync The same `apps.download` permission and the team user's `allowed` apps gate more than artifact downloads. The [report ingestion read API](https://faynosync.com/blog/rollout-health-reports) — `GET /reports/groups` — is also gated by `CheckPermission(download, apps)`, so a team user only sees rollout-health reports for apps in their `allowed_apps`. The same scoping idea applies across [telemetry](https://faynosync.com/blog/telemetry-insights) views: team users can read stats but only filter resources they have access to. One permission model, enforced everywhere. --- ## Common questions **A team user can't see an app they should have access to.** Check the `allowed` array for `apps` — visibility is opt-in. Either add the app's ID to `allowed` via `/user/update`, or have the user create the resource themselves (which auto-adds it). **Two teams need an app with the same name.** That's fully supported — names are unique per administrator, not globally. Just remember to pass `owner` on `/checkVersion` and `/apps/latest`. **A team user got a 403 on user management.** Expected. The entire user API is admin-only; team users cannot create, edit, or delete users, including themselves. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Upload at least two versions of your application. 4. Check for updates with this simple request: 📡 [`/info/latest`](https://faynosync.com/docs/api/info/latest) --- ## Related reading - [Private Apps in faynoSync — Keep Your Software Secure](https://faynosync.com/blog/private-apps) - [Fetch Latest Version of App — Smart Update Links](https://faynosync.com/blog/fetch-latest-version) - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) - [Team Based Authorization docs](https://faynosync.com/docs/team_based_auth) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## Local Development with faynoSync — Choose Your Path # Local Development with faynoSync — Choose Your Path 🛠️ Want to try a self-hosted update server before putting it anywhere near production? Or hack on faynoSync itself? Either way, you start with a local setup — and with faynoSync that takes a few minutes, not an afternoon. There are two ways to run it locally: **everything in Docker**, or **the Go API from source** with only the dependencies in containers. This post shows both, explains what each service in the stack does, and covers the first steps once it's running. {/* truncate */} :::note Updated in October 2026 Local setups use **Garage** (S3-compatible) through the `aws` storage driver, and **Redis** is now a required service. Since v2.7.0 the dashboard is built into the API binary and served at `/dashboard/` — there is no separate dashboard container or repository anymore. The steps below reflect the current setup. ::: --- ## What runs in the local stack Whichever option you pick, faynoSync needs the same pieces: | Service | Local port | What it does | |---|---|---| | faynoSync API | `9000` | The update server itself: uploads, update checks, feeds, telemetry | | Admin dashboard | `9000` (`/dashboard/`) | Web UI for apps, channels, platforms, versions, tokens, TUF — served by the API. When you work on the dashboard itself, its dev server runs on `3000` | | MongoDB | `27017` | Stores apps, versions, channels, platforms, users | | Redis | `6379` | Required — the API connects on startup and exits if Redis is unreachable. Also used by [Performance Mode](https://faynosync.com/docs/performance), rate limits and telemetry counters | | Garage | `3900` (S3 API), `3902` (web) | S3-compatible storage for your build artifacts and generated feeds | | Garage web UI | `3909` | Browse buckets and objects | Garage plays the role that AWS S3 (or another S3-compatible provider) plays in production. faynoSync talks to it through the same `aws` driver it uses for real S3, so your local setup behaves like production instead of like a special case. --- ## Option 1: Everything in Docker Compose 🐳 Best for: trying faynoSync, testing the API, building an integration against it, or running the test suite without installing Go. ### What You'll Need: - **Docker** 🐳 — [Installation Guide](https://docs.docker.com/engine/install/) - **Docker Compose** 🎵 — [Installation Guide](https://docs.docker.com/compose/install/) ### Start the stack ```bash git clone https://github.com/ku9nov/faynoSync.git cd faynoSync docker compose up --build ``` This builds the API image and starts the API, MongoDB, Redis and Garage; the API also serves the dashboard. Local configuration comes from `.env.example` in the repository root, and Garage's credentials and buckets are imported automatically during container bootstrap. When the stack is healthy, apply the database migrations from a second terminal: ```bash docker compose exec -T backend /usr/bin/faynoSync migrate up ``` Check that the API is up: ```bash curl http://localhost:9000/health ``` ```json {"status":"healthy"} ``` The dashboard is now at `http://localhost:9000/dashboard/`. ### Run the tests Wait until Garage finishes its bootstrap — `docker compose logs s3` shows `Garage bootstrap completed successfully.` — then run the test suite inside the backend container: ```bash docker compose exec -T backend /usr/bin/faynoSync_tests ``` The step-by-step version of this option, with every port and config file, is in the [Local Setup docs (Docker Compose tab)](https://faynosync.com/docs/getting-started/local-deploy?local-setup=compose). ### Benefits: - 🚀 One command to a working stack - 🧩 Nothing installed on your machine except Docker - 🔄 Same setup on every machine - 🧪 Easy to run the full test suite The trade-off: rebuilding the image after every code change is slow. If you're changing faynoSync's code, Option 2 is more comfortable. --- ## Option 2: API and dashboard from source, dependencies in Docker 🖥️ Best for: contributing to faynoSync, debugging the API, or anything where you want a fast edit-and-run loop. ### What You'll Need: - **Golang** 🐹 — [Installation Guide](https://go.dev/doc/install) - **Node.js** and **Yarn** — for the dashboard - **Docker** and **Docker Compose** — for MongoDB, Redis and Garage ### Start only the dependencies ```bash docker compose up -d db cache s3 webui ``` Naming the services starts only them, so the `backend` container stays down. MongoDB, Redis and Garage stay in containers; the API and the dashboard run from source. Already running MongoDB, Redis and S3-compatible storage on your machine? Skip this step and point `.env` at them instead — the [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview) lists what to change. ### Configure the environment ```bash cp .env.example .env ``` `.env.example` already holds working values for the containers above — MongoDB and Redis on `localhost`, the Garage S3 API on `127.0.0.1:3900`, the Garage credentials and buckets. Nothing to edit for a local run. The [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview) explains every variable. ### Run faynoSync ```bash # First run and after schema changes go run faynoSync.go migrate up # Start the server go run faynoSync.go --loglevel=debug ``` Roll back migrations with `go run faynoSync.go migrate down`. ### Run the tests ```bash go test ``` The tests run against the real MongoDB, Redis and Garage from your containers — nothing is mocked — so a green run means your local environment is really wired up correctly. Always run the whole suite: the tests share state, so a single `-run` is not supported. ### Benefits: - ⚡ Fast development cycle - 🔧 Direct access to all components - 🧪 Easy debugging - 🔄 Quick code changes ### Run the dashboard The dashboard code lives in the `dashboard/` folder of the same repository. In a second terminal: ```bash cd dashboard yarn install yarn dev # http://localhost:3000/dashboard/, proxies the API on :9000 ``` The dev server gives you hot reload, shares one origin with the API through its proxy, and sends the same Content Security Policy as production. The API on `:9000` also serves a dashboard, but that one is embedded into the binary at compile time from the last `yarn build` — run `yarn build` and restart `go run` to check the production build. The step-by-step version of this option is in the [Local Setup docs (From source tab)](https://faynosync.com/docs/getting-started/local-deploy?local-setup=source). --- ## Which Option Should You Choose? 🤔 | | Option 1: Docker | Option 2: Go from source | |---|---|---| | Setup time | Minutes | A bit longer (Go, Node.js) | | Installed locally | Docker only | Docker + Go + Node.js | | Code change → running | Rebuild the image | Restart `go run`; hot reload for the dashboard | | Debugging the API | Logs only | Full debugger access | | Best for | Trying faynoSync, building integrations, CI-like test runs | Contributing, debugging, daily development | If you're evaluating faynoSync as an update server for your app, start with Option 1. You can switch to Option 2 later — MongoDB, Redis and Garage are the same containers either way. --- ## First steps once it's running A fresh server is empty. To see an actual update check work: 1. **Register an admin.** Open `http://localhost:9000/dashboard/` (or `http://localhost:3000/dashboard/` with the dev server) and sign up. The registration asks for the signup key — that's the `API_KEY` from your environment (`.env.example` in the Docker setup). If `API_KEY` is empty, signup is disabled. 2. **Create the basics.** An application, a channel (for example `stable`), a platform and an architecture. 3. **Upload two versions.** From the dashboard, or from the terminal with the [faynoSync CLI](https://faynosync.com/docs/cli) — the same command later runs in CI. 4. **Check for an update**, the same way your app will: ```bash curl "http://localhost:9000/checkVersion?app_name=myapp&version=0.0.1&owner=admin" ``` If you'd rather follow a complete example end to end — build a real desktop app, upload it, install it, ship a delta update and roll it back — the [self-hosted Velopack update server tutorial](https://faynosync.com/blog/self-hosted-velopack-update-server) runs entirely on this local setup. For Electron apps, see [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update). --- ## Troubleshooting **The API exits right after start.** Most often Redis isn't reachable. Redis is required, and the API exits if it can't connect. Check that the `cache` container is up and `REDIS_HOST` / `REDIS_PORT` match. **Uploads fail right after `docker compose up`.** Garage may still be bootstrapping. Wait for `Garage bootstrap completed successfully.` in `docker compose logs s3`. **Sign up returns an error.** The signup key must match `API_KEY` on the server. With an empty `API_KEY`, the `/signup` endpoint is disabled on purpose. **A port is already in use.** Another service is already listening on one of `9000`, `3000`, `27017`, `6379`, `3900`, `3902` or `3909`. Stop it or change the port mapping in `docker-compose.yaml`. --- ## What's Next? 🚀 Once the local server works, these are good next reads: - [Fetch Latest Version of App — Smart Update Links](https://faynosync.com/blog/fetch-latest-version) — permanent "download latest" links for your website. - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) — Redis caching for update checks. - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) — see which versions your users actually run. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: served by the API at `/dashboard/`, source in [`dashboard/`](https://github.com/ku9nov/faynoSync/tree/main/dashboard) 3. Upload at least two versions of your application. 4. Check for updates with this simple request: 📡 [`/info/latest`](https://faynosync.com/docs/api/info/latest) --- ## Related reading - [Local Setup docs: Docker Compose](https://faynosync.com/docs/getting-started/local-deploy?local-setup=compose) - [Local Setup docs: from source](https://faynosync.com/docs/getting-started/local-deploy?local-setup=source) - [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview) - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## Fetch Latest Version of App — Smart Update Links # Fetch Latest Version of App — Smart Update Links 🔄 Ever needed a download link that always points to the latest version of your app — one you can put on a landing page, in a README, or inside a CI pipeline and never touch again? That's exactly what faynoSync's [`/apps/latest`](https://faynosync.com/docs/api/info/fetch-latest-build) endpoint is for. Instead of hardcoding `myapp-1.2.3.dmg` and updating it on every release, you point users at a stable URL and faynoSync resolves it to the newest matching build. This post walks through the endpoint with copy-paste examples for landing pages, OS auto-detection, shell scripts, and Docker. {/* truncate */} > Looking to check for updates **from inside a running app** (Electron, Tauri, Squirrel)? That's a different endpoint — [`/checkVersion`](https://faynosync.com/docs/api/info/latest). See [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update). `/apps/latest` is for **download links**; `/checkVersion` is for **in-app update checks**. --- ## Meet Our Example App 🎮 Let's imagine we have an app called "SpaceExplorer" with the following setup: ### Channels: - 🚀 `stable` - Production releases - 🧪 `beta` - Testing versions - 🌙 `nightly` - Daily builds ### Platforms & Architectures: - 💻 Linux (amd64, arm64) - 🍎 macOS (amd64, arm64) - 🪟 Windows (amd64) ### Package Types: - 📦 .deb / .rpm (Linux) - 📦 .dmg (macOS) - 📦 .exe (Windows) --- ## Endpoint reference ``` GET /apps/latest?app_name=&channel=stable&platform=linux&arch=amd64&owner=admin ``` | Parameter | Required | Description | |-----------|----------|-------------| | `app_name` | ✅ | The name of the app to fetch | | `channel` | ✅* | Release channel (stable, beta, nightly) | | `platform` | ❌ | Platform (linux, darwin, windows) | | `arch` | ❌ | Architecture (amd64, arm64) | | `package` | ❌ | Package type (deb, rpm, dmg, exe) | | `owner` | ✅ | Your admin user name | \* `channel` becomes **mandatory once you've defined channels** for the app. If your app has no channels configured, you can omit it. Two behaviors make this endpoint convenient: - **Dynamic responses** — the more parameters you supply, the narrower the result. Supply only `app_name` + `channel` and you get every build in that channel; add `platform`/`arch`/`package` to drill down. - **Automatic redirection** — when your filters resolve to a *single* build, faynoSync responds with an HTTP redirect straight to the download URL. Perfect for "Download" buttons. --- ## Let's Fetch Some Versions! 🎯 ### Example 1: Everything in a channel ```bash curl --location 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=stable&owner=admin' ``` Returns all builds in the `stable` channel, grouped by platform → arch → package: ```json { "stable": { "linux": { "amd64": { "deb": { "url": "https://.s3.amazonaws.com/SpaceExplorer/stable/linux/amd64/SpaceExplorer-1.2.3.deb", "version": "1.2.3" }, "rpm": { "url": "https://.s3.amazonaws.com/SpaceExplorer/stable/linux/amd64/SpaceExplorer-1.2.3.rpm", "version": "1.2.3" } }, "arm64": { "deb": { "url": "..." }, "rpm": { "url": "..." } } }, "darwin": { "amd64": { "dmg": { "url": "..." } }, "arm64": { "dmg": { "url": "..." } } }, "windows": { "amd64": { "exe": { "url": "..." } } } } } ``` ### Example 2: Platform-Specific ```bash curl --location 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&owner=admin' ``` Only Linux builds: ```json { "stable": { "linux": { "amd64": { "deb": { "url": "..." }, "rpm": { "url": "..." } }, "arm64": { "deb": { "url": "..." }, "rpm": { "url": "..." } } } } } ``` ### Example 3: Exact match (auto-redirect to download) ```bash curl -L --location 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin' ``` Because this resolves to a single build, faynoSync redirects straight to: ``` https://.s3.amazonaws.com/SpaceExplorer/stable/linux/amd64/SpaceExplorer-1.2.3.deb ``` The `-L` flag tells `curl` to follow the redirect and download the file. --- ## Smart Features 🧠 1. **Automatic Redirection** 🔄 - If only one build matches your filters, you get an HTTP redirect straight to the download. - Ideal for landing pages and direct-download links. 2. **Flexible Responses** 🎯 - Get all builds in a channel, or filter by platform, architecture, and package type. 3. **Stable URLs, moving targets** 🎯 - The link never changes; the file it resolves to advances with every release. --- ## Real-World Use Cases 🌍 ### 1. Landing page download buttons ```html Download for Linux (.deb) Download for macOS (.dmg) ``` ### 2. One "Download" button that auto-detects the OS Detect the visitor's platform in the browser and build the right link on the fly: ```html Download SpaceExplorer ``` ### 3. Install the latest build in a shell script ```bash #!/usr/bin/env bash set -euo pipefail URL="https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin" curl -L "$URL" -o spaceexplorer.deb sudo dpkg -i spaceexplorer.deb ``` ### 4. Always pull the newest build in CI / Docker ```dockerfile FROM ubuntu:24.04 ARG FAYNOSYNC=https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin RUN apt-get update && apt-get install -y curl \ && curl -L "$FAYNOSYNC" -o /tmp/app.deb \ && dpkg -i /tmp/app.deb || apt-get -f install -y ``` ### 5. Beta and nightly channels for testers ```bash # Beta testers curl -L 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=beta&platform=darwin&arch=arm64&package=dmg&owner=admin' # Nightly build enthusiasts curl -L 'http://localhost:9000/apps/latest?app_name=SpaceExplorer&channel=nightly&platform=linux&arch=amd64&package=deb&owner=admin' ``` --- ## Notes and gotchas - **Channel is required once defined.** If your app has channels, every `/apps/latest` request must include `channel` — otherwise the request is rejected. - **`owner` is mandatory** for access control; it must be your admin user name. - **Package types are auto-detected** from what's actually uploaded — you only get a `dmg`/`deb`/`exe` key if such a file exists for that platform/arch. - **"Latest" is per platform and architecture.** A release that ships only for Windows does not break your macOS link — each platform/arch resolves to its own newest published build, and each entry reports its `version`. Builds still in a [staged rollout](https://faynosync.com/docs/rollout) are skipped until they reach 100%. See [Latest per Platform and Architecture](https://faynosync.com/docs/api/info/fetch-latest-build#4-latest-per-platform-and-architecture). - **Redirect only fires for a single match.** If your filters still leave multiple builds (e.g. both `.deb` and `.rpm`), you get JSON instead of a redirect — add `package` to force a direct download. - **Use HTTPS in production** and put faynoSync behind your own domain (e.g. `updates.yourdomain.com`) so download links stay stable and trusted. --- ## It works for native updater apps too `/apps/latest` isn't limited to plain `.deb`/`.dmg` uploads. Apps that use a native updater — Velopack, Squirrel, Sparkle, electron-builder, Tauri — work the same way, and the endpoint returns only their installers. Updater-internal files (release feeds such as `RELEASES`, `releases.{channel}.json`, `latest.yml` or the Sparkle appcast, `.nupkg` and `.delta` packages, `.blockmap` and `.sig` files) are left out — see [Installers Only](https://faynosync.com/docs/api/info/fetch-latest-build#3-installers-only). For example, a [Velopack](https://faynosync.com/docs/updaters/velopack) app on macOS uploads a full and a delta `.nupkg`, its release feed, and a `.pkg` installer. Only the installer is a download target, so the request resolves to a single file: ```bash curl -i 'http://localhost:9000/apps/latest?app_name=HelloVelopack&channel=nightly&platform=darwin&arch=arm64&owner=admin' ``` ``` HTTP/1.1 302 Found Location: .../velopack/admin/HelloVelopack/darwin/arm64/installers/HelloVelopack-nightly-Setup-1.0.4-darwin-arm64.pkg ``` That `302` goes straight to the installer of the newest version — exactly what a "Download for macOS" button needs, no `package` parameter required. From there, the app keeps itself up to date through its own updater. The full Velopack flow is in [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server). --- ## Download links for private apps Everything above assumes a public app. If your app is [private](https://faynosync.com/blog/private-apps), it depends on its download mode: | Download mode | What `/apps/latest` needs | |---|---| | Public app | Nothing | | Private, `unlisted` | Nothing — the link works for anyone who has it | | Private, `strict` | An `X-Download-Token` header with the app's `fnd_…` [download token](https://faynosync.com/docs/api/apps/download-tokens-regenerate), or a JWT of the owning admin or a team user with the `download` permission | Two consequences worth knowing: - **A `strict` app can't be a plain `` link.** Browsers don't send custom headers on a link click. Use `unlisted` for public download pages, or download from a script or backend that adds the header: ```bash curl -L -H "X-Download-Token: $FAYNOSYNC_DOWNLOAD_TOKEN" \ 'https://updates.yourdomain.com/apps/latest?app_name=SpaceExplorer&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin' \ -o spaceexplorer.deb ``` - **A missing token looks like a missing app.** Without a valid credential, a `strict` app answers exactly like an app that doesn't exist, so nobody can probe which private apps you have. Responses of private apps are also never cached in Redis. --- ## `/apps/latest` vs `/checkVersion` Both deal with "the newest version", but they answer different questions: | | `/apps/latest` | `/checkVersion` | |---|---|---| | Question | "Where do I download the newest build?" | "Is there an update for *my* version?" | | Caller | Website, README, script, CI | The running app | | Needs the client's version | No | Yes | | Knows about critical and intermediate versions | No | Yes | | Typical answer | Redirect to the file, or a list of files | `update_available`, changelog, update URLs | Rule of thumb: `/apps/latest` for **first installs**, `/checkVersion` (or a native updater feed) for **updates** after that. Details: [Fetch Latest Version of App](https://faynosync.com/docs/api/info/fetch-latest-build) and [Check Version](https://faynosync.com/docs/api/info/latest). --- ## Troubleshooting **`{"error":"Parameters 'app_name' and 'channel' are required"}`** The app has channels, so `channel` is mandatory. Add it to the query. **`{"error":"app_name not found in apps_meta collection"}`** Either the app name or `owner` is wrong, or the app is private in `strict` mode and the request came without a valid credential. **You get JSON instead of a download.** Your filters still match more than one file — for example both `.deb` and `.rpm`, or a feed plus an installer. Add `package` (and `platform` / `arch`) until exactly one file matches. **The link points to an old version.** `/apps/latest` only looks at **published** versions. Check that the newest version is published in the dashboard. --- ## What's Next? 🚀 - Serving thousands of download checks per minute? See [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode). - Want self-updating desktop apps instead of manual downloads? See [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update). - Running multiple admins with same-named apps? The required `owner` parameter comes from [Team-Based Authorization](https://faynosync.com/blog/team-based-auth). --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Upload at least two versions of your application. 4. Build a smart download link with [`/apps/latest`](https://faynosync.com/docs/api/info/fetch-latest-build). --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## Performance Mode — Speed Up Your API # Performance Mode — Speed Up Your API ⚡ Update checks are bursty and repetitive. Every running copy of your app polls the same `/checkVersion` endpoint with nearly identical parameters, and each call validates the app, channel, platform, and architecture against MongoDB. At a few hundred clients that's fine. At thousands it's the same handful of database queries, repeated thousands of times a minute. Performance Mode fixes that with caching. This post explains how it works, how to configure Redis, how cache invalidation keeps results correct, and how to stack an optional Nginx microcache on top for a multi-layer strategy. {/* truncate */} --- ## What is Performance Mode? 🤔 Performance Mode is a Redis-backed caching layer for the API that: - 🚀 Reduces response times - 📉 Lowers database load - 💾 Optimizes resource usage When enabled, faynoSync caches the answers of its two hottest read endpoints in Redis and serves repeat requests straight from memory instead of re-querying MongoDB. See the [Performance Mode docs](https://faynosync.com/docs/performance) for the reference. --- ## How It Works 🛠️ ```text ┌─────────────────────────────┐ client ──────▶ │ GET /checkVersion │ └──────────────┬──────────────┘ │ PERFORMANCE_MODE=true? │ ┌──────────────┴──────────────┐ ▼ ▼ look up in Redis (disabled) query │ MongoDB every time ┌────────┴────────┐ ▼ ▼ cache HIT cache MISS return from query MongoDB, Redis (fast) store in Redis, then return ``` 1. **Request arrives** — the API checks whether `PERFORMANCE_MODE=true`. 2. **Cache check** — it looks for cached data in Redis for that request. - HIT → return immediately. - MISS → query MongoDB, store the result in Redis, then return. 3. **Next request** — the same query is now a cache hit. ### What gets cached? | Endpoint | Who calls it | Cached | |---|---|---| | [`/checkVersion`](https://faynosync.com/docs/api/info/latest) | Every running copy of your app, on every update check | Yes | | [`/apps/latest`](https://faynosync.com/docs/api/info/fetch-latest-build) | Download buttons, install scripts, CI | Yes | | Anything for a [private app](https://faynosync.com/blog/private-apps) | Clients with a download token | **Never** | Private apps are skipped on purpose: their answers depend on who is asking, so a shared cache entry could leak them to someone without access. Every cached entry lives for **24 hours** at most, and is usually cleared much earlier by the invalidation rules below. :::note Updated in September 2026 This post originally said Performance Mode covers only `/checkVersion`. It now caches `/apps/latest` as well, and cache invalidation covers more than new uploads. The sections below reflect the current behavior. ::: --- ## Why Do We Need It? 🎯 - **Database load reduction** — far fewer MongoDB queries for the same repeated update checks. - **Faster response times** — cached responses come from memory, no database round-trip. - **Resource optimization** — lower CPU and DB pressure means the same hardware scales further. This matters most exactly when it's hardest: traffic spikes right after you publish a release and every client checks in at once. --- ## How to Enable It 🔌 Enable the mode and point faynoSync at your Redis instance in `.env`: ```bash PERFORMANCE_MODE=true REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 ``` | Variable | Default | Description | |----------|---------|-------------| | `PERFORMANCE_MODE` | `false` | Master switch for Redis caching | | `REDIS_HOST` | `localhost` | Redis hostname or IP | | `REDIS_PORT` | `6379` | Redis port | | `REDIS_PASSWORD` | _(empty)_ | Leave empty if Redis has no auth | | `REDIS_DB` | `0` | Redis database number | That's it — once `PERFORMANCE_MODE=true` and Redis is reachable, the API starts caching automatically. Note that Redis isn't an extra dependency you add for this feature: faynoSync already requires Redis and won't start without it. `PERFORMANCE_MODE` only decides whether responses get cached there. [`/health`](https://faynosync.com/docs/api/info/health) pings Redis whether or not the mode is on, and reports `unhealthy` when it's unreachable — handy for load balancer health checks. Full reference in the [environment overview](https://faynosync.com/docs/getting-started/env-overview). --- ## Cache invalidation — staying correct 🔄 A cache is only useful if it never serves stale "latest version" info. faynoSync handles this for you: The cache of an app's channel is cleared whenever something in that channel changes what clients should see: - a version is **uploaded** with a `publish` value (through `/upload` or [presigned uploads](https://faynosync.com/docs/presigned-uploads)), - a version is **updated** with a `publish` value — publishing, unpublishing, or editing it in the dashboard, - a version is **deleted**. The next request after that goes to MongoDB, gets the fresh answer, and caches it again. So the moment you publish a release — or pull a broken one — the old cached answer is gone. No manual flushing, no risk of pinning users to an old build. Invalidation is scoped to the **channel**. Publishing to `beta` doesn't throw away the warm cache for `stable`. --- ## Going further: add an Nginx microcache 🧱 For very high traffic you can stack a second cache layer in front of the API. Nginx can microcache `/checkVersion` responses for a few seconds, absorbing bursts before they ever reach faynoSync. ```nginx # In the http block proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=checkversion_cache:10m inactive=60s max_size=100m; # In the server block location = /checkVersion { proxy_pass http://127.0.0.1:9000; proxy_cache checkversion_cache; proxy_cache_valid 200 60s; proxy_cache_key "$scheme$request_method$host$request_uri"; # Serve stale cache if the backend is slow or erroring proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; proxy_cache_lock on; proxy_cache_background_update on; add_header X-Cache-Status $upstream_cache_status; } ``` This gives you a **two-layer strategy**: Nginx absorbs identical requests for ~60s at the edge of your server, and Redis (Performance Mode) handles everything that gets through. The `X-Cache-Status` header tells you what happened: - `HIT` — served from Nginx - `MISS` — went to the backend - `UPDATING` — cache being refreshed in the background - `STALE` — backend was unhealthy, stale copy served The full Nginx block (with rate limiting and proxy headers) is in the [Performance Mode docs](https://faynosync.com/docs/performance). ### Two things to keep in mind with Nginx caching **1. Nginx doesn't know when you publish.** faynoSync clears its Redis cache the moment a release changes, but it can't reach into Nginx. With `proxy_cache_valid 200 60s`, clients can keep getting the previous answer for up to 60 seconds after you publish — or after you unpublish a broken version. For most apps a minute is fine. If you need rollbacks to take effect faster, lower the value to a few seconds: even a short microcache still absorbs the burst. **2. Don't share cached answers of private apps.** Clients of a private app in `strict` mode send an `X-Download-Token` header, and the cache key above doesn't include it. Without extra rules, Nginx could store a private answer and hand it to a request without a token. Skip the Nginx cache for any request that carries credentials: ```nginx location = /checkVersion { # ... everything from above ... proxy_cache_bypass $http_x_download_token $http_authorization; proxy_no_cache $http_x_download_token $http_authorization; } ``` faynoSync itself never caches private apps in Redis, so this only matters for the Nginx layer. --- ## Measuring the difference 🧪 Don't guess — measure on your own hardware. This script fires 1000 sequential `/checkVersion` requests and reports timing: ```bash start_time=$(date +%s); for i in {1..1000}; do response=$(curl -s -w "Connect Time: %{time_connect}s, Start Transfer Time: %{time_starttransfer}s, Total Time: %{time_total}s\n" -o /dev/stdout --location 'http://localhost:9000/checkVersion?app_name=myapp&version=0.0.3&channel=nightly&platform=null&arch=null'); echo "$response"; done; end_time=$(date +%s); total_time=$((end_time - start_time)); echo "Total execution time: $total_time seconds" ``` Run it twice — once with `PERFORMANCE_MODE=false`, once with `true` — and compare `Total execution time` and `Start Transfer Time` (time to first byte). Absolute numbers depend on your hardware, network, and Redis setup, so treat your own measurements as the source of truth. --- ## When to use it ⚡ **Use Performance Mode when:** - You expect high or bursty traffic (especially right after publishing releases). - Many clients poll `/checkVersion` frequently. - Response time is critical. **It matters less when:** - You have a small, low-traffic deployment. - Update checks are rare. In production with real users, the recommendation is simple: turn it on. --- ## Performance Mode vs Nginx vs Edge caching faynoSync gives you three independent cache layers. You can use one, two, or all three: | | Performance Mode | Nginx microcache | [Edge + S3 cache](https://faynosync.com/docs/edge) | |---|---|---|---| | Where | Redis, inside the API | Your reverse proxy | Object storage / CDN | | What it saves | MongoDB queries | Requests reaching the API | Requests reaching your server at all | | Freshness after publish | Immediate (invalidated) | Up to `proxy_cache_valid` | Invalidated by faynoSync | | Clients | Any | Any | Official SDKs with `EdgeURL` | | Setup | One env variable | Nginx config | Bucket + SDK config | A good path for most teams: turn on Performance Mode first, add the Nginx microcache when a single API instance gets busy, and move to edge caching when you have a large or global install base. The why and how of that last step is in [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache). --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Upload at least two versions of your application. 4. Check for updates with this simple request: 📡 [`/info/latest`](https://faynosync.com/docs/api/info/latest) --- ## Related reading - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) - [Fetch Latest Version of App — Smart Update Links](https://faynosync.com/blog/fetch-latest-version) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Performance Mode docs](https://faynosync.com/docs/performance) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## How to Setup Auto Update for Electron App # How to Setup Auto Update for Electron App 🔄 Shipping an Electron app is easy. Keeping every user on the latest version is the hard part. Unlike apps on the App Store or Google Play, a desktop app you distribute yourself has no built-in update channel — so you have to build one. This guide shows how to add automatic updates to an Electron app using [faynoSync](https://faynosync.com/docs/intro), a self-hosted, open-source update server. We'll cover two approaches: {/* truncate */} - **A custom update flow** — you call faynoSync's `checkVersion` API yourself and control the UX end to end. - **Native `electron-updater` integration** — faynoSync speaks the electron-builder protocol, so the standard updater works out of the box. We'll also cover the parts most tutorials skip: code signing, critical updates, changelogs, and what to do when updates silently don't work. --- ## Two ways to auto-update an Electron app | | Custom flow | electron-updater (electron-builder) | |---|---|---| | Control over UX | Full | Limited to autoUpdater events | | Install handling | You implement it | Automatic (download + relaunch) | | Code signing required | No (for download links) | Yes (macOS/Windows) | | Best for | Custom installers, portable apps | Standard packaged apps (.dmg/.nsis/.AppImage) | | faynoSync support | `manual` updater | `electron-builder` updater | If you just want the app to update itself with minimal code, go with `electron-updater`. If you need full control over the download and install step, use the custom flow. Both talk to the same faynoSync instance. --- ## Prerequisites 📋 Before we begin, make sure you have: - A running faynoSync instance — see [Getting Started](https://faynosync.com/docs/getting-started) - Published versions 0.0.1 and 0.0.2 of your app - Created an app in faynoSync named "myapp" - Created a channel in faynoSync named "nightly" - Created a platform in faynoSync named (darwin/linux/windows) - Created an architecture in faynoSync named (amd64/arm64) --- ## Approach A: Custom update flow This approach gives you complete control: you ask faynoSync whether an update exists and decide how to present it. Under the hood it uses the default `manual` updater, which returns plain JSON with direct download URLs. ### 1. Initialize Your Project First, let's create a new project with the following `package.json`: ```json { "name": "myapp", "version": "0.0.1", "description": "Hello world app for testing faynoSync", "main": "main.js", "scripts": { "start": "electron ." }, "keywords": [], "author": "Example Author", "license": "ISC", "dependencies": { "node-fetch": "2.6.9" }, "devDependencies": { "electron": ">=23.3.13" }, "engines": { "npm": ">=8.19.3", "node": ">=18.13.0" } } ``` ### 2. Create Basic Files #### index.html ```html Hello, world! ``` #### config.js ```javascript const packageJson = require('./package.json'); module.exports = { app_name: packageJson.name, version: packageJson.version, channel: "nightly", owner: "example", }; ``` --- ## The Magic of Auto-Updates ✨ Let's create our `main.js` file step by step: ### 1. Import Required Modules ```javascript const { app, BrowserWindow, dialog, shell } = require('electron'); const fetch = require('node-fetch'); const os = require('os'); const { version, app_name, channel, owner } = require('./config.js'); const fs = require('fs'); ``` ### 2. Platform Detection ```javascript function getLinuxDistributionFamily() { let distroFamily = 'Linux'; try { const releaseInfo = fs.readFileSync('/etc/os-release', 'utf8'); const match = releaseInfo.match(/^ID(?:_LIKE)?=(.*)$/m); if (match) { const idLike = match[1].trim().toLowerCase(); if (idLike.includes('rhel') || idLike.includes('fedora') || idLike.includes('centos')) { distroFamily = 'RHEL'; } else if (idLike.includes('debian') || idLike.includes('ubuntu') || idLike.includes('kali')) { distroFamily = 'Debian'; } } } catch (err) { console.error('Error getting Linux distribution family:', err); } return distroFamily; } ``` ### 3. Update Choice Window ```javascript function createChoiceWindow(updateOptions) { const win = new BrowserWindow({ width: 600, height: 400, webPreferences: { nodeIntegration: true, }, }); win.loadURL(`data:text/html, Choose an update package: ${updateOptions .map( (option, index) => `${option.name}` ) .join('')} ` ); return win; } ``` ### 4. Update Check Function ```javascript function checkUpdates() { let url = `http://localhost:9000/checkVersion?app_name=${app_name}&version=${version}&platform=${os.platform()}&arch=${os.arch()}&owner=${owner}`; if (channel !== undefined) { url += `&channel=${channel}`; } fetch(url, { method: 'GET' }) .then((res) => res.json()) .then((data) => { console.log(data); if (data.update_available) { const message = data.critical ? `A critical update is available. Please update now.` : `You have an older version. Would you like to update your app?`; dialog.showMessageBox({ type: 'question', title: data.critical ? 'Critical update' : 'Update available', message: message, detail: data.changelog || undefined, buttons: ['Yes', 'No'], defaultId: 0, }).then(({ response }) => { if (response === 0) { const updateOptions = []; for (const key in data) { if (key.startsWith('update_url_')) { updateOptions.push({ name: key.substring(11).toUpperCase(), url: data[key] }); } } const choiceWindow = createChoiceWindow(updateOptions); } }); } }) .catch(() => {}); } ``` Notice we now read two extra fields from the response: `critical` and `changelog`. faynoSync returns these automatically — `critical` lets you force the prompt, and `changelog` shows users what changed. We'll cover them in detail below. ### 5. Main Window Creation ```javascript function createWindow() { let osName = os.platform(); let pcArch = os.arch(); if (osName === 'linux') { osName = getLinuxDistributionFamily(); } const title = `${app_name} - v${version} (${osName}-${pcArch})`; let win = new BrowserWindow({ width: 400, height: 300, webPreferences: { nodeIntegration: true, }, }); win.setTitle(title); win.loadFile('index.html'); win.on('closed', () => { win = null; }); checkUpdates(); } app.whenReady().then(createWindow); ``` ### Running Your App 🏃‍♂️ To start your app, simply run: ```bash npm start ``` If a newer version exists, you'll see something like this in your logs: ```json { "critical": false, "update_available": true, "update_url_dmg": "http://localhost:9010/cb-faynosync-s3-public/myapp-example/nightly/darwin/arm64/myapp-0.0.2.0.dmg" } ``` And in your app's UI, you'll see a notification about the available update. After agreeing, the user downloads and installs the new version. --- ## Approach B: Native electron-updater integration Most production Electron apps are packaged with [electron-builder](https://www.electron.build/) and use its `electron-updater` module. The good news: faynoSync ships an `electron-builder` updater that returns the exact YAML feed (`latest.yml`, `latest-mac.yml`, `latest-linux.yml`) the updater expects — so you don't need GitHub Releases or an S3 generic provider. ### 1. Upload builds for the electron-builder updater When uploading, pass the `updater` parameter so faynoSync isolates the artifacts and generates the matching `*.yml`: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path/to/myapp-0.0.2.dmg"' \ --form 'file=@"/path/to/latest-mac.yml"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.2\",\"channel\":\"nightly\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"arm64\",\"updater\":\"electron-builder\"}"' ``` ### 2. Point electron-updater at faynoSync In your app, configure the feed to use the `generic` provider and faynoSync's electron-builder endpoint: ```javascript const { autoUpdater } = require('electron-updater'); autoUpdater.setFeedURL({ provider: 'generic', url: 'http://localhost:9000/checkVersion?app_name=myapp&channel=nightly&platform=darwin&arch=arm64&owner=example&updater=electron-builder', }); ``` faynoSync responds with electron-builder-compatible YAML: ```yaml version: 0.0.2 files: - url: myapp-0.0.2.dmg sha512: size: path: myapp-0.0.2.dmg sha512: releaseDate: '2026-01-15T10:00:00.000Z' ``` See the [Updaters Support](https://faynosync.com/docs/updaters) doc for the full list of supported updaters (`squirrel_windows`, `squirrel_darwin`, `tauri`, and more). ### 3. Wire up the autoUpdater events ```javascript const { app, dialog } = require('electron'); const { autoUpdater } = require('electron-updater'); app.whenReady().then(() => { autoUpdater.checkForUpdates(); }); autoUpdater.on('update-available', (info) => { console.log('Update available:', info.version); }); autoUpdater.on('download-progress', (progress) => { console.log(`Downloaded ${Math.round(progress.percent)}%`); }); autoUpdater.on('update-downloaded', () => { dialog.showMessageBox({ type: 'info', title: 'Update ready', message: 'A new version has been downloaded. Restart to apply it?', buttons: ['Restart', 'Later'], }).then(({ response }) => { if (response === 0) autoUpdater.quitAndInstall(); }); }); autoUpdater.on('error', (err) => { console.error('Update error:', err); }); ``` That's the whole flow: `electron-updater` downloads the package in the background and `quitAndInstall()` swaps the binary and relaunches. --- ## Code signing and notarization `electron-updater` will **refuse to install unsigned updates** on macOS and Windows. This is the single most common reason auto-updates "work in dev but not in production." Before shipping: - **macOS** — sign with a Developer ID certificate and notarize the app, otherwise Gatekeeper blocks it and the updater silently fails. With electron-builder, set `mac.notarize` and provide `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` / `APPLE_TEAM_ID` in your build environment. - **Windows** — sign the installer with an Authenticode certificate (EV or OV). Unsigned installers trigger SmartScreen warnings and break differential updates. If you don't want to buy a traditional certificate, Microsoft's [Azure Trusted Signing](https://azure.microsoft.com/en-us/products/trusted-signing) offers a fully managed signing service for about **$9.99/month** — a cheap, modern alternative for individual developers and small teams. - **Linux** — AppImage updates don't require signing, which makes Linux the easiest target to test the flow first. The custom flow (Approach A) sidesteps the updater's signature checks because you open download links yourself — but you still owe your users signed binaries. --- ## Critical updates, changelogs, and required intermediate builds faynoSync's `checkVersion` response carries three fields that make updates smarter: - **`critical`** — mark a release as critical at upload time, and the API flags it here. Use it to make the update prompt non-dismissible. - **`changelog`** — markdown release notes returned alongside the update, so you can show "What's new" without a second request. - **`is_intermediate_required`** — an informational flag. When a mandatory intermediate build exists between the user's version and latest, faynoSync returns the download link for that **intermediate** version (not latest) and sets this field to `true`. The intermediate build must be installed before the latest one — it's used for migrations that can't be skipped, such as database schema changes. ```json { "update_available": true, "critical": true, "is_intermediate_required": true, "changelog": "### Changelog\n\n- Added feature X\n- Fixed bug Y", "update_url_dmg": "https://.s3.amazonaws.com/myapp/stable/darwin/amd64/myapp-0.0.5.dmg" } ``` If `is_intermediate_required` is `true`, prompt the user to install the returned version, then check again — repeat until they reach latest. Read more in [Required Intermediate Build](https://faynosync.com/docs/intermediate_build). --- ## Troubleshooting | Symptom | Likely cause | |---|---| | `update_available` is always `false` | `version` in the request matches the latest, or wrong `channel`/`platform`/`arch` | | electron-updater says "No published versions" | The `*.yml` feed wasn't uploaded, or `updater=electron-builder` was omitted on upload | | Update downloads but won't install (macOS/Windows) | App or installer isn't code signed / notarized | | Works locally, fails in packaged app | `setFeedURL` points to `localhost` — use your public faynoSync URL in production | | 401 on upload | Missing or expired JWT — re-authenticate against `/login` | Tip: run faynoSync's [`/info/latest`](https://faynosync.com/docs/api/info/latest) endpoint manually with `curl` to confirm the server returns what you expect before debugging the client. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Upload at least two versions of your application. 4. Check for updates with this simple request: 📡 [`/info/latest`](https://faynosync.com/docs/api/info/latest) --- ## Related reading - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) - [Fetch Latest Version of App — Smart Update Links](https://faynosync.com/blog/fetch-latest-version) - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) - [Updaters Support](https://faynosync.com/docs/updaters) --- ## Need Help? 🤝 If you have any questions or need assistance: 1. Check out our [documentation](https://faynosync.com/docs/intro) 2. Create an issue on [GitHub](https://github.com/ku9nov/faynoSync) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- ## Telemetry Insights — Understand Your Users Better 📊 Once your app is in users' hands, the obvious questions follow: How many people actually run it? Which version are they on? Which platforms should you prioritize? Shipping updates blind is guesswork — faynoSync's built-in telemetry turns those questions into data. The best part: it's privacy-first and you already have most of the plumbing. Telemetry is collected from the same [`/checkVersion`](https://faynosync.com/docs/api/info/latest) calls your clients already make — you just add one header. This post covers what's tracked, the critical `X-Device-ID` header, how to enable it, the beacon endpoint for SDK/edge flows, and how to read the data. {/* truncate */} --- ## What is Telemetry? 🤔 Telemetry is faynoSync's built-in analytics layer that: - 📈 Tracks user engagement and active clients - 🔍 Monitors version distribution and adoption - 🌍 Analyzes platform and architecture usage - 📊 Aggregates it all into daily stats It helps you make data-driven decisions about your application's future. Full reference: [Telemetry System docs](https://faynosync.com/docs/telemetry). --- ## What You Can Track 🎯 ### 1. Client Analytics - 👥 Unique clients and active users - 📱 Platform distribution - 💻 Architecture breakdown ### 2. Version Insights - 📦 Version adoption rates - 🎯 Clients on the latest version vs. outdated clients - 📈 Version distribution trends ### 3. Channel Analytics - 🎨 Channel popularity (stable, beta, nightly, alpha) - 📊 Per-channel client counts --- ## How it works Telemetry data is collected **automatically** when clients call the `/checkVersion` endpoint — as long as they send the `X-Device-ID` header. For each request faynoSync records the device ID, app name, platform, channel, architecture, and reported version. The data is stored **in Redis with a 30-day retention period**, aggregated daily, and organized by administrator → application → date → metric. No separate database, no extra service. --- ## The `X-Device-ID` header — the part that matters most Telemetry is only collected when the client sends an `X-Device-ID` header. Without it, the request still works — but it won't count toward your stats. This ID is what lets faynoSync count *unique devices* instead of raw requests. ```http GET /checkVersion?app_name=myapp&version=1.0.0&channel=stable&platform=darwin&arch=arm64 X-Device-ID: 3f6c1a8e-9b2d-4c77-bc1e-2a9f0d5e7a11 ``` ### Implementation guidelines - **Generate a UUID v4** (or similar) on first launch. - **Persist it** in secure, durable storage on the device. - **Reuse the same ID** across app restarts and updates. - **Never change it** for an existing installation, or you'll double-count that user. ### Keep it anonymous The device ID must **not** contain any personal data: - ❌ No names, emails, or account identifiers - ❌ No IP addresses - ❌ No sensitive data It's an opaque identifier and nothing more. This is what keeps faynoSync telemetry privacy-first by design — you control the ID, and a random UUID reveals nothing about the user. --- ## How to Enable It 🔌 Add this to your `.env` file: ```bash ENABLE_TELEMETRY=true ``` That's the server side. Then make sure your clients send `X-Device-ID` on their `/checkVersion` calls, and stats start accumulating automatically. --- ## Collecting telemetry without checkVersion: the beacon endpoint If your client doesn't call `/checkVersion` — for example, [SDK](https://faynosync.com/docs/sdk/intro) or edge flows where updates come from edge infrastructure — use the dedicated `/telemetry/beacon` endpoint instead. It records the same data but is optimized for speed, validating requests against an in-memory snapshot rather than reading full metadata per call. ```bash curl --location 'http://localhost:9000/telemetry/beacon?app_name=myapp&version=1.0.0&channel=stable&platform=linux&arch=amd64&owner=admin' \ --header 'X-Device-ID: 3f6c1a8e-9b2d-4c77-bc1e-2a9f0d5e7a11' ``` - Requires no auth token, but **all query parameters and `X-Device-ID` are required**. - Returns `204 No Content` on success. - If you already call `/checkVersion`, you do **not** need the beacon. See [Telemetry beacon](https://faynosync.com/docs/api/info/telemetry_beacon) and [Edge and S3 Response Cache](https://faynosync.com/docs/edge) for the architecture behind it. --- ## How to Access Insights 📊 ### Dashboard Log in to your admin dashboard and open the Telemetry section to explore charts for clients, versions, platforms, and channels. ### API ```http GET /telemetry?range=week ``` Available query parameters: | Parameter | Description | |-----------|-------------| | `date` | Specific date (`YYYY-MM-DD`) | | `range` | Time range: `week` or `month` | | `apps` | Filter by application(s) | | `channels` | Filter by channel(s) | | `platforms` | Filter by platform(s) | | `architectures` | Filter by architecture(s) | ### Reading the response The API returns aggregated stats. The `summary` block is where the headline numbers live: ```json { "date": "2026-06-16", "admin": "ku9n", "summary": { "total_requests": 1765, "unique_clients": 1751, "clients_using_latest_version": 167, "clients_outdated": 1584, "total_active_apps": 5 }, "platforms": [ { "platform": "windows", "client_count": 229 }, { "platform": "linux", "client_count": 215 }, { "platform": "darwin", "client_count": 207 } ], "channels": [ { "channel": "stable", "client_count": 448 }, { "channel": "beta", "client_count": 446 }, { "channel": "nightly", "client_count": 435 } ], "daily_stats": [ { "date": "2026-06-15", "total_requests": 400, "unique_clients": 400, "clients_using_latest_version": 37, "clients_outdated": 363 } ] } ``` What the key fields tell you: - **`unique_clients`** — real device count (thanks to `X-Device-ID`), not inflated by repeat polling. - **`clients_using_latest_version` vs `clients_outdated`** — your update adoption rate at a glance. In the example above, most clients are still on older builds — a clear signal to investigate why updates aren't landing. - **`platforms` / `architectures` / `channels`** — where to focus QA and build effort. - **`daily_stats`** — trends over time: growing user base, adoption after a release, lingering outdated clients. The full response also includes a `versions` block with per-version client counts. See the [complete example](https://faynosync.com/docs/telemetry#example-response) in the docs. --- ## Access control & privacy 🔒 - **Per-admin isolation** — each administrator sees telemetry only for their own apps. - **Team users** — can view all statistics but can only filter/sort resources they have access to; access is verified by the API. - **Anonymous by design** — identification is via the opaque `X-Device-ID` you generate; no PII, no IP addresses. - **30-day retention** — data ages out automatically. --- ## Limitations 📝 1. Data is retained for 30 days. 2. Statistics are only collected when `X-Device-ID` is provided. 3. Team users have limited filtering capabilities. 4. Data is aggregated daily (not real-time per request). --- ## Best Practices 💡 - **Enable from day one** — telemetry is only as good as its history; you can't backfill. - **Generate device IDs correctly** — UUID v4, persistent, never reused or mutated. - **Review trends weekly** — watch adoption after each release and chase down outdated-client spikes. - **Let the data drive the roadmap** — prioritize the platforms and channels your users actually run. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Set `ENABLE_TELEMETRY=true` and send `X-Device-ID` from your clients. 4. Open the Telemetry dashboard and start collecting insights. 📊 --- ## Related reading - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) - [Telemetry System docs](https://faynosync.com/docs/telemetry) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing 💚 --- --- ## Rollout Health Reports — Catch Failed Updates Before They Spread [Telemetry](https://faynosync.com/blog/telemetry-insights) tells you who's running which version. It doesn't tell you when an update *fails* — when the download checksum doesn't match, the installer hits a full disk, or the app panics on first launch after a release. Those failures are exactly the ones you want to catch in the first hour of a rollout, before they reach the rest of your install base. faynoSync's **report ingestion** fills that gap. Clients send short operational failure reports to a public endpoint; the server validates, groups, and aggregates them into a rollout-health picture by app, version, channel, platform, and architecture. The design is deliberately narrow: cheap to ingest, stable for aggregation, and bounded in storage — **not** a logging pipeline, an APM, or a Sentry replacement. {/* truncate */} This post covers what reports are, how ingestion works, the privacy boundary, and how to read rollout health from the aggregated groups. Everything here is verified against the [Reports Management docs](https://faynosync.com/docs/api/reports/reports-introduction). --- ## What reports are (and aren't) Reports give you: - A simple **public** endpoint for clients to send update/install/runtime failure events. - An **aggregated** view of rollout health grouped by stable technical dimensions. - Optional, size-bounded debug **details** stored separately for support investigations. - Groundwork for a future auto-pause / rollback decision engine. Reports are explicitly **not** a general-purpose logging pipeline, a full APM, or a crash-analytics platform — and they avoid collecting user identity by design. Reports also never feed update-metadata trust decisions: signing, expiration, version monotonicity, and rollback protection are untouched. --- ## Enabling reports Ingestion is gated by a dedicated environment flag, similar to TUF routes. When `REPORTS_ENABLED=false`, **neither** the ingestion endpoint nor the read API is registered. ```env REPORTS_ENABLED=true REPORTS_MAX_BODY_BYTES=262144 REPORTS_MAX_DETAILS_COMPRESSED_BYTES=131072 REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES=1048576 REPORTS_BLOB_RETENTION_DAYS=30 REPORTS_MAX_BLOBS_PER_GROUP=10 REPORTS_STORAGE_PREFIX=reports REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE=100 ``` Two things to know: - Enabling reports **forces a Redis connection**, since rate limits depend on Redis. - Detail blobs reuse the existing `STORAGE_DRIVER` / `S3_*` configuration but are written to the **private** bucket (`S3_BUCKET_NAME_PRIVATE`) and are only retrievable through short-lived presigned URLs. Full variable reference: [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview). --- ## Two auth paths: clients ingest, admins read Reports split cleanly into a public write path and an authenticated read path. | Path | Endpoint | Auth | |------|----------|------| | **Ingestion** (clients) | `POST /reports/ingest` | Per-app **report key** `rpk_<64 hex>` as `Authorization: Bearer rpk_...` — no JWT | | **Read** (admins/team) | `GET /reports/groups`, `GET /reports/groups/:groupHash/blobs` | JWT, gated by `CheckPermission(download, apps)` | Report keys are managed through the existing `report_keys` lifecycle — one key per app with `reports` enabled. They're effectively **public client credentials** shipped inside your app, so abuse protection comes from the `REPORTS_ENABLED` gate, the per-app `reports` flag, and rate limits — not from key secrecy. On the read side, admins see every app under their account; team users only see apps in their `allowed_apps`. Scoping is enforced in the repository: every query filters on the requester's accessible `app_id`s, so cross-owner access is impossible even when two owners' reports share a hash. --- ## Sending a report A minimal report is self-contained and costs exactly one MongoDB upsert: ```bash curl -i -X POST 'http://localhost:9000/reports/ingest' \ --header 'Authorization: Bearer rpk_87077831a3c0c3f5a3cca1b1a5441e36033550708e92b832166d8550ba847315' \ --header 'X-Device-ID: 7f3c9a2e-1b4d-4c8a-9f12-abc123def456' \ --header 'Content-Type: application/json' \ --data '{ "application": { "name": "test", "version": "1.4.2", "channel": "stable" }, "system": { "platform": "windows", "arch": "amd64" }, "event": { "type": "update_failure", "reason": "checksum_mismatch" } }' ``` ```json { "status": "accepted", "group_hash": "9f2b...", "stored_details": false } ``` Every field except `details` is required: `application.name/version/channel`, `system.platform/arch`, and `event.type/reason`. The server validates `name`, `channel`, `platform`, `arch`, and `version` with the **same validators telemetry ingestion uses** — and verifies the report key actually belongs to `application.name`, so you can't accidentally point one app's key at another. ### Event type is a strict enum Clients cannot invent event types. New types require explicit server support, which keeps analytics consistent across SDKs: | Value | Meaning | |-------|---------| | `crash` | Application crashed at runtime | | `startup_failure` | Application failed to start | | `update_failure` | An update failed to apply | | `install_failure` | An install failed | | `rollback_failure` | A rollback failed | ### Reason is an identifier, not a message `event.reason` is a short machine-readable identifier matching `^[a-zA-Z0-9._-]{1,128}$` — used for grouping, filtering, and alerting. It must **not** contain stack traces, HTML, logs, or binary data. Good values: `checksum_mismatch`, `disk_full`, `access_denied`, `missing_dependency`, `panic_nil_pointer`, `signature_verification_failed`. The split matters: `reason` drives grouping and charts; human-readable debugging context goes into optional `details`, which never affects grouping. Unlike `event.type`, SDKs can introduce **new reasons without a server deploy** — only new event types need server support. --- ## Optional details: bounded, private, debug-only When you need more than a reason, attach a `details` blob. It's the JSON debug object, gzip-compressed then base64-encoded: ```bash PAYLOAD=$(printf '{"message":"sha mismatch","stack":"..."}' | gzip | base64 -w0) curl -i -X POST 'http://localhost:9000/reports/ingest' \ --header 'Authorization: Bearer rpk_8707...7315' \ --header 'X-Device-ID: 7f3c9a2e-1b4d-4c8a-9f12-abc123def456' \ --header 'Content-Type: application/json' \ --data "{ \"application\": { \"name\": \"test\", \"version\": \"1.4.2\", \"channel\": \"stable\" }, \"system\": { \"platform\": \"windows\", \"arch\": \"amd64\" }, \"event\": { \"type\": \"crash\", \"reason\": \"panic_nil_pointer\" }, \"details\": { \"encoding\": \"gzip+base64\", \"content_type\": \"application/json\", \"payload\": \"$PAYLOAD\" } }" ``` ```json { "status": "accepted", "group_hash": "1ab3...", "stored_details": true } ``` The server checks the compressed size, base64-decodes, then decompresses the gzip stream **under a hard decompressed-size limit** (`REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES`) enforced with `io.LimitReader`. The client-declared size is never trusted — that's the zip-bomb guard. The compressed bytes land in the private bucket; metadata goes to `report_blobs`. `stored_details` is `true` only when the blob was actually written. On a storage outage the response is still `202`, the base count is preserved, and `stats.detailsRejected` is incremented — the *fact* of the failure is never lost just because the debug payload couldn't be stored. Storage stays bounded: faynoSync keeps the latest `REPORTS_MAX_BLOBS_PER_GROUP` blobs per group, and each blob has a TTL of `REPORTS_BLOB_RETENTION_DAYS`. No unbounded arrays in Mongo, no uncontrolled blob growth. --- ## How grouping works Every report is reduced to a deterministic `groupHash` built **only** from stable dimensions: ```text sha256(name | version | channel | platform | arch | event.type | event.reason) ``` Stack traces, logs, timestamps, client IP, and device identifiers are **excluded** from the hash. That's the whole point: if details affected grouping, every unique stack trace would spawn its own group and the signal would drown in noise. Instead, ten thousand clients hitting the same `checksum_mismatch` on `1.4.2/stable/windows/amd64` collapse into one group with `count: 10000`. Because app names are unique only per `(app_name, owner)`, the grouping identity stored in Mongo is the composite `(app_id, groupHash)` — two different owners can both have an app named `test` without ever seeing each other's reports. --- ## Reading rollout health Admins and team users query aggregated groups, sorted by most-recently-seen: ```bash curl -s 'http://localhost:9000/reports/groups?app=test&type=update_failure&from=2026-05-01T00:00:00Z&to=2026-06-01T00:00:00Z' \ --header 'Authorization: Bearer ' ``` ```json { "items": [ { "group_hash": "9f2b...", "application": { "name": "test", "version": "1.4.2", "channel": "stable" }, "system": { "platform": "windows", "arch": "amd64" }, "event": { "type": "update_failure", "reason": "checksum_mismatch" }, "stats": { "count": 182, "first_seen": "2026-05-20T10:00:00Z", "last_seen": "2026-05-20T12:00:00Z", "details_stored": 17, "details_rejected": 3 } } ], "total": 1, "page": 1, "limit": 20 } ``` All filters are optional — `app`, `version`, `channel`, `platform`, `arch`, `type`, `reason`, plus RFC3339 `from`/`to` on `stats.lastSeen` and `page`/`limit`. The `stats` block is the headline: `count` is the blast radius, `first_seen`/`last_seen` tell you whether a failure is fresh or already winding down, and `details_stored` tells you how many debug blobs you can pull for investigation. To inspect those blobs, call [`GET /reports/groups/:groupHash/blobs`](https://faynosync.com/docs/api/reports/list-detail-blobs) — it returns blob metadata plus a short-lived (15 min) presigned URL per blob against the private bucket. (`owner` and `storage.bucket` are never serialized in either response.) --- ## Rate limits keep a public endpoint safe Because ingestion is public, rate limits are mandatory. They're evaluated **after** `groupHash` is built and **before** the Mongo upsert, using fixed-window counters in Redis: | Dimension | Limit | |-----------|-------| | Per report key | `REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE` requests/minute (default `100`) | | Per `X-Device-ID` + `groupHash` | 1 request/hour | | Per `groupHash` | 30 requests/minute | The per-device limit is scoped **by `groupHash`**, not globally per device — that's deliberate. A single device can legitimately emit a `startup_failure`, then an `update_failure`, then a `crash` within the same hour; only repeats of the *same* failure are suppressed, which is the intended dedup behavior. On a Redis error the limiter **fails open** and logs, since rate limiting is abuse control, not a trust boundary, and a Redis blip must not drop legitimate reports. --- ## The privacy boundary Reports share the anonymous `X-Device-ID` concept with [telemetry](https://faynosync.com/blog/telemetry-insights), used here only for rate limits and dedup. Critically, `X-Device-ID` is **never** part of `groupHash` and **never** written to `report_groups` or `report_blobs`. Allowed dimensions are purely technical: app name, version, channel, platform, arch, event type, reason, and an optional debug blob. Avoid email, username, hostname, device serials, stored IPs, and full filesystem paths that may leak usernames. Stripping secrets and personal data from `details` is the client/SDK's responsibility — the server does not run a redaction pipeline. --- ## Where this is headed Today, reports give you the data. The aggregated `report_groups` are also the natural input for a future **rollout decision engine**: pause a rollout when `update_failure` rate crosses a threshold for a version/channel/platform, alert on a `crash` spike after a release, or suggest a rollback when `rollback_failure` shows up. That's a later layer — the ingestion path itself never mutates rollout state. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Set `REPORTS_ENABLED=true` (plus the `REPORTS_*` limits) and make sure `S3_BUCKET_NAME_PRIVATE` is configured: 👉 [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview) 3. Enable `reports` on an app to get its `rpk_` report key, then send a failure from your client: 📡 [Ingest Report](https://faynosync.com/docs/api/reports/ingest) 4. Watch rollout health in the read API or dashboard: 📊 [List Report Groups](https://faynosync.com/docs/api/reports/list-groups) --- ## Related reading - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) - [Team-Based Authorization in faynoSync — Manage Your Team Like a Pro](https://faynosync.com/blog/team-based-auth) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Reports Management docs](https://faynosync.com/docs/api/reports/reports-introduction) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing. --- --- ## Scaling Update Checks with Edge + S3 Response Cache Update checks are the noisiest traffic an update server ever sees. Every running copy of your app polls `/checkVersion` on a timer, almost always with the same parameters, and each call validates the app, channel, platform, and architecture before answering. At a few hundred clients that's nothing. At a hundred thousand, it's the same handful of answers recomputed millions of times a day — and the usual fix is database clustering, replication, and heavier ops you'd rather not run for an update server. [Performance Mode](https://faynosync.com/docs/performance) already takes the first bite out of this with a Redis cache at the API layer. The next step moves the hot *read* path off the origin entirely: serve repeated checks from object storage at the edge, and let the API handle only cache misses and the control plane. {/* truncate */} This post explains how faynoSync's [Edge + S3 response cache](https://faynosync.com/docs/edge) works, how it differs from Performance Mode, and how telemetry stays accurate when checks no longer reach the API. --- ## The problem: load scales with clients, not with answers The number of *distinct* answers your server can give is small. It's bounded by your dimension matrix: ```text N ≈ client_versions × channel × platform × arch × updater × package ``` The number of *requests* is bounded by your install base times polling frequency. Those two numbers diverge hard as you grow. One hundred thousand clients spread across five client versions still only have a handful of real answers — but they generate one hundred thousand round trips anyway. Edge mode changes the economics. After warm-up, origin load is driven by **distinct cache keys**, not by raw client count. Five client versions across five dimension combinations produce on the order of **twenty-five** origin calls to build coverage — not one hundred thousand direct checks. --- ## Two supported modes — no forced migration faynoSync deliberately keeps both read paths. You don't have to move every client at once. | Mode | How clients check | Best for | |------|-------------------|----------| | **Dynamic API** | `GET /checkVersion` on the API origin | Existing integrations, scripts, and tools that call the API directly | | **Edge + S3 cache** | Official SDK with `EdgeURL` → cached manifests on object storage | High-volume desktop, server, and auto-update clients using SDK edge-first behavior | Dynamic API behavior is unchanged. Edge mode adds a parallel, cache-friendly read path without forking the core update API. --- ## How edge mode fits together ### What you turn on Edge response caching is enabled **per application** with the `cdn` flag when you create or update an app (see [Create Application](https://faynosync.com/docs/api/apps/create) and [Update application](https://faynosync.com/docs/api/apps/update-app)). Two pieces of supporting infrastructure: - A **public** object storage bucket for response manifests — metadata only; binaries stay on the existing artifact flow. Configure `S3_BUCKET_NAME_CDN` as described in the [Environment Variables Overview](https://faynosync.com/docs/getting-started/env-overview). - An **official faynoSync SDK** configured with `BaseURL` (API origin) and optionally `EdgeURL` (edge/CDN base). Edge-first orchestration, fallback, cache fill, and telemetry are built for SDK clients. See the [SDK overview](https://faynosync.com/docs/sdk/intro) and the [Go SDK](https://faynosync.com/docs/sdk/go/sdk). ```go client := faynosync.NewClient(faynosync.Config{ BaseURL: "http://localhost:9000", EdgeURL: "http://faynosync-cdn-edge.web.garage.localhost:3902", }) ``` If `EdgeURL` is omitted, the SDK behaves exactly as before and calls the API directly via `BaseURL`. ### Request flow ```text SDK Client (BaseURL + optional EdgeURL) ↓ If EdgeURL is set, SDK checks edge/S3 first ↓ ├─ HIT → return cached response to client └─ MISS → call Origin API (/checkVersion) via BaseURL ↓ API returns normal response ↓ API writes the response to the S3 cache path in the background ↓ Future checks → edge/S3 HIT ``` - **HIT** — a manifest object exists for the request dimensions; the SDK returns the cached JSON body to the application. - **MISS** — no valid manifest; the SDK calls `/checkVersion` and returns that response, and the API writes it to S3 in the background so later checks become HITs. The API stays the source of truth. Edge storage is a derived, disposable cache of responses the API already knows how to produce. Rare or brand-new dimension combinations fill themselves naturally through the fallback path the first time something asks — no pre-warm job required. ### What a manifest contains Each cached file is the **full faynoSync JSON response** for that client context — the same fields the Dynamic API returns: ```json { "update_available": true, "update_url_deb": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.2.deb", "update_url_rpm": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.2.rpm", "changelog": "### Changelog\n\n- Added feature X\n- Fixed bug Y", "critical": true, "is_intermediate_required": true } ``` Objects live at a deterministic path keyed by dimensions: ```text /responses/{owner}/{app_name}/{channel}/{platform}/{arch}/{updater}/{client_version}.json ``` There is intentionally **no `latest.json` on the CDN**. Version comparison stays on the server (or in precomputed per–client-version manifests). A single "latest" object at the edge would push comparison logic to clients and break faynoSync's model of server-side update decisions. Cached objects carry standard cache headers, for example: ```text Cache-Control: public, max-age=60, must-revalidate ``` so CDNs and browsers revalidate frequently while still serving hot paths cheaply. ### Publish, unpublish, and invalidation When you publish or unpublish **any** version for an app, faynoSync **deletes the response manifests** for that application's scope. The next checks for affected dimensions are MISSes; the SDK falls back to `/checkVersion`, repopulates S3, and traffic HITs again. No manual flushing, no risk of pinning users to an old build — the same correctness guarantee Performance Mode gives you, applied to the edge layer. ### Updater-specific behavior For default JSON update checks, the cached body is the ordinary faynoSync response, and "no update" results are cached under the same key rules as "update available" results. Updater-specific contracts (Electron Builder, Tauri, Squirrel on Windows) still receive the payloads the API produces; only the **lookup path** changes from "always origin" to "edge first, origin on MISS." The JavaScript SDK maps cached JSON to updater-facing HTTP semantics — for example `{"status": "no_content"}` becomes `204 No Content`, and `{"status": "redirect", "url": "..."}` becomes the redirect the updater expects. --- ## Edge cache vs Performance Mode They solve overlapping problems at different layers, and they're independent — run one, the other, or both. | Aspect | [Performance Mode](https://faynosync.com/docs/performance) | Edge + S3 cache | |--------|-------------------------------------|-----------------| | Where it caches | Redis at the API | Object storage manifests at the edge | | Who benefits | Any client calling `/checkVersion` | SDK clients with `EdgeURL` and app `cdn` enabled | | Cache key | API request signature | Per-dimension manifest path | | Invalidation | Redis TTL / cache policies | Publish/unpublish clears app manifest scope | | Coupling | `PERFORMANCE_MODE=true` | App `cdn` + SDK + `S3_BUCKET_NAME_CDN` | Use Performance Mode to shave latency and DB load on the origin. Use edge mode when check volume is dominated by many clients repeating the same dimension keys and you want the origin to see MISS traffic only — not every poll. --- ## Keeping telemetry accurate at the edge Here's the catch with moving checks off the origin: today, telemetry is collected inside `/checkVersion` when clients send `X-Device-ID`. In edge mode, many checks never reach that endpoint — so without a change, your statistics would silently drop exactly as you scale up. The fix is a dedicated, fast path. SDK clients that skip `/checkVersion` send a [`/telemetry/beacon`](https://faynosync.com/docs/api/info/telemetry_beacon) request through the edge to the API instead: ```text Classic path: Client → (optional X-Device-ID) inline telemetry → /checkVersion Edge path: Client → edge manifest HIT → (optional X-Device-ID) → /telemetry/beacon ``` The beacon is built for volume, so it avoids per-request database lookups. Instead, the API keeps a compact **in-memory allow-list index** built from **names only** when metadata changes — owners, applications, channels, platforms, architectures, and versions. Changelog, artifacts, and other heavy fields stay out of memory. On each relevant change (app created, channel added, version published), the service rebuilds the index from a projection and **atomically swaps** the pointer handlers use: ```go var allowList atomic.Pointer[TelemetryAllowList] func ReloadAllowList() { next := BuildAllowListFromDB() // projection: names only allowList.Store(next) } ``` Beacon validation checks that `(owner, app, channel, platform, arch)` exists in the index. The reported **client version is recorded but not required** to match — clients may still report a version you've since removed in the admin UI. Invalid dimension combinations are rejected cheaply, and counters flow to Redis exactly as today's pipeline, so dashboards stay meaningful under edge load. ### Internal benchmarks (local development) These numbers were measured on a **local Mac** during development. Production hardware, network, and Redis will report different absolute values — treat them as order-of-magnitude guidance, not a SLA. They still show the design goal: validate dimensions from an in-memory snapshot instead of hitting the database on every beacon. | Approach | Average time to persistence | Requests/sec (`wrk`, 4t/100c/30s) | Avg latency | |----------|----------------------------|-----------------|-------------| | Previous path (DB-backed validation per request) | ~20.8 ms | ~359 | ~287 ms | | Beacon path (in-memory allow-list snapshot) | ~0.15 ms | ~1,192 | ~88 ms | Under this setup the beacon path handled roughly **3×** more requests per second at lower latency. --- ## When to reach for edge mode - Your check volume is dominated by many clients repeating the same dimension keys. - You want the origin to see MISS traffic only, not every poll. - Your clients can adopt the official SDK with `EdgeURL`. If you're still serving a small or low-traffic deployment, [Performance Mode](https://faynosync.com/docs/performance) alone is usually enough. The two stack cleanly when you outgrow it. --- ## How to try faynoSync? 1. Follow the Getting Started guide: 👉 [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard, and set the `cdn` flag on the app: 📦 API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) 🖥️ Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Configure `S3_BUCKET_NAME_CDN` and point the SDK at both `BaseURL` and `EdgeURL`. 4. Read the full reference: [Edge and S3 Response Cache](https://faynosync.com/docs/edge). --- ## Related reading - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) - [Performance Mode — Speed Up Your API](https://faynosync.com/blog/performance-mode) - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Edge and S3 Response Cache docs](https://faynosync.com/docs/edge) - [SDK overview](https://faynosync.com/docs/sdk/intro) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing. --- --- ## Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI [Velopack](https://velopack.io) is one of the nicest ways to add auto-updates to a desktop app. You pack your app with `vpk`, and the Velopack client inside your app takes care of downloading and installing new versions. It supports delta updates, works on Windows, macOS and Linux, and has client libraries for C#, Rust, JavaScript, C++ and Python. But there's one question every team hits sooner or later: **where do the updates actually live?** Velopack needs a place to host the release feed and packages. You can throw them into a bucket or GitHub Releases, but then publishing, rolling back and "which version is live on which channel" become manual chores. This post shows how to use [faynoSync](https://faynosync.com/docs/intro) as a **self-hosted Velopack update server**. No custom SDK, no glue code — the stock Velopack client talks to faynoSync as if it were a plain file host. We'll go through the whole flow with a ready-made example app, and everything below was run end to end on a local faynoSync before writing it down. {/* truncate */} --- ## What you get Before we start, here's what faynoSync adds on top of "just put files in a bucket": | | Plain bucket / GitHub Releases | faynoSync | |---|---|---| | Release feed (`releases.{channel}.json`) | You upload and keep it in sync by hand | Rebuilt automatically from the database on every change | | Rollback a bad version | Edit the feed by hand | Uncheck **Publish** in the dashboard | | Channels, platforms, architectures | Folder naming conventions | First-class, one feed per combination | | Delta updates | Works, if the feed is right | Works, and broken delta chains are dropped automatically | | Force users through a required version | Not possible, clients always jump to the latest | [Intermediate builds](https://faynosync.com/docs/intermediate_build), when the feed comes from the API | | Install a *specific* old version | Dig through old files | Version-pinned installer kept for every release | | CI uploads | Custom scripts | [faynoSync CLI](https://faynosync.com/docs/cli), GitHub Action, Jenkins step | | Signature verification | — | Optional [TUF](https://faynosync.com/docs/api/tuf/tuf-introduction) on top of Velopack's own checks | The client side stays 100% stock Velopack. If you ever want to move away, your app just points to a different URL. --- ## The example app To keep things simple, we'll use a tiny Python app that already has everything wired up: **[github.com/ku9nov/faynoSync-velopack-p3-example](https://github.com/ku9nov/faynoSync-velopack-p3-example)** It's a few small files: - `app.py` — starts, checks for updates using the stock Velopack `UpdateManager`, downloads and applies them. - `app_version.py` — one line with the app version. You bump it to release a new version. - `build.sh` — builds the app with PyInstaller and packs it with `vpk` into the `releases/` folder. - `USAGE.md` — the same walkthrough plus the optional extras (TUF, telemetry, reports). It's a **terminal app**: no window, it just prints what it's doing — which version it runs, which feed it reads, what update it found. That makes it easy to see every step of the update flow. You'll run it from the terminal the whole time. Python is used here only because it's short and easy to read. The faynoSync side is exactly the same for a C#, Rust, JavaScript or C++ Velopack app — faynoSync never sees your code, only the files `vpk` produces. The walkthrough below uses macOS on Apple Silicon (`darwin` / `arm64`). On Windows or Linux the steps are the same; only the platform, architecture and installer file names change. ### What you need installed - **Docker** — to run faynoSync locally. - **Python 3** — to build the example app. - **Velopack CLI (`vpk`)** — to pack the app. --- ## Step 1. Run faynoSync The fastest way is Docker Compose: ```bash git clone https://github.com/ku9nov/faynoSync.git cd faynoSync docker compose up --build ``` When everything is up, run the database migrations in a second terminal: ```bash docker compose exec -T backend /usr/bin/faynoSync migrate up ``` You'll get two things: - **API** on `http://localhost:9000` — this is what the CLI uploads to. - **Dashboard** on `http://localhost:9000/dashboard/` — this is where you'll click around. Local storage is S3-compatible, so the release feed and packages are served straight from a public bucket, the same way a CDN would serve them in production. For the details, see [Local Setup](https://faynosync.com/docs/getting-started/local-deploy) and [Production Deployment](https://faynosync.com/docs/getting-started/production-deploy). --- ## Step 2. Prepare the app in the dashboard Open `http://localhost:9000/dashboard/` and **register**. The form asks for a `secretKey` — for a local setup use the development default `insecure-dev-api-key`. Then log in and create four things: 1. **Application** — click **Create app**, name it `HelloVelopack`. The name matters: it becomes part of the feed URL, and the example app looks for exactly this name. 2. **Channel** — `nightly`. This must match the channel `vpk` bakes into the package (`build.sh` uses `nightly` by default). 3. **Platform** — `darwin` (or `windows` / `linux`). In the **Updaters** section, add **velopack**. This tells faynoSync to speak Velopack's feed format for this platform. 4. **Architecture** — `arm64` or `amd64`, whichever matches your machine. Why three separate things instead of one "channel"? Velopack squeezes everything into its channel name, while faynoSync keeps channel, platform and architecture as separate axes. That way `nightly` on macOS arm64 and `nightly` on Windows x64 each get their own feed and never mix. The [Velopack updater docs](https://faynosync.com/docs/updaters/velopack) explain this in more detail. --- ## Step 3. Create an upload token You don't want to use your dashboard password in scripts or CI. Instead, create a token that can only upload to this one app: 1. Click the gear icon and open **Settings**. 2. Go to **CI/CD Tokens**. 3. Give the token a name, for example `GitHub Actions - HelloVelopack`, and pick an expiration. 4. Click **Select allowed apps** and choose `HelloVelopack`. 5. Click **Create token**. The token is shown **only once** — copy it right away. If you lose it, just revoke it and create a new one. The API behind this page is described in [Create Token](https://faynosync.com/docs/api/tokens/create). --- ## Step 4. Install the faynoSync CLI Download the binary for your system from the [faynoSync CLI releases page](https://github.com/ku9nov/faynoSync-cli/releases). There are builds for macOS, Linux and Windows, both amd64 and arm64. On an Apple Silicon Mac: ```bash curl -L -o faynosync https://github.com/ku9nov/faynoSync-cli/releases/latest/download/faynosync-cli-darwin-arm64 chmod +x faynosync sudo mkdir -p /usr/local/bin && sudo mv faynosync /usr/local/bin/ faynosync version ``` Now tell the CLI where your server is. Run `faynosync init` and answer the prompts: ```text Enter value for server [https://example.com]: http://localhost:9000 Enter value for owner [example]: admin Enter value for edge []: Enter value for tuf (true/false) [false]: ``` `owner` is the username you registered with. Leave `edge` and `tuf` empty — we don't need them here. Finally, give the CLI your token. It's read only from an environment variable and never saved to a file: ```bash export FAYNOSYNC_TOKEN=fns_your_token_here ``` In CI you can skip `init` completely: set `FAYNOSYNC_TOKEN`, `FAYNOSYNC_URL` and `FAYNOSYNC_ACCOUNT` as secrets instead. Everything else is in the [CLI docs](https://faynosync.com/docs/cli). --- ## Step 5. Build and publish version 1.0.0 Clone the example, set up Python once, and build: ```bash git clone https://github.com/ku9nov/faynoSync-velopack-p3-example.git cd faynoSync-velopack-p3-example python3 -m venv .venv .venv/bin/pip install velopack tuf securesystemslib cryptography pyinstaller ./build.sh ``` After a few seconds the `releases/` folder contains several files. You need only three of them: - `releases.nightly.json` — the feed `vpk` generated. faynoSync reads the hashes, sizes and versions from it exactly as `vpk` wrote them. - `HelloVelopack-1.0.0-nightly-full.nupkg` — the full package. - `HelloVelopack-nightly-Setup.pkg` — the installer (on Windows this is `Setup.exe`). Upload them: ```bash faynosync upload \ --app HelloVelopack \ --version 1.0.0 \ --channel nightly \ --platform darwin \ --arch arm64 \ --updater velopack \ --file releases/releases.nightly.json \ --file releases/HelloVelopack-1.0.0-nightly-full.nupkg \ --file releases/HelloVelopack-nightly-Setup.pkg \ --publish \ --changelog "First release" ``` If everything is fine, you'll see: ```text INFO Upload completed app HelloVelopack files 3 uploaded_id 6abb6fa9ee89ef5881c59fa4 version 1.0.0 ``` A few things happened on the server side: - faynoSync checked that you uploaded at least one `*-full.nupkg` and exactly one `releases.{channel}.json` — the two things Velopack can't work without. - It generated a fresh feed and put it next to the packages at `velopack/admin/HelloVelopack/darwin/arm64/releases.nightly.json`. - It saved a **version-pinned copy of the installer** under `installers/`, named like `HelloVelopack-nightly-Setup-1.0.0-darwin-arm64.pkg`. The plain `Setup.pkg` always installs the latest version, while the pinned one is handy when QA needs to install exactly 1.0.0. --- ## Step 6. Install the app (and get past macOS Gatekeeper) Download the pinned installer and install it. With the local faynoSync setup, the public bucket lives at `cb-faynosync-s3-public.web.garage.localhost:3902`: ```bash curl -o ~/Downloads/HelloVelopack-1.0.0.pkg \ http://cb-faynosync-s3-public.web.garage.localhost:3902/velopack/admin/HelloVelopack/darwin/arm64/installers/HelloVelopack-nightly-Setup-1.0.0-darwin-arm64.pkg sudo installer -pkg ~/Downloads/HelloVelopack-1.0.0.pkg -target / ``` **Why the terminal and not a double-click?** The example installer isn't signed with an Apple Developer certificate. If you double-click it, macOS Gatekeeper refuses to open it because it can't verify the developer. Installing with `sudo installer` from the terminal gets around that. If you downloaded the installer (or the faynoSync CLI) with a **browser**, macOS also marks the file as "downloaded from the internet". Remove that mark before installing or running it: ```bash xattr -d com.apple.quarantine ~/Downloads/HelloVelopack-1.0.0.pkg ``` Files downloaded with `curl`, like in the commands above, don't get that mark, so you can skip this. For a real app you'd sign and notarize instead: `vpk pack` has `--signAppIdentity`, `--signInstallIdentity` and `--notaryProfile` for that, and then users can simply double-click the installer. Now run the app. Remember, it's a terminal app, so start it from the terminal to see what it does: ```bash /Applications/HelloVelopack.app/Contents/MacOS/HelloVelopack ``` ```text [INFO] Hello from hello-velopack v1.0.0 (darwin/arm64) [INFO] Checking updates: admin/HelloVelopack platform=darwin arch=arm64 [INFO] Feed URL: http://cb-faynosync-s3-public.web.garage.localhost:3902/velopack/admin/HelloVelopack/darwin/arm64/releases.nightly.json [INFO] Already on the latest version (1.0.0). ``` You may also see a line like `Couldn't write out staging userId.` and a warning from Python's `urllib3` about LibreSSL — both are harmless and don't affect updates. --- ## Step 7. Ship an update Open `app_version.py`, change `1.0.0` to `1.0.1`, and build again: ```bash ./build.sh ``` Because 1.0.0 is still in the `releases/` folder, `vpk` now also creates a **delta package** — only the difference between the two versions. Upload four files this time: ```bash faynosync upload \ --app HelloVelopack \ --version 1.0.1 \ --channel nightly \ --platform darwin \ --arch arm64 \ --updater velopack \ --file releases/releases.nightly.json \ --file releases/HelloVelopack-1.0.1-nightly-full.nupkg \ --file releases/HelloVelopack-1.0.1-nightly-delta.nupkg \ --file releases/HelloVelopack-nightly-Setup.pkg \ --publish \ --changelog "Second release" ``` Run the installed app again: ```bash /Applications/HelloVelopack.app/Contents/MacOS/HelloVelopack ``` ```text [INFO] Hello from hello-velopack v1.0.0 (darwin/arm64) [INFO] Update available: 1.0.0 -> 1.0.1 (upgrade) via FULL (13114793 bytes) [INFO] Downloading... [INFO] Downloaded. Applying and exiting; re-run to launch 1.0.1. ``` It found 1.0.1, downloaded it, applied it and exited. Velopack's own updater prints a few more lines while it replaces the app — that's normal. Run the same command once more and the app greets you as `v1.0.1`. Two things you might notice on macOS: - **The first update comes down as FULL.** That's expected for the first update after a fresh install. In our run it was about 13 MB. - **macOS may ask for your password once.** The `.pkg` installer puts the app into `/Applications` as root, so the first time Velopack replaces the app bundle, it asks for permission. Later updates in our run went through without asking. Now change the version to `1.0.2`, run `./build.sh`, and upload with the same command — just replace `1.0.1` with `1.0.2` everywhere. Run the app again: ```text [INFO] Hello from hello-velopack v1.0.1 (darwin/arm64) [INFO] Update available: 1.0.1 -> 1.0.2 (upgrade) via DELTA (1 package(s), 197422 bytes) [INFO] Downloading... [INFO] Downloaded. Applying and exiting; re-run to launch 1.0.2. ``` This time it's a **DELTA**: about 197 KB instead of 13 MB. For real apps with big bundles and lots of users, this is where you save most of the bandwidth. --- ## Step 8. Roll back a bad release Say 1.0.2 turns out to be broken. With a plain bucket, you'd have to edit the feed by hand and hope you got it right. With faynoSync: 1. Open `HelloVelopack` in the dashboard. 2. Edit version **1.0.2** and uncheck **Publish**. 3. Save. That's it. faynoSync rebuilds the feed without 1.0.2. Its full and delta packages disappear from the feed together, so clients never see half a version. Run the app: ```text [INFO] Hello from hello-velopack v1.0.2 (darwin/arm64) [INFO] Update available: 1.0.2 -> 1.0.1 (DOWNGRADE (revoked?)) via FULL (13114793 bytes) [INFO] Downloading... [INFO] Downloaded. Applying and exiting; re-run to launch 1.0.1. ``` The app noticed that 1.0.1 is now the latest and went back to it. The example allows this because it enables `AllowVersionDowngrade` in the Velopack update options — check `app.py` if you want the same in your app. Fixed the bug? Publish a new version and everyone moves forward again. --- ## Step 9. Make users stop at a required version Here's a situation every desktop team runs into sooner or later. Version 1.0.2 migrates the local database to a new format, and 1.0.3 expects that new format. A user who is still on 1.0.1 must not jump straight to 1.0.3 — they need to go through 1.0.2 first. Velopack alone can't do this. Its client always goes for the highest version in the feed, and a static feed file is the same for everyone, so there's no way to say "you, on 1.0.1, stop at 1.0.2 first". faynoSync calls this an **intermediate build**, and it works with the stock Velopack client too. There's one condition: **the app has to read its feed from the faynoSync API, not from the static bucket.** Why? Every time the stock client checks for updates, it already tells the server which version it's running (`localVersion` in the request). The API reads that and builds the feed just for this client: if there's a required version between the client's version and the latest, the feed simply ends at that required version. A static file in a bucket ignores all of this and always shows the full list. Here's how we tried it, with 1.0.1 installed: 1. In the dashboard, edit version **1.0.2**, check **Intermediate**, and make sure **Publish** is checked too. From CI you can do the same at upload time by adding `--intermediate` to `faynosync upload`. 2. Set the version to `1.0.3`, run `./build.sh`, and upload it like the earlier versions. 3. Run the app with `ASK_API=true`. This switch in the example makes the app read the feed from the API instead of the bucket (the logic is in `app.py`): ```bash ASK_API=true /Applications/HelloVelopack.app/Contents/MacOS/HelloVelopack ``` First run — the app on 1.0.1 is offered **1.0.2**, not 1.0.3. Note the feed URL now points to the API: ```text [INFO] Hello from hello-velopack v1.0.1 (darwin/arm64) [INFO] Feed URL: http://localhost:9000/velopack/admin/HelloVelopack/darwin/arm64/releases.nightly.json [INFO] Update available: 1.0.1 -> 1.0.2 (upgrade) via DELTA (1 package(s), 197422 bytes) [INFO] Downloading... [INFO] Downloaded. Applying and exiting; re-run to launch 1.0.2. ``` Second run with the same command — now on 1.0.2, it continues to 1.0.3: ```text [INFO] Hello from hello-velopack v1.0.2 (darwin/arm64) [INFO] Update available: 1.0.2 -> 1.0.3 (upgrade) via DELTA (1 package(s), 179988 bytes) [INFO] Downloading... [INFO] Downloaded. Applying and exiting; re-run to launch 1.0.3. ``` A third run reports `Already on the latest version (1.0.3)`. You can see the difference yourself by asking the API for the feed the way the client does: ```bash curl 'http://localhost:9000/velopack/admin/HelloVelopack/darwin/arm64/releases.nightly.json?localVersion=1.0.1&id=HelloVelopack' ``` For a client on 1.0.1 the newest version in that answer is 1.0.2. The static feed in the bucket, at the same moment, lists 1.0.3 — an app reading from the bucket would have jumped straight from 1.0.1 to 1.0.3. So which mode should you pick? The static bucket (or a CDN in front of it) is the default and scales to any number of users, because update checks never touch your server. The API mode costs one request to faynoSync per check, and in return gives you per-client decisions like intermediate builds. You can start with the bucket and switch the feed URL to the API the day you actually need a required step. Both modes are compared in detail in the [Velopack updater docs](https://faynosync.com/docs/updaters/velopack#two-delivery-modes). --- ## Going further Once the basic flow works, the example app has a few optional switches, all described step by step in its `USAGE.md`: - **TUF verification.** Turn on TUF for the app, sign versions in the dashboard, and the client checks every package against signed metadata before installing it. An unsigned version is simply rejected. See [TUF in faynoSync](https://faynosync.com/docs/api/tuf/tuf-introduction). - **Telemetry.** Send a small beacon on every update check and see on the **Statistics** page which versions people actually run. See [Telemetry Insights](https://faynosync.com/blog/telemetry-insights). - **Failure reports.** Send failed updates to faynoSync and catch a bad rollout early. See [Rollout Health Reports](https://faynosync.com/blog/rollout-health-reports). - **CI.** The same `faynosync upload` command runs in the [GitHub Action](https://github.com/marketplace/actions/faynosync-upload) or as a Jenkins step, so the whole release becomes "build, then upload". --- ## Troubleshooting **macOS says the installer or the CLI "can't be opened" or "Apple could not verify" it.** That's Gatekeeper blocking an unsigned file. Install the `.pkg` with `sudo installer` from the terminal, and if you downloaded a file with a browser, remove the quarantine mark with `xattr -d com.apple.quarantine ` first (see Step 6). **The app says "Update check skipped (app not installed?)".** You ran `app.py` directly. The update flow works only from a Velopack-installed copy. Install it from the Setup installer first. **The app says it's on the latest version, but you just uploaded a new one.** Check that the version was uploaded with `--publish` (or that **Publish** is checked in the dashboard). Also check that channel, platform and architecture match what the app asks for — the app prints the feed URL it uses on every start. **The upload is rejected.** For the `velopack` updater you need at least one `*-full.nupkg` and exactly one `releases.{channel}.json` in the same upload. Also make sure the platform has **velopack** in its updaters. **The app skips the intermediate version and jumps straight to the latest.** The app is reading the static feed from the bucket. Intermediate builds work only when the feed URL points to the faynoSync API — in the example app, run it with `ASK_API=true`. **The app keeps downloading FULL instead of DELTA.** A delta is served only while the version right below it is still published. If you unpublished that version, faynoSync drops the delta on purpose so clients don't get a delta they can't apply, and the full package is used instead. --- ## Wrapping up Velopack does the hard part on the client: packing, installing, deltas. faynoSync takes care of the server side: feeds, channels, publishing, rollbacks, required intermediate versions and uploads from CI. Together you get a self-hosted Velopack update server without writing a single line of server code or client glue. The whole example is one repo and a few commands. Clone it, run through the steps above on your own machine, and watch your first delta update land. --- ## How to try faynoSync? 1. Follow the Getting Started guide: [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Clone the [Velopack example app](https://github.com/ku9nov/faynoSync-velopack-p3-example) and follow its `USAGE.md`. 4. Read the full reference: [Velopack Updater](https://faynosync.com/docs/updaters/velopack). --- ## Related reading - [Velopack Updater docs](https://faynosync.com/docs/updaters/velopack) — feed format, CDN vs API delivery, delta handling - [Velopack example app docs](https://faynosync.com/docs/examples/velopack) — how the example client is built - [faynoSync CLI](https://faynosync.com/docs/cli) — all upload flags, GitHub Action and Jenkins step - [Self-Hosted Sparkle Update Server: Appcast Hosting for macOS](https://faynosync.com/blog/self-hosted-sparkle-update-server) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Scaling Update Checks with Edge + S3 Response Cache](https://faynosync.com/blog/edge-s3-cache) - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing. --- --- ## Self-Hosted Sparkle Update Server: Appcast Hosting for macOS [Sparkle](https://sparkle-project.org) is the standard way to auto-update a native macOS app. You link the framework, set a feed URL, and Sparkle checks an **appcast** — an RSS file that lists your versions — downloads the newest archive, checks its EdDSA signature and installs it. Sparkle tells you how to *generate* that appcast (`generate_appcast`), but not where to *host* it. Most teams end up with a bucket or a GitHub Pages site and a script that copies files around. Then come the questions: how do I pull a bad release? How do I mark an update as critical without rebuilding? Which feed do the beta testers get? This post shows how to use [faynoSync](https://faynosync.com/docs/intro) as a **self-hosted Sparkle update server**. The stock Sparkle client stays untouched — faynoSync hosts the appcast and the archives, and you manage releases from a dashboard or CI. Everything below was run end to end on a local faynoSync with a real macOS app before writing it down. {/* truncate */} --- ## What you get | | Plain bucket / GitHub Pages | faynoSync | |---|---|---| | Appcast (`appcast.xml`) | You upload it and keep it in sync by hand | Rebuilt automatically on every change | | Pull a bad release | Edit the XML by hand | Uncheck **Publish** in the dashboard | | Critical update | Rebuild the appcast with `--critical-update-version` | Check **Critical** (or `--critical` on upload) | | Release notes | HTML files next to the archives | Changelog field, rendered into the appcast | | Channels, architectures | Folder naming conventions | One appcast per channel / platform / arch | | EdDSA signatures | Yours | Still yours — passed through byte for byte | | Delta updates | Work if the files are in the right place | Uploaded with the version, URLs rewritten for you | | CI uploads | Custom scripts | [faynoSync CLI](https://faynosync.com/docs/cli), GitHub Action, Jenkins step | Your private signing key never touches the server. faynoSync reads the `sparkle:edSignature` from the appcast you upload and serves it unchanged, so Sparkle verifies every archive exactly as it would against any static host. --- ## The example app We'll use a small native AppKit app that already has Sparkle wired in: **[github.com/ku9nov/faynoSync-sparkle-example](https://github.com/ku9nov/faynoSync-sparkle-example)** - `Sources/UpdaterManager.swift` — starts the stock `SPUStandardUpdaterController` and builds the feed URL. - `Sources/Config.swift` — reads owner, app name and channel from `Info.plist`, so one codebase can target any channel or architecture. - `project.yml` — the [XcodeGen](https://github.com/yonsweng/xcodegen) project, including the version numbers. - `Makefile` + `scripts/` — `make keys`, `make build`, `make appcast` on top of Sparkle's own tools. It opens one window, checks for updates on start and shows the standard Sparkle dialogs. There's no faynoSync SDK inside — the only "integration" is the feed URL. How the client is built is described in the [Sparkle example docs](https://faynosync.com/docs/examples/sparkle). ### What you need installed - **Docker** — to run faynoSync locally. - **Xcode command line tools** and **XcodeGen** (`brew install xcodegen`). - **Sparkle's CLI tools** (`generate_keys`, `generate_appcast`) in the example's `tools/` folder. The repo README has a three-line snippet that downloads them from a Sparkle release. You **don't** need an Apple Developer ID for this walkthrough. Without one, the build is ad-hoc signed, which is fine for local testing. --- ## Step 1. Run faynoSync ```bash git clone https://github.com/ku9nov/faynoSync.git cd faynoSync docker compose up --build ``` In a second terminal, run the database migrations: ```bash docker compose exec -T backend /usr/bin/faynoSync migrate up ``` The API is now on `http://localhost:9000`, the dashboard on `http://localhost:9000/dashboard/`, and a public S3-compatible bucket serves the appcast and archives at `http://cb-faynosync-s3-public.web.garage.localhost:3902` — the same way a CDN would in production. More details: [Local Setup](https://faynosync.com/docs/getting-started/local-deploy). --- ## Step 2. Prepare the app in the dashboard Open `http://localhost:9000/dashboard/` and **register** (for a local setup the `secretKey` is `insecure-dev-api-key`). Then create: 1. **Application** — `faynosyncSparkleExample`. The example reads this name from `FaynoSyncApp` in `Info.plist`, and it becomes part of the feed URL. 2. **Channel** — `nightly`, the default channel in `project.yml`. 3. **Platform** — `darwin`. In the **Updaters** section, add **sparkle**. This is what tells faynoSync to accept and serve Sparkle appcasts for this platform. 4. **Architecture** — `arm64` on Apple Silicon, `amd64` on Intel. faynoSync builds **one appcast per channel, platform and architecture**, at a predictable path: ```text sparkle/{owner}/{app}/{platform}/{arch}/appcast.{channel}.xml ``` The example composes exactly this URL at runtime. If you registered with a username other than `admin`, change `FaynoSyncOwner` in `Resources/Info.plist`. --- ## Step 3. Create an upload token and install the CLI In the dashboard, open **Settings → CI/CD Tokens**, create a token, and in **Select allowed apps** pick `faynosyncSparkleExample`. Copy it — it's shown only once. See [Create Token](https://faynosync.com/docs/api/tokens/create) for the API behind it. Install the [faynoSync CLI](https://github.com/ku9nov/faynoSync-cli/releases) (Sparkle support needs v1.1.0 or newer): ```bash curl -L -o faynosync https://github.com/ku9nov/faynoSync-cli/releases/latest/download/faynosync-cli-darwin-arm64 chmod +x faynosync sudo mv faynosync /usr/local/bin/ export FAYNOSYNC_TOKEN=fns_your_token_here export FAYNOSYNC_URL=http://localhost:9000 export FAYNOSYNC_ACCOUNT=admin ``` The environment variables are the same ones you'd set as secrets in CI. Run `faynosync init` instead if you prefer a config file — see the [CLI docs](https://faynosync.com/docs/cli). --- ## Step 4. Generate your signing keys Sparkle verifies every update with an **Ed25519** key pair. The private key signs archives on your machine, the public key ships inside the app as `SUPublicEDKey`. ```bash git clone https://github.com/ku9nov/faynoSync-sparkle-example.git cd faynoSync-sparkle-example make keys ``` `generate_keys` stores the private key in your login Keychain and prints the public key. Paste it into `Resources/Info.plist`, replacing the `PASTE_SUPublicEDKey_FROM_make_keys` placeholder. If you already have a Sparkle key in your Keychain, it prints that one instead of creating a new key. This is the only trust anchor in the whole setup. faynoSync never sees the private key, and it can't produce a valid signature — so even a compromised update server can't push an archive your app will accept. --- ## Step 5. Build and publish version 1.0.0 Set the version in `project.yml`: ```yaml MARKETING_VERSION: "1.0.0" CURRENT_PROJECT_VERSION: "1" ``` `MARKETING_VERSION` is what users see. `CURRENT_PROJECT_VERSION` becomes `CFBundleVersion` — **this is the number Sparkle compares**, so it must go up with every release. ```bash make build make appcast ``` `make build` produces `build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.0.zip`. `make appcast` signs it and writes `build/dist/darwin/arm64/appcast.nightly.xml`: ```xml 1.0.0 1 1.0.0 11.0 ``` Notice the enclosure URL is just a file name. You don't need `--download-url-prefix` — faynoSync rewrites every enclosure URL to where it actually stores the file. Upload the appcast and the archive: ```bash faynosync upload \ --app faynosyncSparkleExample \ --version 1.0.0 \ --channel nightly \ --platform darwin \ --arch arm64 \ --updater sparkle \ --file build/dist/darwin/arm64/appcast.nightly.xml \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.0.zip \ --publish \ --changelog "First release" ``` ```text INFO Upload completed app faynosyncSparkleExample files 2 uploaded_id 6abe76633daf3c60d93cb5a2 version 1.0.0 ``` There's no `--signature` flag here — the signature already lives inside the appcast. Fetch the feed the way Sparkle will: ```bash curl http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/appcast.nightly.xml ``` ```xml 1.0.0 Thu, 01 Oct 2026 18:05:15 +0300 1 1.0.0 11.0 ``` Three things changed, and only these: the enclosure URL points to the stored file, `pubDate` is the moment you published, and the `description` is your faynoSync changelog. `sparkle:version`, `length` and `edSignature` are byte-for-byte what `generate_appcast` wrote. The full list of managed vs. pass-through fields is in the [Sparkle updater docs](https://faynosync.com/docs/updaters/sparkle#what-faynosync-manages-vs-passes-through). --- ## Step 6. Install the app ```bash curl -o faynosyncSparkleExample-1.0.0.zip \ http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/faynosyncSparkleExample-1.0.0.zip ditto -x -k faynosyncSparkleExample-1.0.0.zip /Applications open /Applications/faynosyncSparkleExample.app ``` The app checks for updates on start, and Sparkle answers with its standard dialog: **"You're up to date! faynoSync Sparkle Example 1.0.0 is currently the newest version available."** Files downloaded with `curl` don't get the macOS quarantine flag, so Gatekeeper doesn't complain about the unsigned build. For a real release you'd sign with your Developer ID and notarize — set `DEVELOPER_ID` and `DEVELOPMENT_TEAM` in the example's `.env` and the build switches to manual signing with hardened runtime. --- ## Step 7. Ship an update Bump both numbers in `project.yml` to `1.0.1` and `2`, then: ```bash make build make appcast ``` Because the 1.0.0 archive is still in the folder, `generate_appcast` now also creates a **delta**: `faynosyncSparkleExample2-1.delta`, a binary diff from build 1 to build 2. In our run the full archive was about 1 MB and the delta 1,078 bytes — a hello-world app barely changes between builds, but for a real app the delta is where most of the bandwidth saving is. Upload the new archive together with its delta: ```bash faynosync upload \ --app faynosyncSparkleExample --version 1.0.1 --channel nightly \ --platform darwin --arch arm64 --updater sparkle \ --file build/dist/darwin/arm64/appcast.nightly.xml \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.1.zip \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample2-1.delta \ --publish --changelog "Second release" ``` The appcast you upload now lists both 1.0.0 and 1.0.1, but faynoSync only takes the items for the files in *this* upload. Older versions are kept in its database and stay in the feed until you unpublish or delete them — so in CI you can upload an appcast generated from just the new build. The served feed now has a `` block under 1.0.1, with the delta's URL rewritten and its own `edSignature` untouched. Sparkle picks the delta when the installed build matches `sparkle:deltaFrom` and falls back to the full archive otherwise. Restart the app. Sparkle shows **"A new version of faynoSync Sparkle Example is available! faynoSync Sparkle Example 1.0.1 is now available—you have 1.0.0."** with "Second release" as the release notes. Click **Install Update**: Sparkle downloads the update, verifies it (`OK: EdDSA signature is correct for update` in the system log), replaces the app and relaunches it as 1.0.1. --- ## Step 8. Ship a critical update Build `1.0.2` (build `3`) the same way and upload it with `--critical`: ```bash faynosync upload \ --app faynosyncSparkleExample --version 1.0.2 --channel nightly \ --platform darwin --arch arm64 --updater sparkle \ --file build/dist/darwin/arm64/appcast.nightly.xml \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.2.zip \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample3-2.delta \ --file build/dist/darwin/arm64/nightly/faynosyncSparkleExample3-1.delta \ --publish --critical --changelog "Security fix" ``` faynoSync adds `` to the 1.0.2 item. On 1.0.1 the app now shows **"An important update to faynoSync Sparkle Example is ready to install"** — with only an **Install Update** button. No "Skip This Version", no "Remind Me Later". The flag is a faynoSync setting, not part of the build. Uncheck **Critical** in the dashboard and the tag disappears from the appcast without rebuilding or re-signing anything. --- ## Step 9. Pull a bad release Say 1.0.2 is broken. In the dashboard, edit version **1.0.2** and uncheck **Publish**. faynoSync rebuilds the appcast without it: ```bash curl -s http://cb-faynosync-s3-public.web.garage.localhost:3902/sparkle/admin/faynosyncSparkleExample/darwin/arm64/appcast.nightly.xml | grep '' ``` ```text <title>faynosyncSparkleExample 1.0.1 1.0.0 ``` The item and its deltas are gone together, and a client on 1.0.1 gets **"You're up to date!"** again. Be clear about what this does: **unpublishing stops the spread, it doesn't downgrade anyone.** Sparkle never installs a lower `CFBundleVersion`, so clients that already got 1.0.2 stay on it. The fix is to ship 1.0.3 with a higher build number. To catch a bad release before it reaches many users, combine this with [Rollout Health Reports](https://faynosync.com/blog/rollout-health-reports) or Sparkle's own `sparkle:phasedRolloutInterval`, which faynoSync passes through unchanged. --- ## Uploading from CI The upload is one command, so it fits any pipeline. With the [GitHub Action](https://github.com/marketplace/actions/faynosync-upload): ```yaml - name: Upload to faynoSync uses: ku9nov/faynoSync-cli@v1 with: app: faynosyncSparkleExample version: 1.0.3 channel: nightly platform: darwin arch: arm64 updater: sparkle file: | build/dist/darwin/arm64/appcast.nightly.xml build/dist/darwin/arm64/nightly/faynosyncSparkleExample-1.0.3.zip publish: true env: FAYNOSYNC_TOKEN: ${{ secrets.FAYNOSYNC_TOKEN }} FAYNOSYNC_URL: ${{ secrets.FAYNOSYNC_URL }} FAYNOSYNC_ACCOUNT: ${{ secrets.FAYNOSYNC_ACCOUNT }} ``` A CI runner has no Keychain with your key. Export the private key once with `generate_keys -x private-key-file`, store it as a secret, and let `generate_appcast` read it from a file with `--ed-key-file` (the example's `release.sh` maps `ED_KEY_FILE` to that flag). If you also want deltas in CI, download the previous archive into the dist folder before running `generate_appcast` — Sparkle can only diff against an archive it can see. --- ## What's different from Velopack If you read the [self-hosted Velopack update server](https://faynosync.com/blog/self-hosted-velopack-update-server) post, a few things work differently with Sparkle: - **No intermediate builds yet.** The Sparkle appcast is a static file, the same for every client, so faynoSync can't say "you, on 1.0.1, stop at 1.0.2 first". [Intermediate builds](https://faynosync.com/docs/intermediate_build) need a per-client feed and are planned as a dynamic Sparkle route. - **No faynoSync percentage rollout.** Same reason — use Sparkle's phased rollout instead. See [Rollout](https://faynosync.com/docs/rollout#updater-compatibility). - **No rollback by downgrade.** Velopack can be told to allow downgrades; Sparkle doesn't install lower versions. What you get in return: update checks never touch the faynoSync API. Sparkle reads a static file from the bucket, so you can put a CDN in front of it. --- ## Troubleshooting **The app crashes at launch with "Library not loaded: @rpath/Sparkle.framework … different Team IDs".** The app was built with hardened runtime but ad-hoc signed, so macOS refuses to load the embedded Sparkle framework. The example's `build.sh` turns hardened runtime off when `DEVELOPER_ID` is empty. In your own project, either sign with a Developer ID or disable hardened runtime for local builds. **Upload fails: "sparkle appcast has no matching \ for uploaded file(s)".** You uploaded an archive that isn't in the appcast — usually because you ran `make build` but not `make appcast`. Regenerate the appcast and upload again. **Upload fails: "sparkle updater requires exactly one appcast\*.xml feed file".** Every Sparkle upload needs the appcast plus at least one full archive (`.zip`, `.dmg`, `.tar.*` or `.aar`). Also check that the `darwin` platform has **sparkle** in its updaters. **Sparkle refuses the update with a signature error.** The `SUPublicEDKey` in the installed app doesn't match the key that signed the archive. Run `make keys`, paste the printed key into `Info.plist`, and rebuild — older installs signed with a different key can't verify new updates. **The app says it's up to date, but you just uploaded a new version.** Check that you bumped `CURRENT_PROJECT_VERSION` (Sparkle compares that, not the marketing version), that the version is published, and that channel, platform and architecture match the feed URL the app uses. **Don't enable `SURequireSignedFeed`.** faynoSync rewrites enclosure URLs, so a signature over the whole appcast can't stay valid. Security comes from the per-archive `edSignature`, which faynoSync never changes. --- ## Wrapping up Sparkle handles the client: checking, verifying, installing, deltas. faynoSync handles the server: hosting the appcast, channels, critical flags, release notes, pulling bad releases and uploads from CI. Your signing key stays on your machine, and the client stays stock Sparkle — if you ever move away, you just change the feed URL. Clone the example, run the steps above, and watch your first Sparkle update come from your own server. --- ## How to try faynoSync? 1. Follow the Getting Started guide: [https://faynosync.com/docs/getting-started](https://faynosync.com/docs/getting-started) 2. Create your app using the REST API or web dashboard: API Docs: [https://faynosync.com/docs/api](https://faynosync.com/docs/api) Dashboard UI: [https://github.com/ku9nov/faynoSync-dashboard](https://github.com/ku9nov/faynoSync-dashboard) 3. Clone the [Sparkle example app](https://github.com/ku9nov/faynoSync-sparkle-example) and follow its README. 4. Read the full reference: [Sparkle Updater](https://faynosync.com/docs/updaters/sparkle). --- ## Related reading - [Sparkle Updater docs](https://faynosync.com/docs/updaters/sparkle) — appcast contract, managed vs. pass-through fields, materialized feed - [Sparkle example app docs](https://faynosync.com/docs/examples/sparkle) — how the example client is built - [faynoSync CLI](https://faynosync.com/docs/cli) — all upload flags, GitHub Action and Jenkins step - [Self-Hosted Velopack Update Server: Deltas, Rollbacks, CI](https://faynosync.com/blog/self-hosted-velopack-update-server) - [How to Setup Auto Update for Electron App](https://faynosync.com/blog/electron-auto-update) - [Rollout Health Reports — Catch Failed Updates Before They Spread](https://faynosync.com/blog/rollout-health-reports) --- If you find this project helpful, please consider **subscribing**, **leaving a comment**, or giving it a **star**, create an **Issue** or **feature request** on GitHub. Your support keeps the project alive and growing. --- --- ## API Reference import ApiStats from '@site/src/components/ApiStats'; import ApiCardList from '@site/src/components/ApiCardList'; # API Reference Welcome to the FaynoSync API Reference! This section provides detailed information about all the API requests supported by the FaynoSync application. Each endpoint is documented with its purpose, request parameters, and example responses. You can find a comprehensive [Postman collection](https://github.com/ku9nov/faynoSync/blob/main/examples/faynoSync.postman_collection.json) to help you explore and test the API here. This collection includes all the endpoints and their respective examples, making it easy to get started with integrating FaynoSync into your applications. Explore the API categories below to find specific endpoints and their details: If you have any questions or need further assistance, please feel free to reach out or create discussions on our GitHub repository. --- ## Application Management Create, update, and manage applications. These endpoints allow you to manage apps within the **FaynoSync** API. You can upload new versions, update existing ones, and organize them by different deployment channels. This helps streamline version control and the deployment process across multiple platforms. --- ## Create Application Creates a new application with optional parameters including description, logo, and privacy settings. ## Endpoint ``` POST /app/create ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `multipart/form-data` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `app` | string | ✅ | The name of the application to be created | | `file` | file | ❌ | Logo of the application | | `description` | string | ❌ | App description | | `private` | boolean | ❌ | Lock app (if selected, the app will be stored in a private bucket and cannot be changed) | | `download_mode` | string | ❌ | Private apps only: `unlisted` or `strict`, who may [download](./download) artifacts and check for updates. Defaults to `unlisted` when `ENABLE_PRIVATE_APP_DOWNLOADING=true`, otherwise `strict` | | `tuf` | boolean | ❌ | Enable TUF framework for application | | `cdn` | boolean | ❌ | Enable or disable API response caching on S3. Not allowed for private apps | ## Example Request ### cURL ```bash curl --location 'http://localhost:9000/app/create' \ --header 'Authorization: Bearer ' \ --form 'data="{\"app\":\"appName\", \"cdn\": \"true\"}"' ``` ### Private app ```bash curl --location 'http://localhost:9000/app/create' \ --header 'Authorization: Bearer ' \ --form 'data="{\"app\":\"appName\", \"private\": \"true\", \"download_mode\": \"strict\"}"' ``` ## Response ### Success Response (200 OK) ```json { "createAppResult.Created": "641459ffb8760d74164e7e3c" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `createAppResult.Created` | string | The unique identifier (ID) of the created application | ## Notes - The `private` field is irreversible - once set to `true`, the app cannot be made public later - The `file` field accepts common image formats (PNG, JPG, etc.) - The `description` field is optional but recommended for better app management - When `cdn` is set to `true`, API responses are cached on S3; set `cdn` to `false` to disable caching - `private` together with `cdn: true` is rejected with `400`: CDN responses are public and would expose the private app's versions and links - An invalid `download_mode` is rejected with `400`; for a public app the field is ignored - A private app supports only the `manual`, `tauri` and `squirrel_darwin` updaters. Feed-based updaters (`velopack`, `sparkle`, `electron-builder`, `squirrel_windows`) are rejected on [upload](./upload) - Clients of a `strict` app need a [download token](./download-tokens-regenerate) for update checks and downloads --- ## Delete artifact This endpoint allows you to delete specific artifacts of an application by their identifiers. ## Endpoint ``` POST /artifact/delete ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `multipart/form-data` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | Unique identifier of the specific version of application | | `app_name` | string | ✅ | Name of the application to which the artifact belongs | | `version` | string | ✅ | Current version of the application | | `artifacts_to_delete` | array | ✅ | Array of identifiers of the artifacts to be deleted (e.g., `["0", "1"]`) | ## Example Request ```bash curl --location 'http://localhost:9000/apps/update' \ --header 'Authorization: Bearer ' \ --form 'data="{\"id\":\"653a5e4f51ce5114611f5abb\", \"app_name\":\"secondapp\", \"version\":\"0.0.1\", \"artifacts_to_delete\":[\"0\"]}"' ``` ## Response ### Success Response (200 OK) ```json { "deleteSpecificArtifactResult": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `deleteSpecificArtifactResult` | boolean | Indicates whether the artifacts were successfully deleted | ## Notes - Artifact identifiers are typically numeric strings representing the position/index of the artifact - You can delete multiple artifacts in a single request by including multiple identifiers in the array - This operation is irreversible - deleted artifacts cannot be recovered --- ## Delete specific version of application Delete one or several versions of an application by their unique identifiers. ## Endpoint ``` DELETE /apps/delete?id= DELETE /apps/delete?id=&id=&id= ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string | ✅ | The unique identifier of a version to delete. Repeat the parameter to delete several versions in one request | ## Bulk deletion rules - **One application per request.** All ids must belong to the same application, otherwise the request is rejected. - **All or nothing.** Nothing is deleted unless every id is a valid, existing version you are allowed to delete. - **Limit.** The number of `id` parameters is capped by [`MAX_BULK_DELETE_VERSIONS`](/docs/getting-started/env-overview#performance--features) (default: `10`). The limit is checked against the ids as sent, before repeated ids are dropped. - **Duplicates.** Repeated ids are deleted once. ## Example Requests ### Single version ```bash curl --location --request DELETE 'http://localhost:9000/apps/delete?id=653a5e4f51ce5114611f5abb' \ --header 'Authorization: Bearer ' ``` ### Several versions ```bash curl --location --request DELETE 'http://localhost:9000/apps/delete?id=653a5e4f51ce5114611f5abb&id=653a5e4f51ce5114611f5abc' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) — single version ```json { "deleteSpecificAppResult.DeletedCount": 1 } ``` ### Success Response (200 OK) — several versions ```json { "deleteSpecificAppResult.DeletedCount": 2, "app_name": "bulkapp", "deleted": [ { "id": "653a5e4f51ce5114611f5abb", "version": "1.0.3.0", "channel": "" }, { "id": "653a5e4f51ce5114611f5abc", "version": "1.0.4.0", "channel": "" } ] } ``` `channel` is an empty string for versions uploaded without a channel. ### Response Fields | Field | Type | Description | |-------|------|-------------| | `deleteSpecificAppResult.DeletedCount` | number | Number of versions deleted | | `app_name` | string | Application the versions belonged to (only when several ids were sent) | | `deleted` | array | Deleted versions with `id`, `version` and `channel` (only when several ids were sent) | | `orphaned_links` | array | Artifact links that could not be removed from storage. The versions are already deleted; only present when storage cleanup partially failed | ### Error Responses A request with a single `id` keeps the original error format. The responses below apply when several ids are sent. **400 Bad Request** — too many ids: ```json { "error": "too many versions requested", "limit": 10, "requested": 12 } ``` **400 Bad Request** — malformed id: ```json { "error": "invalid version id", "id": "not-an-object-id", "details": "the provided hex string is not a valid ObjectID" } ``` **400 Bad Request** — versions from different applications: ```json { "error": "all versions must belong to the same application", "apps": ["myapp", "otherapp"] } ``` **403 Forbidden** — some versions belong to another owner (`not_found` is included only if some ids also do not exist): ```json { "error": "you don't have permission to delete these items", "forbidden": ["653a5e4f51ce5114611f5abb"], "not_found": ["653a5e4f51ce5114611f5abd"] } ``` **404 Not Found** — some versions do not exist: ```json { "error": "some of the requested versions do not exist", "not_found": ["653a5e4f51ce5114611f5abd"] } ``` ## Notes - This operation permanently deletes the specified versions and all their associated artifacts - The deletion is irreversible - deleted versions cannot be recovered - Only the specified versions are deleted, other versions of the same application remain unaffected --- ## Delete Specific application This endpoint allows you to delete a specific application and all its versions. ## Endpoint ``` DELETE /app/delete?id= ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string | ✅ | The unique identifier of the application to delete | ## Example Request ```bash curl -X DELETE http://localhost:9000/app/delete?id=64145ebaedd163d59d52e1dc \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "deleteAppResult.DeletedCount": 1 } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `deleteAppResult.DeletedCount` | number | Number of applications successfully deleted (should be 1 if successful) | ## Notes - This operation permanently deletes the entire application - The deletion is irreversible - deleted applications cannot be recovered --- ## List download tokens Returns the download tokens of the apps the requester may edit. Token values are never returned — only a non-secret prefix to identify them. ## Endpoint ``` GET /download-tokens/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | Requires permission to edit apps. A team user sees only tokens of the apps in their allowed list. ## Example Request ```bash curl --location 'http://localhost:9000/download-tokens/list' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) ```json { "download_tokens": [ { "id": "68cc0a1e9b5f2d0f4c1a7b21", "app_id": "66ae13fe5b663c058367f893", "app_name": "secondapp", "channel_id": "66fea051c57c81dff9a0f38d", "channel_name": "stable", "token_prefix": "fnd_3f9c2a7e", "updated_at": "2026-09-18T14:39:16.233Z" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `download_tokens` | array | Token objects, most recently updated first | #### Token Object Fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Token identifier | | `app_id` | string | App the token is scoped to | | `app_name` | string | Name of that app | | `channel_id` | string | Channel the token is scoped to | | `channel_name` | string | Name of that channel; omitted for a token without a channel | | `token_prefix` | string | Non-secret prefix for identification | | `updated_at` | string | When the token was created or last rotated | ## Notes - Create or rotate a token with [`POST /download-tokens/regenerate`](./download-tokens-regenerate) --- ## Regenerate download token Creates the download token of a private app and channel on the first call, and rotates it on every following call. The previous value stops working immediately. Clients of a [private app](./create) in `strict` mode send this token in the `X-Download-Token` header on [update checks](../info/latest) and on [`/download`](./download). ## Endpoint ``` POST /download-tokens/regenerate ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | Requires permission to edit apps. A team user also needs access to the app and to the channel. ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `app_id` | string | ✅ | ID of the private app | | `channel_id` | string | ❌ | ID of the channel the token is scoped to. Omit it only for an app that has no channels | ## Example Request ```bash curl -X POST --location 'http://localhost:9000/download-tokens/regenerate' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{"app_id": "66ae13fe5b663c058367f893", "channel_id": "66fea051c57c81dff9a0f38d"}' ``` ## Response ### Success Response (200 OK) ```json { "app_id": "66ae13fe5b663c058367f893", "channel_id": "66fea051c57c81dff9a0f38d", "token": "fnd_3f9c2a7e0b..." } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `app_id` | string | App the token is scoped to | | `channel_id` | string | Channel the token is scoped to | | `token` | string | The token value. It is returned only once | ### Error Responses | Status | When | |--------|------| | `400` | Invalid body or IDs, or the app is public | | `403` | The requester has no access to the app or channel | | `404` | The app or channel does not exist | ## Notes - Only a hash of the token is stored, so a lost token cannot be recovered — regenerate it and ship the new value - One token exists per app and channel; regenerating replaces it - Deleting the app or the channel deletes its tokens - The token lets a client read versions and download artifacts of its channel; treat it as a secret embedded in the build - List existing tokens with [`GET /download-tokens/list`](./download-tokens-list) --- ## Download artifact from private bucket Artifacts of a [private app](./create) live in the private bucket and are never served by a public URL. Their links point to the API instead — `{API_URL}/download?key=` — and this endpoint decides whether the caller may have the file. If so, it answers with a short-lived signed URL. Access is decided **per app**, by its `download_mode`, which is set on [create](./create) and can be changed on [update](./update-app). ## Endpoint ``` GET /download ``` ## Authentication The endpoint is public; access depends on the app's `download_mode` and on the credential sent with the request. | Header | Value | Required | |--------|-------|----------| | `X-Download-Token` | `fnd_…` [download token](./download-tokens-regenerate) of the app and channel | Conditional | | `Authorization` | `Bearer ` | Conditional | | `download_mode` | Who gets the file | |-----------------|-------------------| | `unlisted` | Anyone who knows the key. | | `strict` | A request with the download token of the artifact's app and channel, or a JWT of the owning admin or of a team user with the `download` permission and access to the app and channel. | A JWT with access works in both modes. CI/CD tokens (`fns_…`) are not accepted on this endpoint. ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `key` | string | ✅ | URL-encoded object key from the artifact link | ## Example Requests ### With a download token (`strict` app) ```bash curl -X GET --location 'http://localhost:9000/download?key=secondapp-admin%2Fstable%2Flinux%2Famd64%2Fsecondapp-0.0.1.deb' \ --header 'X-Download-Token: fnd_' ``` ### With a JWT ```bash curl -X GET 'http://localhost:9000/download?key=secondapp-admin%2Fstable%2Flinux%2Famd64%2Fsecondapp-0.0.1.deb' \ --header 'Authorization: Bearer ' ``` ## Response ### Download token, or `unlisted` app (302 Found) The response redirects to the signed URL of the file. ### JWT with access (200 OK) ```json { "download_url": "https://.s3.amazonaws.com/secondapp-admin/stable/linux/amd64/secondapp-0.0.1.deb?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=MdnaDEXKy9nOc4beIvNcgy%2F20250409%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20250409T153628Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host&X-Amz-Signature=bb89a695151c19880c2a2de6620e49a8f532ef1b356542f4821e701c32db9382" } ``` ### Denied or unknown key (404 Not Found) ```json { "error": "Artifact not found" } ``` A denied request gets exactly the same answer as a key that does not exist, so the endpoint cannot be used to find out which private artifacts exist. ### Response Fields | Field | Type | Description | |-------|------|-------------| | `download_url` | string | Signed URL for downloading the file (JWT requests only) | ## Notes - The signed URL expires after 15 minutes - URL encoding is required for the `key` parameter - Only keys of artifacts that belong to a private app resolve; any other key returns `404` - A download token is scoped to one app and one channel: a token for `beta` does not open an artifact in `stable` - Clients of a `strict` app must send the same header on [update checks](../info/latest) too, not only on `/download` - If your HTTP client follows the redirect, do not forward `X-Download-Token` to the storage host - `ENABLE_PRIVATE_APP_DOWNLOADING` no longer controls this endpoint; it only sets the default `download_mode` for new private apps (see [environment variables](../../getting-started/env-overview)) --- ## Get All Applications (deprecated) :::warning This endpoint is deprecated. Use `/app/list` instead for better performance and cleaner response format. ::: Retrieve a list of all applications with their versions and artifacts. ## Endpoint ``` GET / ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "apps": [ { "ID": "653a544c1ff613bed613df5b", "AppName": "firstapp", "Version": "0.0.1", "Channel": "nightly", "Published": false, "Artifacts": [ { "Link": "https://.s3.amazonaws.com/firstapp/nightly/linux/amd64/firstapp-0.0.1.deb", "Platform": "linux", "Arch": "amd64", "Package": ".deb" } ], "Changelog": [ { "Version": "0.0.1", "Changes": "", "Date": "2023-10-26" } ], "Updated_at": "2023-10-26T14:58:04.258+03:00" }, { "ID": "653a5e4f51ce5114611f5abb", "AppName": "secondapp", "Version": "0.0.1", "Channel": "stable", "Published": true, "Artifacts": [ { "Link": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.1.deb", "Platform": "linux", "Arch": "amd64", "Package": ".deb" }, { "Link": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.1.rpm", "Platform": "linux", "Arch": "amd64", "Package": ".rpm" } ], "Changelog": [ { "Version": "0.0.1", "Changes": "", "Date": "2023-10-26" } ], "Updated_at": "2023-10-26T15:40:47.226+03:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `apps` | array | Array of application objects | #### Application Object Fields | Field | Type | Description | |-------|------|-------------| | `ID` | string | Unique identifier of the application version | | `AppName` | string | Name of the application | | `Version` | string | Version number | | `Channel` | string | Release channel (e.g., stable, nightly) | | `Published` | boolean | Whether the version is published | | `Artifacts` | array | Array of artifact objects | | `Changelog` | array | Array of changelog entries | | `Updated_at` | string | Last update timestamp | #### Artifact Object Fields | Field | Type | Description | |-------|------|-------------| | `Link` | string | Direct download URL for the artifact | | `Platform` | string | Target platform (e.g., linux, windows) | | `Arch` | string | Target architecture (e.g., amd64, arm64) | | `Package` | string | Package format (e.g., .deb, .rpm) | #### Changelog Entry Fields | Field | Type | Description | |-------|------|-------------| | `Version` | string | Version number | | `Changes` | string | Changelog content in markdown format | | `Date` | string | Release date | ## Notes - This endpoint returns all versions of all applications, which can be slow for large datasets - Consider using `/app/list` for a simpler list of applications or `/search` for specific applications - The response includes detailed information about each version and its artifacts --- ## List Applications Retrieve a list of all existing applications with basic information. ## Endpoint ``` GET /app/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/app/list -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "apps": [ { "ID": "66ae18a29807f0696d967176", "AppName": "first", "Logo": "", "Updated_at": "2024-08-03T14:46:42.361+03:00" }, { "ID": "66ae14024b663c058367f895", "AppName": "myapp", "Logo": "", "Updated_at": "2024-08-03T14:26:58.701+03:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `apps` | array | Array of application objects | #### Application Object Fields | Field | Type | Description | |-------|------|-------------| | `ID` | string | Unique identifier of the application | | `AppName` | string | Name of the application | | `Logo` | string | URL or path to the application logo (empty if not set) | | `Updated_at` | string | Last update timestamp | ## Notes - This endpoint provides a lightweight list of applications without version details - Use `/search?app_name=` to get detailed information about specific applications - The response is optimized for performance and includes only essential application metadata --- ## Search application by name Search for all versions of an application by name with optional filtering. ## Endpoint ``` GET /search?app_name= ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_name` | string | ✅ | Name of the application to search for | | `limit` | number | ❌ | Maximum number of records to return | ### Optional Filters | Parameter | Type | Description | |-----------|------|-------------| | `channel` | string | Filter by channel name (e.g., stable, beta) | | `published` | boolean | Set to `true` to return only published versions | | `critical` | boolean | Set to `true` to return only critical versions | | `platform` | string | Filter by platform (e.g., linux, windows) | | `arch` | string | Filter by architecture (e.g., amd64, arm64) | ## Example Request ### Basic Search ```bash curl -X GET --location 'http://localhost:9000/search?app_name=secondapp' \ --header 'Authorization: Bearer ' ``` ### Search with Filters ```bash curl -X GET --location 'http://localhost:9000/search?app_name=secondapp&channel=stable&published=true&limit=10' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) ```json { "apps": [ { "ID": "653a5e4f51ce5114611f5abb", "AppName": "secondapp", "Version": "0.0.1", "Channel": "stable", "Published": true, "Artifacts": [ { "Link": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.1.deb", "Platform": "linux", "Arch": "amd64", "Package": ".deb" }, { "Link": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.1.rpm", "Platform": "linux", "Arch": "amd64", "Package": ".rpm" } ], "Changelog": [ { "Version": "0.0.1", "Changes": "### Changelog\n\n- Added new feature X\n- Fixed bug Y", "Date": "2023-10-26" } ], "Updated_at": "2023-10-26T15:40:47.226+03:00" }, { "ID": "653a6268f51dee6a99a3d88c", "AppName": "secondapp", "Version": "0.0.3", "Channel": "stable", "Published": true, "Artifacts": [ { "Link": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.deb", "Platform": "linux", "Arch": "amd64", "Package": ".deb" }, { "Link": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.rpm", "Platform": "linux", "Arch": "amd64", "Package": ".rpm" } ], "Changelog": [ { "Version": "0.0.3", "Changes": "### Changelog\n\n- Added new feature X\n- Fixed bug Y", "Date": "2023-10-26" } ], "Updated_at": "2023-10-26T15:58:16.999+03:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `apps` | array | Array of application version objects | #### Application Version Object Fields | Field | Type | Description | |-------|------|-------------| | `ID` | string | Unique identifier of the application version | | `AppName` | string | Name of the application | | `Version` | string | Version number | | `Channel` | string | Release channel (e.g., stable, nightly) | | `Published` | boolean | Whether the version is published | | `Artifacts` | array | Array of artifact objects | | `Changelog` | array | Array of changelog entries | | `Updated_at` | string | Last update timestamp | #### Artifact Object Fields | Field | Type | Description | |-------|------|-------------| | `Link` | string | Direct download URL for the artifact | | `Platform` | string | Target platform (e.g., linux, windows) | | `Arch` | string | Target architecture (e.g., amd64, arm64) | | `Package` | string | Package format (e.g., .deb, .rpm) | #### Changelog Entry Fields | Field | Type | Description | |-------|------|-------------| | `Version` | string | Version number | | `Changes` | string | Changelog content in markdown format | | `Date` | string | Release date | ## Notes - Results are typically sorted by version (newest first) - The `limit` parameter helps control response size for applications with many versions - Multiple filters can be combined for precise searching - Empty results array is returned if no matching applications are found --- ## Update application Update an existing application's metadata including name, logo, and description. :::warning Changing the application name might prevent existing apps from checking for new versions, as the application name will have changed. ::: ## Endpoint ``` POST /app/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `multipart/form-data` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | The ID of the application you want to update | | `app` | string | ❌ | New application name | | `file` | file | ❌ | New logo for the application | | `description` | string | ❌ | New application description | | `tuf` | boolean | ❌ | Enable TUF framework for application | | `cdn` | boolean | ❌ | Enable or disable API response caching on S3. Cannot be enabled for private apps | | `download_mode` | string | ❌ | Private apps only: `unlisted` or `strict`, who may [download](./download) artifacts and check for updates | ## Example Request ```bash curl --location 'http://localhost:9000/app/update' \ -H "Authorization: Bearer " \ --form 'data="{\"id\":\"66ae13fe5b663c058367f893\", \"app\":\"new_name\", \"tuf\": \"true\", \"cdn\": \"true\"}"' ``` ## Response ### Success Response (200 OK) ```json { "updateAppResult.Updated": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `updateAppResult.Updated` | boolean | Indicates whether the application was successfully updated | ## Notes - Only the fields you provide will be updated - omitted fields remain unchanged - The `file` field accepts common image formats (PNG, JPG, etc.) - Application name changes should be done carefully as they may break existing client integrations - Set `cdn` to `true` to cache API responses on S3, or `false` to disable this caching - `cdn: true` on a private app is rejected with `400` - `download_mode` on a public app, or with a value other than `unlisted` / `strict`, is rejected with `400`. A change takes effect on the next request: responses of private apps are never cached - `private` itself cannot be changed after creation --- ## Update specific version of application Update an existing specific version of an application with new artifacts and metadata. You can specify an updater type for artifact isolation when updating files for updaters that generate special files. :::warning You cannot change `app_name`, `channel` and `version`. `app_name` and `version` are used for correct searching. ::: ## Endpoint ``` POST /apps/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `multipart/form-data` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | file | ❌ | App binary file(s) | | `app_name` | string | ✅ | Name of the application | | `version` | string | ✅ | Version of the application | | `channel` | string | ❌ | Channel (e.g., stable, beta) | | `publish` | boolean | ❌ | Marks the version available for users | | `critical` | boolean | ❌ | Marks the version as critical | | `platform` | string | ❌ | Platform (e.g., linux, windows) | | `arch` | string | ❌ | Architecture (e.g., amd64, arm64) | | `changelog` | string | ❌ | Changelog in markdown format | | `updater` | string | ❌ | Updater type for artifact isolation (manual, velopack, sparkle, squirrel_darwin, squirrel_windows, electron-builder, tauri) | | `signature` | string | ❌ | Cryptographic signature (required for Tauri updater) | :::info The `updater` parameter is useful when updating files for updaters that generate or ingest special files (like RELEASES for Squirrel Windows, *.yml for electron-builder, `releases.{channel}.json` for Velopack, or `appcast.{channel}.xml` for Sparkle). It ensures files are stored in isolated folders specific to that updater. For Sparkle, the edit flow re-ingests the appcast so metadata changes (including adding a platform/arch) carry into the materialized feed. ::: ## Example Requests ### Single File Upload (Standard) ```bash curl --location 'http://localhost:9000/apps/update' \ --header 'Authorization: Bearer ' \ --form 'data="{\"id\": \"653a6268f51dee6a99a3d88c\", \"app_name\": \"secondapp\", \"version\": \"0.0.2\", \"channel\": \"stable\", \"publish\": true, \"platform\": \"linux\", \"arch\": \"amd64\", \"changelog\": \"\"}"' \ --form 'file=@"/path_to_file/secondapp.deb"' ``` ### Update with Updater Parameter (Squirrel Windows) ```bash curl --location 'http://localhost:9000/apps/update' \ --header 'Authorization: Bearer ' \ --form 'data="{\"id\": \"653a6268f51dee6a99a3d88c\", \"app_name\": \"secondapp\", \"version\": \"0.0.2\", \"channel\": \"stable\", \"publish\": true, \"platform\": \"windows\", \"arch\": \"amd64\", \"updater\": \"squirrel_windows\", \"changelog\": \"\"}"' \ --form 'file=@"/path_to_file/secondapp.exe"' \ --form 'file=@"/path_to_file/RELEASES"' ``` ### Update with Updater Parameter (Electron Builder) ```bash curl --location 'http://localhost:9000/apps/update' \ --header 'Authorization: Bearer ' \ --form 'data="{\"id\": \"653a6268f51dee6a99a3d88c\", \"app_name\": \"secondapp\", \"version\": \"0.0.2\", \"channel\": \"stable\", \"publish\": true, \"platform\": \"windows\", \"arch\": \"amd64\", \"updater\": \"electron-builder\", \"changelog\": \"\"}"' \ --form 'file=@"/path_to_file/secondapp.exe"' \ --form 'file=@"/path_to_file/latest.yml"' ``` ### Multiple File Upload (Standard) ```bash curl --location 'http://localhost:9000/apps/update' \ --header 'Authorization: Bearer ' \ --form 'data="{\"id\": \"653a6268f51dee6a99a3d88c\", \"app_name\": \"secondapp\", \"version\": \"0.0.2\", \"channel\": \"stable\", \"publish\": true, \"platform\": \"linux\", \"arch\": \"amd64\", \"changelog\": \"\"}"' \ --form 'file=@"/path_to_file/secondapp.deb"' \ --form 'file=@"/path_to_file/secondapp.rpm"' ``` ## Response ### Success Response (200 OK) ```json { "updatedResult.Updated": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `updatedResult.Updated` | boolean | Indicates whether the version was successfully updated | ## Updater-Specific File Organization When you specify an `updater` parameter, files are organized in isolated folders: ### File Structure Examples **Standard Update (No Updater):** ``` /secondapp/stable/linux/amd64/ ├── secondapp-0.0.1.deb └── secondapp-0.0.1.rpm ``` **Squirrel Windows Update:** ``` /squirrel_windows/secondapp/stable/windows/amd64/0.0.2/ ├── secondapp.exe └── RELEASES ``` **Electron Builder Update:** ``` /electron-builder/secondapp/stable/windows/amd64/0.0.2/ ├── secondapp.exe └── latest.yml ``` ## Supported Updater Types | Updater | Generated Files | Use Case | Special Requirements | |---------|-----------------|----------|---------------------| | `manual` | None | Standard FaynoSync uploads | None | | `velopack` | `releases.{channel}.json` | Velopack cross-platform desktop apps | Requires channel, platform, and arch | | `sparkle` | `appcast.{channel}.xml` | Native macOS apps using Sparkle | macOS only; upload the `generate_appcast` appcast + archives | | `squirrel_darwin` | None | Squirrel macOS applications | None | | `squirrel_windows` | RELEASES | Squirrel Windows applications | None | | `electron-builder` | *.yml | Electron applications | None | | `tauri` | None | Tauri applications with built-in updater | Requires `signature` field | ## Notes - You can upload multiple files for different platforms/architectures in a single request - The `changelog` field supports markdown formatting - Critical versions are typically used for security updates or critical bug fixes - Published versions are immediately available to end users - Only the fields you provide will be updated - omitted fields remain unchanged - **Updater Parameter**: Use the `updater` parameter when updating files for updaters that generate special files (RELEASES, *.yml) - **Artifact Isolation**: Files updated with an updater parameter are stored in isolated folders to prevent conflicts - **File Organization**: Generated files (RELEASES, *.yml) are properly organized when using updater-specific updates - **Signature Field**: When using Tauri updater, the `signature` field is required for cryptographic verification of updates. This signature is generated by Tauri's build process and ensures update integrity and authenticity. - **Private Apps**: a private app accepts only the `manual`, `tauri` and `squirrel_darwin` updaters. `velopack`, `sparkle`, `electron-builder` and `squirrel_windows` need a publicly served update feed, so they are rejected with `400`: `updater is not supported for private apps: requires a publicly served update feed; use manual, tauri or squirrel_darwin, or a public app` - **Files cannot replace stored ones.** Added files are checked before anything is written. A file whose platform, architecture and extension the version already has returns `409` with `app with this name, version, platform, architecture and extension already exists`, and the stored file is left untouched — to replace it, [delete the artifact](./delete-specific-artifact) first and upload the new one. Two files of the request that would share a storage key or an extension return `400`. A `channel` different from the version's returns `409` (`the channel of a version cannot be changed`); omitting `channel` keeps the version's own, and added files are stored in that channel's folder. Update feeds (`RELEASES`, `*.yml`, `releases.{channel}.json`, `appcast*.xml`) are exempt, since Velopack and Sparkle require the feed on every update and faynoSync regenerates it. `.nupkg`, `.delta` and `.blockmap` may repeat, but not under the storage key of a file the version already has — re-sending the same one returns `409`. Previously a re-sent file overwrote the stored object while the version kept the old file's hashes --- ## Upload Complete (Presigned) Finishes a [presigned upload](/docs/presigned-uploads) started with [Upload Init](./upload-init). faynoSync reads size and MD5 of every uploaded file from object storage, compares them with the manifest recorded at init, moves the files from the staging prefix to their final keys (server-side copy) and creates the version exactly as [`POST /upload`](./upload) does. The request carries only the `upload_id`: version metadata, file names and hashes are taken from init and cannot be changed here. ## Endpoint ``` POST /upload/complete ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | Must be the same user that called init. A [CI/CD token](/docs/api/tokens/tokens-introduction) must still have access to the app. ## Request Body ```json { "upload_id": "f0a2a06eb2ab9e945eefe8e287ac41d9" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `upload_id` | string | ✅ | The `upload_id` returned by [Upload Init](./upload-init) | ## Example Request ```bash curl -X POST --location 'http://localhost:9000/upload/complete' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{"upload_id":"f0a2a06eb2ab9e945eefe8e287ac41d9"}' ``` ## Response ### Success Response (200 OK) Same as [`POST /upload`](./upload): ```json { "uploadResult.Uploaded": "6ab3af0558c1b6f28063faef" } ``` | Field | Type | Description | |-------|------|-------------| | `uploadResult.Uploaded` | string | The unique identifier (ID) of the uploaded version | ### Files Do Not Match (422 Unprocessable Entity) ```json { "error": "uploaded files do not match the upload manifest", "files": [ "MyApp-1.4.0-full.nupkg: not uploaded", "MyApp-win-Setup.exe: stored 1048575 bytes, declared 1048576" ] } ``` The upload stays open after a `422`: re-run the failed PUTs while their URLs are valid and call complete again. It also stays open after a `403`, or after an error that happened before any artifact was recorded, so the same `upload_id` can be completed once the cause is fixed. ### Error Responses | Status | When | |--------|------| | `400` | Missing or malformed `upload_id` | | `401` | Missing or invalid token | | `403` | The CI/CD token has no access to the app, or the app, channel, platform or architecture is no longer in the [team user's](/docs/team_based_auth) allowed lists. Access is checked again at complete, before any file is moved | | `404` | Unknown `upload_id`, an upload started by another user, an upload whose pending record expired (URL lifetime + 90 minutes, 2 hours by default), or one that was already completed | | `409` | The app was switched between private and public after init, or an artifact with the same app, version, platform, architecture and extension was uploaded after init — start a new upload | | `422` | A file was not uploaded, or its size or MD5 in storage differs from the manifest | | `501` | The configured storage driver does not support presigned uploads | ## Notes - Complete is single-use: after a successful call the same `upload_id` returns `404`, so a retried CI step cannot create the version twice. - Staged objects under `pending//` are deleted after a successful complete. - The artifact length is the size reported by storage, whether or not `length` was declared at init. - SHA-256 and SHA-512 from the manifest are stored as **unverified**. [TUF publish](/docs/api/tuf/publish) checks them against the stored bytes before signing; apps without TUF do not use them. See [TUF: hashes are verified before signing](/docs/presigned-uploads#tuf-hashes-are-verified-before-signing). - Updater feeds (Velopack, Sparkle) are regenerated and caches are invalidated the same way as after [`POST /upload`](./upload). --- ## Upload Init (Presigned) Starts a [presigned upload](/docs/presigned-uploads). faynoSync validates the version metadata and the file manifest, stores the inline feed files, and returns one presigned PUT URL per file. The uploader then sends each file directly to object storage and finishes with [Upload Complete](./upload-complete). All checks of a regular [`POST /upload`](./upload) run here, before any file is transferred. ## Endpoint ``` POST /upload/init ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | A [CI/CD token](/docs/api/tokens/tokens-introduction) works as well and must have access to the app. ## Request Body `multipart/form-data` (or `application/x-www-form-urlencoded` when there are no inline feed files): | Field | Type | Required | Description | |-------|------|----------|-------------| | `data` | string (JSON) | ✅ | Version metadata — the same JSON as the `data` field of [`POST /upload`](./upload): `app_name`, `version`, `channel`, `publish`, `critical`, `platform`, `arch`, `changelog`, `updater`, `signature`, `rollout`, ... | | `files` | string (JSON) | ✅ | Manifest of the files that will be uploaded through presigned URLs (see below) | | `file` | file | ❌ | Updater feed files only (`releases.{channel}.json`, `appcast*.xml`, `RELEASES`, `*.yml`), sent inline. Max 10 MiB each | ### `files` manifest A JSON array with one entry per file: ```json [ { "name": "MyApp-1.4.0-full.nupkg", "md5": "fed304a43b43b889ac1b948e073928d0", "sha256": "d73428d4e21289de53fa964274ed1dc0e39a4f58e3825fc69617c9d3dee366d7", "sha512": "5a1b...(128 hex characters)", "length": 52428800 } ] ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | ✅ | File name without any path. Must be unique within the upload | | `md5` | string | ✅ | Hex MD5 of the file (32 characters). Signed into the PUT URL; storage rejects bytes that do not match | | `sha256` | string | ✅ for TUF apps | Hex SHA-256 (64 characters). Verified against the stored bytes before [TUF signing](/docs/api/tuf/publish) | | `sha512` | string | ✅ for TUF apps | Hex SHA-512 (128 characters). Signed into TUF targets together with `sha256` | | `length` | integer | ❌ | Size in bytes, up to 5 GiB. When set, it is signed as `Content-Length`. The stored length always comes from object storage | :::info Required for TUF apps `sha256` and `sha512` are not checked by init, because only [TUF publish](/docs/api/tuf/publish) uses them. For an app with TUF enabled they are effectively required: an artifact uploaded without them cannot be signed, and TUF clients will not be able to download it. For apps without TUF they can be omitted. ::: Digests are case-insensitive. Compute them from the same bytes you upload. ## Example Request ```bash curl -X POST --location 'http://localhost:9000/upload/init' \ --header 'Authorization: Bearer ' \ --form-string 'data={"app_name":"myapp","version":"1.4.0","channel":"stable","publish":true,"platform":"windows","arch":"amd64","updater":"velopack"}' \ --form-string 'files=[{"name":"MyApp-1.4.0-full.nupkg","md5":"","sha256":"","sha512":"","length":52428800}]' \ --form 'file=@"/path_to_file/releases.stable.json"' ``` Use `--form-string` for `data` and `files` so that `curl` does not interpret `@` or `<` inside the JSON. A complete script (hashing, init, PUT, complete) is on the [Presigned Uploads](/docs/presigned-uploads#complete-example-with-curl) page. ## Response ### Success Response (200 OK) ```json { "upload_id": "f0a2a06eb2ab9e945eefe8e287ac41d9", "expires_at": "2026-09-23T14:20:44.857939+03:00", "files": [ { "name": "MyApp-1.4.0-full.nupkg", "method": "PUT", "url": "https://my-bucket.s3.eu-central-1.amazonaws.com/pending/f0a2a06eb2ab9e945eefe8e287ac41d9/1?X-Amz-Algorithm=...", "headers": { "Content-Length": "52428800", "Content-Md5": "cqfhk/D1vmKz3g30/HpOiQ==" } } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `upload_id` | string | Identifier to pass to [Upload Complete](./upload-complete) | | `expires_at` | string | When the presigned URLs expire (30 minutes after init by default, see [`PRESIGNED_UPLOAD_URL_TTL`](/docs/getting-started/env-overview#presigned-uploads)) | | `files[].name` | string | File name from the manifest | | `files[].method` | string | Always `PUT` | | `files[].url` | string | Presigned URL pointing directly at object storage | | `files[].headers` | object | Headers that **must** be sent with the PUT exactly as returned. They are part of the signature. `Content-Length` is present only when `length` was declared | Inline feed files are stored by faynoSync during init and are not listed in `files`. Upload each file with: ```bash curl -X PUT -H 'Content-Length: <...>' -H 'Content-Md5: <...>' --upload-file MyApp-1.4.0-full.nupkg '' ``` ### Error Responses | Status | When | |--------|------| | `400` | Invalid `data` or `files`; a file name with a path; duplicate names; a missing `md5`; a digest that is not hex of the right length; `length` above 5 GiB or negative; a feed file in the manifest or a non-feed file inline; updater file rules not met; an updater that private apps do not support; two files that would share an extension or storage key | | `401` | Missing or invalid token | | `403` | The CI/CD token has no access to the app, or the app, channel, platform or architecture is not in the [team user's](/docs/team_based_auth) allowed lists | | `404` | The app does not exist | | `409` | An artifact with this app, version, platform, architecture and extension already exists (for `.nupkg`, `.delta` and `.blockmap`: one stored under the same key), or the version already exists in another channel (the channel of a version cannot be changed) | | `501` | The configured storage driver does not support presigned uploads (MinIO). Use [`POST /upload`](./upload) | ## Notes - Presigned URLs point at the storage API endpoint, which must be reachable from the machine doing the PUT. - Treat the URLs as credentials: they allow writing one staging object until they expire. Do not log them. - Files are staged under `pending//` and moved to their final keys by [Upload Complete](./upload-complete). Configure a [lifecycle rule](/docs/presigned-uploads#clean-up-abandoned-uploads-with-a-lifecycle-rule) for abandoned uploads. - Storage-specific behavior is described in [Storage provider differences](/docs/presigned-uploads#storage-provider-differences). --- ## Upload App Upload a new version of an application, including the binary file and relevant metadata. You can specify an updater type for artifact isolation when uploading files for updaters that generate special files. ## Endpoint ``` POST /upload ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `multipart/form-data` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `file` | file | ✅ | App binary file(s) | | `app_name` | string | ✅ | Name of the application | | `version` | string | ✅ | Version of the application | | `channel` | string | ❌ | Channel (e.g., stable, beta) | | `publish` | boolean | ❌ | Marks the version available for users | | `critical` | boolean | ❌ | Marks the version as critical | | `platform` | string | ❌ | Platform (e.g., linux, windows) | | `arch` | string | ❌ | Architecture (e.g., amd64, arm64) | | `changelog` | string | ❌ | Changelog in markdown format | | `updater` | string | ❌ | Updater type for artifact isolation (manual, velopack, sparkle, squirrel_darwin, squirrel_windows, electron-builder, tauri) | | `signature` | string | ❌ | Cryptographic signature (required for Tauri updater) | :::tip Large files Every file sent to this endpoint passes through faynoSync and the reverse proxy in front of it. For large builds, use [presigned uploads](/docs/presigned-uploads) ([Upload Init](./upload-init) + [Upload Complete](./upload-complete)) to send files directly to object storage. ::: :::info The `updater` parameter is useful when uploading files for updaters that generate or ingest special files (like RELEASES for Squirrel Windows, *.yml for electron-builder, `releases.{channel}.json` for Velopack, or `appcast.{channel}.xml` for Sparkle). It ensures files are stored in isolated folders specific to that updater. ::: ## Example Requests ### Single File Upload (Standard) ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.deb"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"linux\",\"arch\":\"amd64\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ### Upload with Updater Parameter (Squirrel Windows) ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.exe"' \ --form 'file=@"/path_to_file/RELEASES"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"windows\",\"arch\":\"amd64\",\"updater\":\"squirrel_windows\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ### Upload with Updater Parameter (Electron Builder) ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.exe"' \ --form 'file=@"/path_to_file/latest.yml"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"windows\",\"arch\":\"amd64\",\"updater\":\"electron-builder\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ### Upload with Updater Parameter (Tauri) ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.app.tar.gz"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"1.0.0\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"amd64\",\"updater\":\"tauri\",\"signature\":\"dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVUUzIzUGJLcHpQNHdCVEJzTXJjbWFhUUdUVEtaUGRLdGhWdGZJZkF2VmM0TzJGMkdPQUk4V1hzYWJuUUhWbWI2cTFWTkhEZE9lbkVwUERCQmhRPT0K\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ### Upload with Updater Parameter (Sparkle, macOS) Sparkle has no `signature` field — the `edSignature` already lives inside the uploaded appcast. Upload the `generate_appcast` output plus every archive/delta it references: ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/appcast.stable.xml"' \ --form 'file=@"/path_to_file/MyApp-1.0.0.zip"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"1.0.0\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"darwin\",\"arch\":\"arm64\",\"updater\":\"sparkle\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ### Multiple File Upload (Standard) ```bash curl -X POST --location 'http://localhost:9000/upload' \ --header 'Authorization: Bearer ' \ --form 'file=@"/path_to_file/myapp.deb"' \ --form 'file=@"/path_to_file/myapp.rpm"' \ --form 'data="{\"app_name\":\"myapp\",\"version\":\"0.0.1\",\"channel\":\"stable\",\"publish\":true,\"platform\":\"linux\",\"arch\":\"amd64\",\"changelog\":\"### Changelog\\n\\n- Added new feature X\\n- Fixed bug Y\"}"' ``` ## Response ### Success Response (200 OK) ```json { "uploadResult.Uploaded": "6411c7c0ec4ff9a9a9bc18fa" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `uploadResult.Uploaded` | string | The unique identifier (ID) of the uploaded version | ## Updater-Specific File Organization When you specify an `updater` parameter, files are organized in isolated folders: ### File Structure Examples **Standard Upload (No Updater):** ``` /myapp/stable/linux/amd64/ ├── myapp-0.0.1.deb └── myapp-0.0.1.rpm ``` **Squirrel Windows Upload:** ``` /squirrel_windows/myapp/stable/windows/amd64/0.0.1/ ├── myapp.exe └── RELEASES ``` **Electron Builder Upload:** ``` /electron-builder/myapp/stable/windows/amd64/0.0.1/ ├── myapp.exe └── latest.yml ``` **Sparkle Upload (materialized per platform/arch):** ``` /sparkle/admin/myapp/darwin/arm64/ ├── MyApp-1.0.0.zip └── appcast.stable.xml ``` ## Supported Updater Types | Updater | Generated Files | Use Case | Special Requirements | |---------|-----------------|----------|---------------------| | `manual` | None | Standard FaynoSync uploads | None | | `velopack` | `releases.{channel}.json` | Velopack cross-platform desktop apps | Requires channel, platform, and arch | | `sparkle` | `appcast.{channel}.xml` | Native macOS apps using Sparkle | macOS only; upload the `generate_appcast` appcast + archives | | `squirrel_darwin` | None | Squirrel macOS applications | None | | `squirrel_windows` | RELEASES | Squirrel Windows applications | None | | `electron-builder` | *.yml | Electron applications | None | | `tauri` | None | Tauri applications with built-in updater | Requires `signature` field | ## Notes - You can upload multiple files for different platforms/architectures in a single request - The `changelog` field supports markdown formatting - Critical versions are typically used for security updates or critical bug fixes - Published versions are immediately available to end users - The `channel` field helps organize releases (e.g., stable, beta, nightly) - File uploads support common package formats (.deb, .rpm, .exe, .dmg, etc.) - **Updater Parameter**: Use the `updater` parameter when uploading files for updaters that generate special files (RELEASES, *.yml) - **Artifact Isolation**: Files uploaded with an updater parameter are stored in isolated folders to prevent conflicts - **File Organization**: Generated files (RELEASES, *.yml) are properly organized when using updater-specific uploads - **Signature Field**: When using Tauri updater, the `signature` field is required for cryptographic verification of updates. This signature is generated by Tauri's build process and ensures update integrity and authenticity. - **Private Apps**: a private app accepts only the `manual`, `tauri` and `squirrel_darwin` updaters. `velopack`, `sparkle`, `electron-builder` and `squirrel_windows` need a publicly served update feed, so they are rejected with `400`: `updater is not supported for private apps: requires a publicly served update feed; use manual, tauri or squirrel_darwin, or a public app` - Uploading to an app that does not exist returns `404` - **Conflicts are checked before anything is written.** If two files of the request would share a storage key or an extension, the request returns `400` (`files "" and "" would be stored under the same key` / `... have the same extension`). If the version already has an artifact with the same platform, architecture and extension, it returns `409` with `app with this name, version, platform, architecture and extension already exists`; if the version exists in another channel, `409` with `this version already exists in another channel; the channel of a version cannot be changed` (the same codes as [presigned uploads](./upload-init)). Before this change both conflicts returned `500`. In both cases no file is written to storage and no artifact is recorded, so a stored file is never overwritten and a retry never meets a half-recorded version. `.nupkg`, `.delta` and `.blockmap` may repeat within one platform and architecture (full and delta packages, the `.zip.blockmap` and `.dmg.blockmap` of an electron-builder macOS build) as long as each is stored under its own key. Without an updater, files are stored as `{app}-{version}{ext}`, so a second `.nupkg`, `.delta` or `.blockmap` for the same version, platform and architecture also returns `409` - **Team users**: the app, channel, platform and architecture must all be in the team user's allowed lists (see [Team Based Authorization](/docs/team_based_auth)). Otherwise the request returns `403` with `you don't have access to this `, and no file is written to storage. Before v2.4.0 these lists were not checked on this endpoint. --- ## Architecture Management Work with architecture for different types of applications. These endpoints allow you to manage deployment architecture within the **FaynoSync** API. Archs help organize and manage different deployment environments such as amd64, arm64, or custom architecture. --- ## Create arch Create a deployment architecture for applications. :::warning After creating the first architecture, the arch field becomes required for all subsequent operations. ::: ## Endpoint ``` POST /arch/create ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `application/json` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `arch` | string | ✅ | Architecture of the application (e.g., amd64, arm64) | ## Example Request ```bash curl --location 'http://localhost:9000/arch/create' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "arch": "amd64" }' ``` ## Response ### Success Response (200 OK) ```json { "createArchResult.Created": "641459ffb8360d74164e7e3c" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `createArchResult.Created` | string | The unique identifier (ID) of the created architecture | ## Notes - Once created, architectures can be used when uploading application artifacts --- ## Delete Specific Arch Delete a specific architecture by its unique identifier. ## Endpoint ``` DELETE /arch/delete?id= ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string | ✅ | The unique identifier of the architecture to delete | ## Example Request ```bash curl -X DELETE 'http://localhost:9000/arch/delete?id=64145ebaedd163d59d52e1dc' \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "deleteArchResult.DeletedCount": 1 } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `deleteArchResult.DeletedCount` | number | Number of architectures successfully deleted (should be 1 if successful) | ## Notes - This operation permanently deletes the architecture - The deletion is irreversible - deleted architectures cannot be recovered - Ensure no applications are currently using this architecture before deletion --- ## Get All Architectures Retrieve a list of all available architectures in the system. ## Endpoint ``` GET /arch/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/arch/list \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "archs": [ { "ID": "653a52611ff613bed613df59", "ArchID": "amd64", "Updated_at": "2023-10-26T14:49:53.815+03:00" }, { "ID": "653a52691ff613bed613df5a", "ArchID": "arm64", "Updated_at": "2023-10-26T14:50:01.413+03:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `archs` | array | Array of architecture objects | #### Architecture Object Fields | Field | Type | Description | |-------|------|-------------| | `ID` | string | Unique identifier of the architecture | | `ArchID` | string | Architecture identifier (e.g., amd64, arm64) | | `Updated_at` | string | Last update timestamp | ## Notes - This endpoint returns all architectures available for application deployment - The `ArchID` field contains the standard architecture name used in uploads - Results are typically sorted by creation date (newest first) - Empty results array is returned if no architectures are configured --- ## Update Arch Update an existing architecture's identifier. :::warning Changing the architecture name might prevent existing apps from checking for new versions, as the architecture identifier will have changed. ::: ## Endpoint ``` POST /arch/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `application/json` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | The ID of the architecture you want to update | | `arch` | string | ✅ | New architecture identifier | ## Example Request ```bash curl --location 'http://localhost:9000/arch/update' \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data '{ "id": "66ae13fe5b663c058367f893", "arch": "new_name" }' ``` ## Response ### Success Response (200 OK) ```json { "updateArchResult.Updated": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `updateArchResult.Updated` | boolean | Indicates whether the architecture was successfully updated | ## Notes - Architecture name changes should be done carefully as they may break existing client integrations - The change affects all future uploads using this architecture --- ## Authentication Management The Authentication Management endpoints handle user registration and login within the **FaynoSync** API. These routes ensure secure access to the system by allowing users to sign up and authenticate with their credentials. --- ## Login Authenticate and receive a JWT token for accessing the API. ## Endpoint ``` POST /login ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `username` | string | ✅ | The username of your account | | `password` | string | ✅ | The password for your account | ## Example Request ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "password"}' \ http://localhost:9000/login ``` ## Response ### Success Response (200 OK) ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjY3NDQ4NDMsInVzZXJuYW1lIjoiYWRtaW4ifQ.eYkCNem24-6rpw8aXo6NIcN6xtU9rqq2_2YYz1nS82Q" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `token` | string | JWT token for authenticating subsequent API requests | ### Error Responses | Status | Body | When | |--------|------|------| | `400` | `{"error": "invalid request body"}` | The body is not valid JSON | | `401` | `{"error": "invalid username or password"}` | Unknown username or wrong password | | `429` | `{"error": "too many attempts, try again later"}` | Rate limit exceeded, see below | | `503` | `{"error": "service temporarily unavailable"}` | Redis is unavailable, so the rate limit cannot be checked | ## Rate Limiting Login attempts are limited in fixed one-minute windows: - **10 attempts per minute per client IP** - **5 attempts per minute per username**, counted whether or not the username exists. A successful login resets the counter for that username. When a limit is exceeded, the API responds with `429 Too Many Requests` and a `Retry-After` header holding the number of seconds until the current window ends. The response is the same for both limits and does not reveal whether the username exists. The client IP is taken from `X-Forwarded-For` only when the request comes from a proxy listed in [`TRUSTED_PROXIES`](/docs/getting-started/env-overview). ## Notes - The JWT token should be included in the `Authorization` header as `Bearer ` - Tokens have an expiration time - you may need to re-authenticate periodically - Store the token securely and do not share it with unauthorized parties - Use this token for all authenticated API endpoints - If authentication fails, check your username and password credentials --- ## SignUp This endpoint allows a new user to register and authenticate by providing the necessary credentials. Upon successful registration, the user will receive a confirmation message, and further actions will require a JWT token for secure access to the API. ## Endpoint ``` POST /signup ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `username` | string | ✅ | The username for your account | | `password` | string | ✅ | The password for the new account | | `api_key` | string | ✅ | API key required for signing up (check this value in `.env` file) | ## Example Request ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "password", "api_key": "insecure-dev-api-key"}' \ http://localhost:9000/signup ``` ## Response ### Success Response (200 OK) ```json { "result": "Successfully created admin user." } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `result` | string | Confirmation message indicating successful user creation | ### Error Responses | Status | Body | When | |--------|------|------| | `400` | `{"error": "invalid request body"}` | The body is not valid JSON | | `401` | `{"error": "wrong api key"}` | `api_key` does not match the server's `API_KEY` | | `401` | `{"error": "user already exists"}` | An admin with this username already exists | | `429` | `{"error": "too many attempts, try again later"}` | Rate limit exceeded, see below | | `503` | `{"error": "service temporarily unavailable"}` | Redis is unavailable, so the rate limit cannot be checked | ## Rate Limiting Signup requests are limited in fixed one-hour windows: - **5 requests per hour per client IP** - **20 requests per hour in total** across all clients When a limit is exceeded, the API responds with `429 Too Many Requests` and a `Retry-After` header holding the number of seconds until the current window ends. ## Notes - The `api_key` must match the value configured in your server's `.env` file - If `API_KEY` is not set on the server, `/signup` is disabled and returns `404` - Usernames must be unique within the system - Passwords should follow security best practices (minimum length, complexity, etc.) - After successful registration, use the `/login` endpoint to obtain a JWT token - The user created typically receives administrative privileges --- ## Channel Management Work with channels for different types of applications. These endpoints allow you to manage deployment channels within the **FaynoSync** API. Channels help organize and manage different deployment environments such as nightly, stable, or custom channels. --- ## Create Channel Create a new deployment channel for organizing application releases. :::warning After creating the first channel, the `channel` field becomes required for all subsequent operations. ::: ## Endpoint ``` POST /channel/create ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `application/json` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `channel` | string | ✅ | The name of the channel to be created (e.g., stable, beta, nightly) | ## Example Request ```bash curl --location 'http://localhost:9000/channel/create' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "channel": "nightly" }' ``` ## Response ### Success Response (200 OK) ```json { "createChannelResult.Created": "641459ffb8360d74164e7e3c" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `createChannelResult.Created` | string | The unique identifier (ID) of the created channel | ## Notes - Channels help organize different types of releases (production, testing, development) - Once created, channels can be used when uploading application versions --- ## Delete Specific Channel Delete a specific deployment channel by its unique identifier. ## Endpoint ``` DELETE /channel/delete?id= ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string | ✅ | The unique identifier of the channel to delete | ## Example Request ```bash curl -X DELETE http://localhost:9000/channel/delete?id=64145ebaedd163d59d52e1dc \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "deleteChannelResult.DeletedCount": 1 } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `deleteChannelResult.DeletedCount` | number | Number of channels successfully deleted (should be 1 if successful) | ## Notes - This operation permanently deletes the channel - The deletion is irreversible - deleted channels cannot be recovered - Ensure no applications are currently using this channel before deletion - You cannot delete the last remaining channel in the system --- ## Get All Channels Retrieve a list of all available deployment channels in the system. ## Endpoint ``` GET /channel/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/channel/list -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "channels": [ { "ID": "641459ffb8360d74164e7e3c", "ChannelName": "nightly", "Updated_at": "2023-03-17T14:15:59.818+02:00" }, { "ID": "64145ebaedd163d59d52e1dc", "ChannelName": "stable", "Updated_at": "2023-03-17T14:36:10.278+02:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `channels` | array | Array of channel objects | #### Channel Object Fields | Field | Type | Description | |-------|------|-------------| | `ID` | string | Unique identifier of the channel | | `ChannelName` | string | Name of the channel (e.g., stable, nightly) | | `Updated_at` | string | Last update timestamp | ## Notes - This endpoint returns all channels available for application deployment - The `ChannelName` field contains the channel identifier used in uploads and searches - Results are typically sorted by creation date (newest first) - Empty results array is returned if no channels are configured --- ## Update Channel Update an existing deployment channel's name. :::warning Changing the channel name may disrupt existing client apps' ability to check for new versions due to the altered channel name. ::: ## Endpoint ``` POST /channel/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `application/json` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | The ID of the channel to update | | `channel` | string | ✅ | The new name for the channel | ## Example Request ```bash curl --location 'http://localhost:9000/channel/update' \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data '{ "id": "66ae13fe4b663c058367f893", "channel": "new_name" }' ``` ## Response ### Success Response (200 OK) ```json { "updateChannelResult.Updated": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `updateChannelResult.Updated` | boolean | Indicates whether the channel was successfully updated | ## Notes - Channel name changes should be done carefully as they may break existing client integrations - The change affects all future uploads and searches using this channel --- ## Fetch Latest Version of App This API endpoint retrieves the latest version of a specific app based on the provided parameters. ## Endpoint ``` GET /apps/latest?app_name=&channel=stable&platform=linux&arch=amd64&owner=admin ``` ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_name` | string | ✅ | The name of the app you want to fetch | | `channel` | string | ✅ | The release channel (e.g., stable, beta) | | `platform` | string | ❌ | The platform (e.g., linux, darwin) | | `arch` | string | ❌ | The architecture (e.g., amd64, arm64) | | `package` | string | ❌ | The package type (e.g., deb, rpm, dmg) | | `owner` | string | ✅ | Name of your admin user | ## Headers | Header | Value | Required | |--------|-------|----------| | `X-Download-Token` | `fnd_…` [download token](../apps/download-tokens-regenerate) of the app and channel | Private apps in `strict` mode | | `Authorization` | `Bearer ` of the owning admin, or of a team user with the `download` permission and access to the app and channel | Alternative to `X-Download-Token` | Public apps and private apps in `unlisted` mode need no credential. For a private app in `strict` mode, a request without a valid credential gets exactly the same answer as an app that does not exist, `500 Internal Server Error`: ```json { "error": "app_name not found in apps_meta collection" } ``` ## Important Behavior ### 1. Channel Parameter - If channels have been pre-defined, the channel parameter becomes mandatory. ### 2. Single URL Redirection - If the response contains only one URL, the API will automatically redirect the request to download the build at that URL. This simplifies the process when only one option is available. ### 3. Installers Only `/apps/latest` returns only files a user can download and install. Files that exist for native updaters are never included, whichever updater uploaded them: | Excluded | Examples | |----------|----------| | Update feeds | Squirrel.Windows `RELEASES`, Velopack `releases.{channel}.json`, electron-builder `latest*.yml`, Sparkle `appcast*.xml` | | Update packages | `.nupkg` (full and delta, Squirrel.Windows and Velopack), Sparkle `.delta`, Tauri update bundles (`.app.tar.gz`, `.AppImage.tar.gz`, `.nsis.zip`, `.msi.zip`) | | Updater helpers | electron-builder `.blockmap`, `.sig` signature files | So for an app using Velopack or Squirrel.Windows, a request narrowed to one platform and architecture resolves to the single installer and redirects to it. Filtering by an excluded type (for example `package=nupkg` or `package=yml`) returns `404`. Native updaters get these files from their feeds via [`/checkVersion`](./latest), not from this endpoint. ### 4. Latest per Platform and Architecture The newest build is picked **separately for every platform and architecture**, from published versions only: - A release uploaded for one platform does not hide the others. If `1.0.3` ships only for Windows, `platform=darwin` keeps returning the newest macOS build, e.g. `1.0.2`. - With `package`, the newest version **that has this package** is returned. If the latest macOS release has only a `.dmg`, `package=zip` still resolves to the newest `.zip`. - A version whose files for a platform are all updater-internal (see [Installers Only](#3-installers-only)) is skipped for that platform. - Versions in a [staged rollout](../../rollout.mdx) (`rollout` below 100) are skipped until they reach 100%, so a download page never hands out a build that in-app updates are still holding back. Every entry carries the `version` it comes from, so a download page can show it next to the link. Without `platform`/`arch`, different platforms may therefore report different versions. ### 5. Flexible Responses Based on Parameters The API responds dynamically based on the parameters you provide. #### Example: Specific Parameters ```bash GET /apps/latest?app_name=secondapp&channel=stable&platform=linux&arch=amd64&owner=admin ``` Returns a list of available builds: ```json { "stable": { "linux": { "amd64": { "deb": { "url": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.deb", "version": "0.0.3" }, "rpm": { "url": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.rpm", "version": "0.0.3" } } } } } ``` #### Example: With Package Specification ```bash GET /apps/latest?app_name=secondapp&channel=stable&platform=linux&arch=amd64&package=deb&owner=admin ``` The API will redirect directly to the .deb package download URL: ``` https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.deb ``` #### Example: Omitting Platform and Arch ```bash GET /apps/latest?app_name=secondapp&channel=stable&owner=admin ``` Returns all available builds under that channel: ```json { "stable": { "linux": { "amd64": { "deb": { "url": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.deb", "version": "0.0.3" }, "rpm": { "url": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.3.rpm", "version": "0.0.3" } } }, "darwin": { "arm64": { "dmg": { "url": "https://.s3.amazonaws.com/secondapp/stable/darwin/arm64/secondapp-0.0.3.dmg", "version": "0.0.3" } }, "amd64": { "pkg": { "url": "https://.s3.amazonaws.com/secondapp/stable/darwin/amd64/secondapp-0.0.2.pkg", "version": "0.0.2" } } } } } ``` ## Notes - The API provides all available builds depending on what is uploaded in the specified channel, platform, and architecture - Use specific parameters to narrow down results and get direct download URLs - The `owner` parameter is required for proper access control - Each entry holds the download `url` and the `version` it belongs to - Package types are automatically detected from the file extension; updater feeds, update packages and helper files are excluded (see [Installers Only](#3-installers-only)) - Responses of private apps are never cached in Redis ## Related reading - [Fetch Latest Version of App — Smart Update Links](https://faynosync.com/blog/fetch-latest-version) — how to use this endpoint for permanent download links. --- ## Health Check Check the health status of the **FaynoSync** service. The Health Check API allows you to verify the operational status of the FaynoSync application. This is useful for monitoring and ensuring that your service is running as expected. ## Endpoint ``` GET /health ``` ## Example Request ```bash curl -X GET http://localhost:9000/health ``` ## Response ### Success Response (200 OK) ```json { "status": "healthy" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `status` | string | Health status of the application (always "healthy" when functioning correctly) | ### Error Response (424 Failed Dependency) Returned when MongoDB or Redis does not answer a ping within 5 seconds. MongoDB is checked first. ```json { "status": "unhealthy", "details": "MongoDB connection failed" } ``` ```json { "status": "unhealthy", "details": "Redis connection failed" } ``` | Field | Type | Description | |-------|------|-------------| | `status` | string | `unhealthy` | | `details` | string | Which dependency failed: `MongoDB connection failed` or `Redis connection failed` | ## Usage You can use this endpoint in your monitoring tools or health checks to ensure that the **FaynoSync** application is running smoothly. The endpoint pings MongoDB and Redis on every request, so a `200` response means the API can reach both. The Docker image's built-in healthcheck (`faynoSync healthcheck`) probes this endpoint. If you encounter issues or receive a different status, further investigation may be necessary to identify and resolve any problems. ## Notes - This endpoint does not require authentication - The response is immediate and lightweight - Use this for load balancer health checks and monitoring systems - A non-200 response indicates the service is not healthy --- ## Status API The Status API allows you to verify the operational state and version of the **FaynoSync** system. These endpoints help monitor the service health and ensure that clients are using the latest available version. --- ## Check Latest Version # Check Latest Version of application Check if there is a newer version of a specific application available. The response format can be customized based on the updater type specified. ## Endpoint ``` GET /checkVersion?app_name=&version=&owner=admin ``` ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_name` | string | ✅ | Name of the app to check | | `version` | string | ✅ | Current version of the app | | `channel` | string | ❌ | Channel (e.g., stable, beta) | | `platform` | string | ❌ | Platform (e.g., linux, windows) | | `arch` | string | ❌ | Architecture (e.g., amd64, arm64) | | `owner` | string | ✅ | Name of your admin user | | `updater` | string | ❌ | Updater type to use for response format (manual, squirrel_darwin, squirrel_windows, electron-builder) | :::info If no `updater` parameter is specified, the API will use the default updater configured for the platform. ::: ## Headers | Header | Value | Required | |--------|-------|----------| | `X-Download-Token` | `fnd_…` [download token](../apps/download-tokens-regenerate) of the app and channel | Private apps in `strict` mode | | `Authorization` | `Bearer ` of the owning admin, or of a team user with the `download` permission and access to the app and channel | Alternative to `X-Download-Token` | | `X-Device-ID` | Device identifier | ❌ | Public apps and private apps in `unlisted` mode need no credential. For a private app in `strict` mode, a request without a valid credential gets exactly the same answer as an app that does not exist, `400 Bad Request`: ```json { "error": "app_name not found in apps_meta collection" } ``` ## Example Requests ### Standard Request (Uses Platform Default Updater) ```bash curl -X GET --location 'http://localhost:9000/checkVersion?app_name=secondapp&version=0.0.1&channel=stable&platform=linux&arch=amd64&owner=admin' ``` ### Request with Specific Updater ```bash curl -X GET --location 'http://localhost:9000/checkVersion?app_name=secondapp&version=0.0.1&channel=stable&platform=linux&arch=amd64&owner=admin&updater=electron-builder' ``` ## Response Formats ### Manual Updater (Default) Standard FaynoSync response format: ```json { "update_available": true, "update_url_deb": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.2.deb", "update_url_rpm": "https://.s3.amazonaws.com/secondapp/stable/linux/amd64/secondapp-0.0.2.rpm", "changelog": "### Changelog\n\n- Added new feature X\n- Fixed bug Y", "critical": true, "is_intermediate_required": true } ``` ### Squirrel Darwin (macOS) Optimized for Squirrel macOS updates: ```json { "update_available": true, "url": "https://.s3.amazonaws.com/secondapp/stable/darwin/amd64/secondapp-0.0.2.zip", "changelog": "### Changelog\n\n- Added new feature X\n- Fixed bug Y", "critical": true, "is_intermediate_required": true } ``` ### Electron Builder Returns YAML content for electron-builder: ```yaml version: 0.0.2 files: - url: secondapp-0.0.2.exe sha512: size: path: secondapp-0.0.2.exe sha512: releaseDate: '2024-01-15T10:00:00.000Z' ``` ## Response Fields ### Standard Response Fields | Field | Type | Description | |-------|------|-------------| | `update_available` | boolean | Indicates whether a newer version is available | | `update_url_` | string | Direct download URL for each artifact, keyed by file extension (`update_url_deb`, `update_url_rpm`, `update_url_dmg`, ...) | | `update_url` | string | Direct download URL for an artifact uploaded without a file extension | | `url` | string | Direct download URL (Squirrel Darwin format) | | `changelog` | string | Changelog content for the latest version (in markdown format) | | `critical` | boolean | Indicates if this is a critical update that should be applied immediately | | `is_intermediate_required` | boolean | Indicates if an intermediate version must be installed before latest version | ### Updater-Specific Differences | Updater Type | Key Differences | |--------------|----------------| | `manual` | Standard response with `update_url_zip`, `update_url_deb`, `update_url_rpm` | | `squirrel_darwin` | Uses `url` field instead of `update_url_zip`, only ZIP archives | | `squirrel_windows` | Standard format but optimized for Windows Squirrel | | `electron-builder` | Returns YAML content instead of JSON | ## Notes - **Default Updater**: If no `updater` parameter is specified, the platform's default updater is used - **Response Format**: The response format varies based on the updater type to ensure compatibility - **Platform Configuration**: Updaters must be configured for the platform before they can be used - **Version Comparison**: Version comparison is done automatically by the API - **Critical Updates**: Critical updates should be prioritized and applied as soon as possible - **Intermediate Versions**: Intermediate versions may be required for major version jumps or breaking changes - **Download URLs**: URLs are provided in the format expected by each updater type - **Private Apps**: Download URLs of a private app point to [`/download`](../apps/download), which applies the same `download_mode` check. Responses of private apps are never cached in Redis or published to the CDN --- ## Squirrel Windows Update # Squirrel Windows Update Endpoint Special endpoint for Squirrel Windows applications that uses URL-based routing instead of query parameters. ## Overview Squirrel Windows has a specific update mechanism that expects URLs in a particular format. Instead of using query parameters like the standard `checkVersion` endpoint, Squirrel Windows uses URL path segments. ## Endpoint ``` GET /update/{owner}/{app_name}/{channel}/{platform}/{arch}/{version}/ ``` ## URL Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `owner` | string | ✅ | Name of your admin user | | `app_name` | string | ✅ | Name of the application | | `channel` | string | ✅ | Channel (e.g., stable, beta) | | `platform` | string | ✅ | Platform (e.g., windows) | | `arch` | string | ✅ | Architecture (e.g., amd64) | | `version` | string | ✅ | Current version of the application | ## Example Request ```bash curl --location 'http://localhost:9000/update/admin/secondapp/stable/windows/amd64/0.0.0.1/RELEASES' ``` ## Response ### Success Response (200 OK) The response will be in the format expected by Squirrel Windows, typically containing update information and download URLs. ## How Squirrel Windows Works 1. **URL Construction**: Squirrel Windows constructs the update URL using the pattern: `{base_url}/update/{owner}/{app_name}/{channel}/{platform}/{arch}/{version}/` 2. **RELEASES File**: Squirrel Windows automatically appends `RELEASES` to the end of the URL and checks for updates 3. **Final URL**: The complete URL becomes: `{base_url}/update/{owner}/{app_name}/{channel}/{platform}/{arch}/{version}/RELEASES` ## Configuration Requirements To use this endpoint, ensure that: 1. **Platform Configuration**: The platform must have `squirrel_windows` updater configured 2. **Default Updater**: You can set `squirrel_windows` as the default updater for the platform 3. **Application Setup**: Your Squirrel Windows application must be configured to use this URL pattern ## Example Platform Configuration ```json { "id": "689f076f9ed9e38071926986", "platform": "windows", "updaters": [ { "type": "manual", "default": false }, { "type": "squirrel_windows", "default": true } ] } ``` ## Comparison with Standard Endpoint | Aspect | Standard checkVersion | Squirrel Windows | |--------|----------------------|------------------| | **URL Format** | Query parameters | Path segments | ## Notes - **URL Pattern**: The URL pattern is fixed and must match exactly what Squirrel Windows expects - **Automatic RELEASES**: Squirrel Windows automatically appends `RELEASES` to the URL - **Platform Specific**: This endpoint is specifically designed for Windows platforms using Squirrel - **Configuration**: Ensure the platform has the `squirrel_windows` updater configured - **Compatibility**: This endpoint is compatible with Squirrel Windows update mechanism out of the box --- ## Telemetry beacon Records client update telemetry for statistics collection. This endpoint stores telemetry in the same way as `/checkVersion`, but it is optimized for speed by keeping a snapshot of parameters in RAM. On metadata changes, the API rebuilds a compact allow-list index (owner, app, channel, platform, architecture names) and serves beacon validation from that in-memory snapshot instead of reading full `apps_meta` documents per request. ## Performance (development benchmarks) Measured on a **local Mac** (not production). Absolute numbers will differ on servers, but the comparison shows why this endpoint exists for edge-heavy traffic. | Metric | Previous `/checkVersion`-style path | `/telemetry/beacon` | |--------|-------------------------------------|---------------------| | Avg time until persistence step | ~20.8 ms | ~0.15 ms | | `wrk` throughput (4 threads, 100 conn, 30s) | ~359 req/s | ~1,192 req/s | | `wrk` avg latency | ~287 ms | ~88 ms | For architecture context (edge mode, allow-list lifecycle, and caveats), see [Edge and S3 Response Cache](/docs/edge). ## Endpoint ``` GET /telemetry/beacon?app_name=&version=&channel=&platform=&arch=&owner= ``` ## Authentication No auth token is required. ## Required Header | Header | Type | Required | Description | |--------|------|----------|-------------| | `X-Device-ID` | string | ✅ | Unique client device identifier used for telemetry deduplication and tracking | ## Query Parameters All query parameters are required. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `app_name` | string | ✅ | Application name | | `version` | string | ✅ | Current client version | | `channel` | string | ✅ | Release channel (for example, `stable` or `beta`) | | `platform` | string | ✅ | Client platform (for example, `linux`, `windows`, `darwin`) | | `arch` | string | ✅ | Client architecture (for example, `amd64`, `arm64`) | | `owner` | string | ✅ | Application owner username | ## Example Request ```bash curl --location 'http://localhost:9000/telemetry/beacon?app_name=secondapp&version=0.0.1&channel=stable&platform=linux&arch=amd64&owner=admin' \ --header 'X-Device-ID: ncjskbfhbhjs473tdah' ``` ## Response ### Success Response (204 No Content) This endpoint always returns `204 No Content` on success and does not include a response body. ## Usage Notes - This endpoint is intended for telemetry collection when `/checkVersion` is not used. - If your client already calls `/checkVersion`, you do not need to call `/telemetry/beacon`. - This endpoint is designed for `faynosync-sdk` flows where updates are received from edge infrastructure and `/checkVersion` is skipped. - Missing `X-Device-ID` or missing query parameters should be treated as invalid requests. - Reported `version` is stored for statistics but is not required to match the in-memory allow-list (clients may report versions no longer present in the admin UI). ## Related reading - [Telemetry Insights — Understand Your Users Better](https://faynosync.com/blog/telemetry-insights) --- ## Velopack Feed # Velopack Feed Endpoint Dynamic feed endpoint for [Velopack](../../updaters/velopack.mdx) applications. It returns the `releases.{channel}.json` document the stock Velopack client expects, built from the database on every request so it can honor per-client logic — most importantly [required intermediate builds](../../intermediate_build.mdx). ## Overview FaynoSync serves Velopack feeds two ways: - **Static (CDN, default)** — FaynoSync materializes `releases.{channel}.json` to object storage and the client reads it directly. No API involvement. See [Velopack updater](../../updaters/velopack.mdx#mode-1--cdn-on-by-default). - **Dynamic (this endpoint)** — the client points its `UPDATE_URL` at the API route. FaynoSync builds the feed per request, which lets it cap the feed at a required intermediate version for the reporting client. Object storage can't do this because it ignores query parameters. The channel is parsed from the requested file name (`releases.{channel}.json`), so it is not a separate path parameter. ## Endpoint ``` GET /velopack/{owner}/{app}/{platform}/{arch}/releases.{channel}.json ``` The same route also resolves package downloads: a request whose final segment ends in `.nupkg` is answered with a `302` redirect to the artifact's real storage link. ## URL Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `owner` | string | ✅ | Name of your admin user | | `app` | string | ✅ | Name of the application | | `platform` | string | ✅ | Platform (e.g., `darwin`, `windows`, `linux`) | | `arch` | string | ✅ | Architecture (e.g., `arm64`, `amd64`) | | `channel` | string | ✅ | Parsed from the file name `releases.{channel}.json` (e.g., `nightly`) | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `localVersion` | string | ❌ | The client's current version. When set and a required intermediate build exists between it and the latest, the feed is capped so the highest `Full` is that intermediate version. | | `id` | string | ❌ | Velopack package id. If present it must equal `app`, otherwise the request is rejected. | ## Example Request ```bash curl --location 'http://localhost:9000/velopack/admin/HelloVelopack/darwin/arm64/releases.nightly.json?localVersion=1.0.0&id=HelloVelopack' ``` ## Response ### Success Response (200 OK) `Content-Type: application/json`. The body is the Velopack feed in PascalCase — the exact shape the stock client parses: ```json { "Assets": [ { "PackageId": "HelloVelopack", "Version": "1.0.1", "Type": "Full", "FileName": "HelloVelopack-1.0.1-nightly-full.nupkg", "SHA1": "…", "SHA256": "…", "Size": 13355895, "NotesMarkdown": "- fix crash on startup", "NotesHTML": "fix crash on startup" }, { "PackageId": "HelloVelopack", "Version": "1.0.1", "Type": "Delta", "FileName": "HelloVelopack-1.0.1-nightly-delta.nupkg", "SHA1": "…", "SHA256": "…", "Size": 189039 }, { "PackageId": "HelloVelopack", "Version": "1.0.0", "Type": "Full", "FileName": "HelloVelopack-1.0.0-nightly-full.nupkg", "SHA1": "…", "SHA256": "…", "Size": 13355733 } ] } ``` In the example above `1.0.2` is published but `1.0.1` is marked intermediate, so a client on `localVersion=1.0.0` is stepped to `1.0.1` first — the feed is capped there. On the next check (now on `1.0.1`) it proceeds to the latest. ### Empty feed When there are no published velopack releases for the tuple, or the feed can't be built, the endpoint returns `200 OK` with an empty document rather than an error: ```json { "Assets": [] } ``` ### Package redirect (302) A `.nupkg` request on this route redirects to the package's public storage link: ``` GET /velopack/admin/HelloVelopack/darwin/arm64/HelloVelopack-1.0.1-nightly-full.nupkg → 302 Found (Location: ) ``` ## Configuration Requirements 1. **Updater**: the platform must have the `velopack` updater configured. 2. **Channel, platform, arch**: Velopack releases require all three — each `(channel, platform, arch)` tuple has its own feed. 3. **Client `UPDATE_URL`**: point it at `{api_base}/velopack/{owner}/{app}/{platform}/{arch}`; the stock client appends `/releases.{channel}.json`. ## Comparison with the static feed | Aspect | Static feed (CDN) | This endpoint (API) | |--------|-------------------|---------------------| | Who serves it | Object storage / CDN | FaynoSync, per request | | API load per check | ~none | every check | | Required intermediate builds | ❌ | ✅ (`localVersion`) | | Enabled by | Always on for velopack apps | Point `UPDATE_URL` at the API | ## Notes - **Channel from file name** — the channel is not a path parameter; it is read from `releases.{channel}.json`. - **Feed format** — PascalCase keys; `Full` and `Delta` asset types; hashes are taken verbatim from the uploaded `releases.{channel}.json`. - **Related** — [Velopack updater](../../updaters/velopack.mdx), [Required Intermediate Build](../../intermediate_build.mdx), [Velopack example app](../../examples/velopack.mdx). --- ## WhoamI # Check current user information Retrieve information about the currently authenticated user, including permissions and role. ## Endpoint ``` GET /whoami ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/whoami \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) #### Admin User Response ```json { "id": "67ffc3f5a2120e73468ce66c", "username": "admin", "is_admin": true } ``` #### Team User Response ```json { "id": "6800be205e5d00c1c14fe244", "username": "teamuser1", "is_admin": false, "owner": "admin", "permissions": { "Apps": { "Create": true, "Delete": false, "Edit": false, "Download": true, "Upload": true, "Allowed": [ "67fe34c804d701fb7f3cc656", "67fe3669cebd0550e07763de" ] }, "Channels": { "Create": true, "Delete": false, "Edit": false, "Allowed": [ "67f826fa5c0a2b68411b2111" ] }, "Platforms": { "Create": false, "Delete": false, "Edit": true, "Allowed": [ "67f82615a27c60636bd57308" ] }, "Archs": { "Create": true, "Delete": true, "Edit": false, "Allowed": [ "67f8261aa27c60636bd57309", "67f827ac8ffe38b6de325c07" ] } } } ``` ### Response Fields #### Common Fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Unique identifier of the user | | `username` | string | Username of the authenticated user | | `is_admin` | boolean | Whether the user has administrative privileges | #### Team User Additional Fields | Field | Type | Description | |-------|------|-------------| | `owner` | string | Username of the admin who created this team user | | `permissions` | object | Detailed permissions for different resource types | #### Permission Object Fields | Field | Type | Description | |-------|------|-------------| | `Create` | boolean | Permission to create new resources | | `Delete` | boolean | Permission to delete resources | | `Edit` | boolean | Permission to edit existing resources | | `Download` | boolean | Permission to download files | | `Upload` | boolean | Permission to upload files | | `Allowed` | array | List of resource IDs the user has access to | ## Notes - Admin users have full access to all resources and operations - Team users have restricted access based on their assigned permissions - The `Allowed` array contains IDs of specific resources the user can access - Use this endpoint to determine user capabilities and access rights - Permissions are granular and can be customized per resource type --- ## Create Platform Create a new deployment platform for organizing application releases with configurable updaters. :::warning After creating the first platform, the `platform` field becomes required for all subsequent operations. ::: ## Endpoint ``` POST /platform/create ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `application/json` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `platform` | string | ✅ | Name of the platform (e.g., linux, darwin, windows) | | `updaters` | array | ❌ | Array of updater configurations (see Updater Configuration below) | ### Updater Configuration Each updater in the array should have the following structure: | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | ✅ | Updater type: `manual`, `velopack`, `sparkle`, `squirrel_darwin`, `squirrel_windows`, `electron-builder`, `tauri` | | `default` | boolean | ❌ | Whether this updater is the default (only one can be default) | ### Supported Updater Types | Type | Description | |------|-------------| | `manual` | Standard FaynoSync update flow (default if none specified) | | `velopack` | Velopack cross-platform installer/updater | | `sparkle` | Sparkle macOS update framework | | `squirrel_darwin` | Squirrel macOS update mechanism | | `squirrel_windows` | Squirrel Windows update mechanism | | `electron-builder` | Electron Builder update mechanism | | `tauri` | Tauri built-in updater | ## Example Requests ### Basic Platform Creation (Manual Updater Default) ```bash curl --location 'http://localhost:9000/platform/create' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "platform": "linux" }' ``` ### Platform with Multiple Updaters ```bash curl --location 'http://localhost:9000/platform/create' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "platform": "windows", "updaters": [ {"type": "manual", "default": true}, {"type": "squirrel_windows", "default": false}, {"type": "electron-builder"} ] }' ``` ### Platform with Electron Builder as Default ```bash curl --location 'http://localhost:9000/platform/create' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "platform": "darwin", "updaters": [ {"type": "manual"}, {"type": "squirrel_darwin", "default": true} ] }' ``` ## Response ### Success Response (200 OK) ```json { "createPlatformResult.Created": "641459ffb8360d74164e7e3c" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `createPlatformResult.Created` | string | The unique identifier (ID) of the created platform | ## Notes - **Default Updater**: If no updaters are specified, `manual` will be automatically set as the default - **Single Default**: Only one updater can be set as default per platform - **Updater Compatibility**: Choose updaters that match your application's update mechanism - **Platform Organization**: Platforms help organize different operating system targets and update strategies --- ## Delete Specific Platform Delete a specific platform by its unique identifier. ## Endpoint ``` DELETE /platform/delete?id= ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string | ✅ | The unique identifier of the platform to delete | ## Example Request ```bash curl -X DELETE 'http://localhost:9000/platform/delete?id=64145ebaedd163d59d52e1dc' \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "deletePlatformResult.DeletedCount": 1 } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `deletePlatformResult.DeletedCount` | number | Number of platforms successfully deleted (should be 1 if successful) | ## Notes - This operation permanently deletes the platform - The deletion is irreversible - deleted platforms cannot be recovered - Ensure no applications are currently using this platform before deletion --- ## Get All Platforms Retrieve a list of all available platforms in the system. ## Endpoint ``` GET /platform/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/platform/list \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "platforms": [ { "ID": "653a52481ff613bed613df58", "PlatformName": "darwin", "Updated_at": "2023-10-26T14:49:28.894+03:00" }, { "ID": "653a52401ff613bed613df57", "PlatformName": "linux", "Updated_at": "2023-10-26T14:49:20.976+03:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `platforms` | array | Array of platform objects | #### Platform Object Fields | Field | Type | Description | |-------|------|-------------| | `ID` | string | Unique identifier of the platform | | `PlatformName` | string | Name of the platform (e.g., linux, darwin) | | `Updated_at` | string | Last update timestamp | ## Notes - This endpoint returns all platforms available for application deployment - The `PlatformName` field contains the platform identifier used in uploads and searches - Results are typically sorted by creation date (newest first) - Empty results array is returned if no platforms are configured --- ## Platform Management Work with platforms for different types of applications. These endpoints allow you to manage deployment platforms within the **FaynoSync** API. Platforms help organize and manage different deployment environments such as linux, darwin, or custom platforms. --- ## Update Platform Update an existing platform's name and updater configuration. :::warning Changing the platform name might prevent existing apps from checking for new versions, as the platform name will have changed. ::: ## Endpoint ``` POST /platform/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Request Body The request uses `application/json` format with the following fields: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | The ID of the platform you want to update | | `platform` | string | ✅ | New platform name | | `updaters` | array | ❌ | Array of updater configurations (see Updater Configuration below) | ### Updater Configuration Each updater in the array should have the following structure: | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | ✅ | Updater type: `manual`, `velopack`, `sparkle`, `squirrel_darwin`, `squirrel_windows`, `electron-builder`, `tauri` | | `default` | boolean | ❌ | Whether this updater is the default (only one can be default) | ### Supported Updater Types | Type | Description | |------|-------------| | `manual` | Standard FaynoSync update flow | | `velopack` | Velopack cross-platform installer/updater | | `sparkle` | Sparkle macOS update framework | | `squirrel_darwin` | Squirrel macOS update mechanism | | `squirrel_windows` | Squirrel Windows update mechanism | | `electron-builder` | Electron Builder update mechanism | | `tauri` | Tauri built-in updater | ## Example Requests ### Update Platform Name Only ```bash curl --location 'http://localhost:9000/platform/update' \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data '{ "id": "66ae13fe5b663c058367f893", "platform": "new_name" }' ``` ### Update Platform with Updaters Configuration ```bash curl --location 'http://localhost:9000/platform/update' \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data '{ "id": "66ae13fe5b663c058367f893", "platform": "updated_platform", "updaters": [ {"type": "manual", "default": true}, {"type": "squirrel_windows", "default": false}, {"type": "electron-builder"} ] }' ``` ### Update Updaters Only (Keep Platform Name) ```bash curl --location 'http://localhost:9000/platform/update' \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ --data '{ "id": "66ae13fe5b663c058367f893", "platform": "existing_name", "updaters": [ {"type": "squirrel_darwin", "default": true}, {"type": "manual"} ] }' ``` ## Response ### Success Response (200 OK) ```json { "updatePlatformResult.Updated": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `updatePlatformResult.Updated` | boolean | Indicates whether the platform was successfully updated | ## Notes - **Platform Name Changes**: Platform name changes should be done carefully as they may break existing client integrations - **Updater Configuration**: You can update updaters without changing the platform name - **Default Updater**: Only one updater can be set as default per platform - **Impact**: Changes affect all future uploads and searches using this platform - **Backward Compatibility**: Existing applications will continue to work with the default updater unless changed --- ## Delete Report Group Hard-deletes a report group together with its blob metadata and the underlying storage objects. Object deletion is best-effort — if the storage call fails, the group and its metadata are still removed, and the orphaned objects fall back to the storage lifecycle / TTL for cleanup. This is an authenticated (JWT) route, active only when `REPORTS_ENABLED=true`. ## Endpoint ``` DELETE /reports/groups/:groupHash ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | Gated by `CheckPermission(delete, apps)`: - **Admins** can delete every group under their account. - **Team users** must have the `apps.delete` permission and can only delete groups whose app is in their `allowed_apps`. The delete is scoped by accessible `app_id`, so a group belonging to another owner (even with an identical hash) is never matched. ## Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `groupHash` | string | ✅ | The group's SHA-256 hash; must be exactly 64 hex characters (otherwise `400`) | ## What gets deleted 1. All `report_blobs` metadata documents for the group. 2. The group's `report_groups` document. 3. The private-bucket storage objects referenced by those blobs (best-effort; failures fall back to lifecycle/TTL). ## Example Request ```bash curl -s -X DELETE 'http://localhost:9000/reports/groups/9c460a0d02a7a80a1456b49d88033100cad27266848feb2a1cdd7480d50efbcc' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) ```json { "group_hash": "9c460a0d02a7a80a1456b49d88033100cad27266848feb2a1cdd7480d50efbcc", "deleted": true } ``` ### Error Responses | Status | Condition | |--------|-----------| | `400` | Invalid group hash | | `401` | Missing or invalid JWT | | `404` | No matching group in an accessible app | | `500` | Internal error while deleting the group or blob metadata | ## Notes - Deletion is permanent — there is no undo. To suppress a noisy group without losing its history, set its status to `muted` via [Update Report Group](/docs/api/reports/update-group) instead. - A failure to delete storage objects is logged but does not fail the request; the blobs expire via the configured lifecycle / TTL. --- ## Ingest Report Accepts a single operational failure report from a client, validates it, builds a deterministic `groupHash`, and increments the matching aggregation group with one MongoDB upsert. Optional debug `details` are stored as a size-bounded blob in the private bucket. This is a **public** route (no JWT). It is registered before the global JWT middleware and is authenticated by the report key. It is active only when `REPORTS_ENABLED=true`. ## Endpoint ``` POST /reports/ingest ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer rpk_<64_hex>` | The server looks up the report key by value, resolves its `app_id`, and verifies that `application.name` in the body matches the key's app and that the app's `reports` flag is enabled. ## Required Headers | Header | Type | Required | Description | |--------|------|----------|-------------| | `Authorization` | string | ✅ | `Bearer` followed by the app's report key (`rpk_` + 64 hex) | | `X-Device-ID` | string | ✅ | Anonymous technical device identifier, used for rate limits and dedup only. Never stored in report documents or included in `groupHash` | | `Content-Type` | string | ✅ | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `application.name` | string | ✅ | Application name; must match the report key's app | | `application.version` | string | ✅ | Client version (validated for syntax, not required to exist in release metadata) | | `application.channel` | string | ✅ | Release channel (e.g. `stable`, `beta`) | | `system.platform` | string | ✅ | Platform (e.g. `windows`, `linux`, `darwin`) | | `system.arch` | string | ✅ | Architecture (e.g. `amd64`, `arm64`) | | `event.type` | string | ✅ | One of the allowed event types (see below) | | `event.reason` | string | ✅ | Short machine-readable identifier, regex `^[a-zA-Z0-9._-]{1,128}$` | | `details` | object | ❌ | Optional compressed debug payload (see [Details](#details)) | ### Event types `event.type` is a strict enum. Clients cannot send arbitrary types; new types require explicit server support. | Value | Meaning | |-------|---------| | `crash` | Application crashed at runtime | | `startup_failure` | Application failed to start | | `update_failure` | An update failed to apply | | `install_failure` | An install failed | | `rollback_failure` | A rollback failed | ### Reason `event.reason` is a short identifier used for grouping, filtering, and alerting — **not** a human-readable message. It must match `^[a-zA-Z0-9._-]{1,128}$` and must not contain stack traces, HTML, logs, or binary data. Examples: `checksum_mismatch`, `disk_full`, `access_denied`, `missing_dependency`, `panic_nil_pointer`, `signature_verification_failed`. ### Details `details` is optional. When present, the server checks the compressed size, base64-decodes it, decompresses the gzip stream under a hard decompressed-size limit (enforced with `io.LimitReader`; the client-declared size is never trusted), stores the **compressed** bytes in the private bucket, and writes metadata to `report_blobs`. Details are excluded from `groupHash`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `details.encoding` | string | ✅ | Must be `gzip+base64` | | `details.content_type` | string | ✅ | Must be `application/json` | | `details.payload` | string | ✅ | The JSON debug object, gzip-compressed then base64-encoded | ## Example Request ### Minimal report ```bash curl -i -X POST 'http://localhost:9000/reports/ingest' \ --header 'Authorization: Bearer rpk_87077831a3c0c3f5a3cca1b1a5441e36033550708e92b832166d8550ba847315' \ --header 'X-Device-ID: 7f3c9a2e-1b4d-4c8a-9f12-abc123def456' \ --header 'Content-Type: application/json' \ --data '{ "application": { "name": "test", "version": "1.4.2", "channel": "stable" }, "system": { "platform": "windows", "arch": "amd64" }, "event": { "type": "update_failure", "reason": "checksum_mismatch" } }' ``` ### Report with details ```bash # build a payload: gzip then base64 PAYLOAD=$(printf '{"message":"sha mismatch","stack":"..."}' | gzip | base64 -w0) curl -i -X POST 'http://localhost:9000/reports/ingest' \ --header 'Authorization: Bearer rpk_8707...7315' \ --header 'X-Device-ID: 7f3c9a2e-1b4d-4c8a-9f12-abc123def456' \ --header 'Content-Type: application/json' \ --data "{ \"application\": { \"name\": \"test\", \"version\": \"1.4.2\", \"channel\": \"stable\" }, \"system\": { \"platform\": \"windows\", \"arch\": \"amd64\" }, \"event\": { \"type\": \"crash\", \"reason\": \"panic_nil_pointer\" }, \"details\": { \"encoding\": \"gzip+base64\", \"content_type\": \"application/json\", \"payload\": \"$PAYLOAD\" } }" ``` ## Response ### Success Response (202 Accepted) ```json { "status": "accepted", "group_hash": "9f2b...", "stored_details": false } ``` When details were stored: ```json { "status": "accepted", "group_hash": "1ab3...", "stored_details": true } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `status` | string | Always `accepted` on success | | `group_hash` | string | The deterministic SHA-256 group hash the report was counted into | | `stored_details` | boolean | `true` only when the blob was written to the private bucket and `report_blobs` metadata inserted | `stored_details` is `false` if no details were sent, or if a storage outage prevented the write. On a storage outage the response is still `202`, the base count is preserved, and `stats.detailsRejected` is incremented. ## Status Codes | Code | When | |------|------| | `202 Accepted` | Report counted (with or without details) | | `400 Bad Request` | Missing `X-Device-ID`, malformed JSON, missing/invalid field, bad `event.type` / `event.reason`, or unsupported details encoding/content type | | `401 Unauthorized` | Missing/non-Bearer `Authorization`, or unknown report key | | `403 Forbidden` | Reports disabled for the app, or `application.name` does not match the key's app | | `413 Payload Too Large` | Body over `REPORTS_MAX_BODY_BYTES`, or compressed/decompressed details over their limits | | `429 Too Many Requests` | A rate limit was hit | | `500 Internal Server Error` | Unexpected Mongo error on the base upsert | > `403 reports disabled` is largely theoretical: disabling reports deletes the app's report key, so requests fail at `401` first. The `reports` flag is still re-checked on every request as defence in depth. ### Error examples ```json // 401 — unknown / missing key { "error": "invalid report key" } // 403 — application.name does not match the key's app { "error": "report key does not belong to this application" } // 400 — event.type not in the enum { "error": "invalid event.type" } // 413 — decompressed details exceed REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES { "error": "decompressed details too large" } // 429 — repeat of the same report from the same device within the hour { "error": "rate limit exceeded" } ``` ## Rate Limits Because ingestion is public, rate limits are mandatory. They are evaluated **after** `groupHash` is built and **before** the Mongo upsert, using fixed-window counters in Redis: | Dimension | Limit | |-----------|-------| | Per report key | `REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE` requests/minute (default `100`) | | Per `X-Device-ID` + `groupHash` | 1 request/hour | | Per `groupHash` | 30 requests/minute | The per-device limit is scoped by `groupHash`, so a single device can still emit different event types within the hour — only repeats of the *same* failure are suppressed. On a Redis error the limiter **fails open** (allows the request) and logs, since rate limiting is abuse control, not a trust boundary. ## Notes - A minimal report (no details) costs exactly one Mongo upsert; the base count is always incremented even when a blob fails to store. - The server validates `application.name`, `application.channel`, `system.platform`, `system.arch`, and `application.version` with the same validators used by telemetry ingestion. Only `event.reason` has its own dedicated regex. - The report key is never copied into `report_groups` or `report_blobs`; it already lives in `report_keys`. - Report ingestion never mutates rollout state and never feeds update-metadata trust decisions. --- ## List Group Detail Blobs Returns metadata for the stored debug detail blobs of a single report group, plus a short-lived (15 minute) presigned download URL per blob, generated against the private bucket. Only the latest N blobs per group are retained (configurable via `REPORTS_MAX_BLOBS_PER_GROUP`); older blobs and their storage objects are removed when newer ones arrive, and each blob document also expires via TTL after `REPORTS_BLOB_RETENTION_DAYS`. This is an authenticated (JWT) route, active only when `REPORTS_ENABLED=true`. ## Endpoint ``` GET /reports/groups/:groupHash/blobs ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | Gated by `CheckPermission(download, apps)`. Admins see every app under their account; team users only see apps in their `allowed_apps`. Queries are scoped by accessible `app_id`, so blobs from another owner's identically-hashed group are never returned. ## Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `groupHash` | string | ✅ | The group's SHA-256 hash; must be exactly 64 hex characters (otherwise `400`) | ## Example Request ```bash curl -s 'http://localhost:9000/reports/groups/1ab3.../blobs' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) ```json { "items": [ { "id": "667...", "group_hash": "1ab3...", "app_id": "663...", "application": { "name": "test", "version": "1.4.2", "channel": "stable" }, "system": { "platform": "windows", "arch": "amd64" }, "event": { "type": "crash", "reason": "panic_nil_pointer" }, "storage": { "driver": "aws", "key": "reports/test/2026/06/08/1ab3...-9c1f.json.gz", "compressed_size": 142, "decompressed_size": 38, "content_type": "application/json", "encoding": "gzip" }, "created_at": "2026-06-08T12:00:00Z", "expires_at": "2026-07-08T12:00:00Z", "url": "https://...presigned..." } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `items` | array | Array of blob metadata objects | #### Blob Object | Field | Type | Description | |-------|------|-------------| | `id` | string | Mongo document id | | `group_hash` | string | Group hash this blob belongs to | | `app_id` | string | Owning application id | | `application.name` | string | Application name | | `application.version` | string | Version | | `application.channel` | string | Channel | | `system.platform` | string | Platform | | `system.arch` | string | Architecture | | `event.type` | string | Event type | | `event.reason` | string | Reason identifier | | `storage.driver` | string | Storage driver (e.g. `aws`) | | `storage.key` | string | Object key in the private bucket | | `storage.compressed_size` | number | Stored (compressed) size in bytes | | `storage.decompressed_size` | number | Decompressed size in bytes | | `storage.content_type` | string | Original payload content type (`application/json`) | | `storage.encoding` | string | Compression encoding (`gzip`) | | `created_at` | string | Blob creation timestamp | | `expires_at` | string | TTL expiry (`created_at` + `REPORTS_BLOB_RETENTION_DAYS`) | | `url` | string | Short-lived (15 min) presigned download URL | `storage.bucket` and `owner` are never serialized. ## Notes - The presigned `url` is valid for ~15 minutes; re-call the endpoint to obtain fresh URLs. - The stored object is the **compressed** payload; decompress it client-side to read the original JSON debug object. - Blobs live in the **private** bucket (`S3_BUCKET_NAME_PRIVATE`) and are only reachable through these presigned URLs. --- ## List Report Groups Returns a paginated list of aggregated report groups. Each group is one combination of stable dimensions (app, version, channel, platform, arch, event type, reason) with running statistics. Results are sorted by `stats.lastSeen` descending. This is an authenticated (JWT) route, active only when `REPORTS_ENABLED=true`. ## Endpoint ``` GET /reports/groups ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | Gated by `CheckPermission(download, apps)`: - **Admins** see every app under their account. - **Team users** must have the `apps.download` permission and only see apps present in their `allowed_apps`. ## Query Parameters All parameters are optional. `from`/`to` are RFC3339 and filter on `stats.lastSeen`. | Parameter | Type | Description | |-----------|------|-------------| | `app` | string | Filter by application name | | `version` | string | Filter by version | | `channel` | string | Filter by channel | | `platform` | string | Filter by platform | | `arch` | string | Filter by architecture | | `type` | string | Filter by event type (e.g. `update_failure`) | | `reason` | string | Filter by reason (e.g. `checksum_mismatch`) | | `status` | string | Filter by lifecycle status: `open`, `resolved`, or `muted`. `status=open` also matches pre-existing groups that have no status field; `status=all` disables the filter | | `from` | string | RFC3339 lower bound on `stats.lastSeen` | | `to` | string | RFC3339 upper bound on `stats.lastSeen` | | `page` | number | Page number (default `1`) | | `limit` | number | Page size (default `20`) | ## Example Request ```bash curl -s 'http://localhost:9000/reports/groups?app=test&limit=20' \ --header 'Authorization: Bearer ' ``` ### With filters ```bash curl -s 'http://localhost:9000/reports/groups?app=test&version=1.4.2&channel=stable&platform=windows&arch=amd64&type=update_failure&reason=checksum_mismatch&from=2026-05-01T00:00:00Z&to=2026-06-01T00:00:00Z&page=1&limit=20' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) ```json { "items": [ { "id": "665...", "group_hash": "9f2b...", "app_id": "663...", "application": { "name": "test", "version": "1.4.2", "channel": "stable" }, "system": { "platform": "windows", "arch": "amd64" }, "event": { "type": "update_failure", "reason": "checksum_mismatch" }, "stats": { "count": 182, "first_seen": "2026-05-20T10:00:00Z", "last_seen": "2026-05-20T12:00:00Z", "details_stored": 17, "details_rejected": 3 }, "status": "resolved", "tags": ["ui", "needs-repro"], "note": "tracked in JIRA-123", "resolved_at": "2026-05-20T13:00:00Z", "resolved_by": "admin", "created_at": "2026-05-20T10:00:00Z", "updated_at": "2026-05-20T13:00:00Z" } ], "total": 1, "page": 1, "limit": 20 } ``` `owner` and `storage.bucket` are never serialized. ### Response Fields | Field | Type | Description | |-------|------|-------------| | `items` | array | Array of report group objects | | `total` | number | Total groups matching the filters | | `page` | number | Current page | | `limit` | number | Page size | #### Report Group Object | Field | Type | Description | |-------|------|-------------| | `id` | string | Mongo document id | | `group_hash` | string | Deterministic SHA-256 group hash | | `app_id` | string | Owning application id (partition key) | | `application.name` | string | Application name | | `application.version` | string | Version | | `application.channel` | string | Channel | | `system.platform` | string | Platform | | `system.arch` | string | Architecture | | `event.type` | string | Event type | | `event.reason` | string | Reason identifier | | `stats.count` | number | Total reports counted into this group | | `stats.first_seen` | string | First time this group was seen | | `stats.last_seen` | string | Most recent time this group was seen | | `stats.details_stored` | number | Detail blobs successfully stored | | `stats.details_rejected` | number | Detail payloads that failed to store | | `status` | string | Lifecycle status: `open`, `resolved`, or `muted` (new groups are created `open`) | | `tags` | array | Free-form triage tags (omitted when empty) | | `note` | string | Free-form triage note (omitted when empty) | | `resolved_at` | string | When the group was resolved (present only while `resolved`) | | `resolved_by` | string | Username that resolved the group (present only while `resolved`) | | `created_at` | string | Group creation timestamp | | `updated_at` | string | Last update timestamp | ## Notes - Results are always sorted by `stats.last_seen` descending. - The grouping identity is the composite `(app_id, group_hash)`; `group_hash` alone is not globally unique across owners. - To inspect debug payloads for a group, use [List Group Detail Blobs](/docs/api/reports/list-detail-blobs). - To change a group's status, tags, or note, use [Update Report Group](/docs/api/reports/update-group); to remove it entirely, use [Delete Report Group](/docs/api/reports/delete-group). --- ## Reports Management The Reports Management endpoints let clients send operational failure reports (crashes, failed updates, failed installs) and let admins and team users view an aggregated, privacy-conscious picture of rollout health by app, version, channel, platform, and architecture. The design goal is to keep ingestion **cheap, stable, and bounded**: a minimal report costs a single MongoDB upsert, grouping never depends on stack traces or logs, and storage growth is capped. ## What reports are (and are not) Reports give you: - A simple public endpoint for clients to send update/install/runtime failure events. - An aggregated view of rollout health grouped by stable technical dimensions. - Optional, size-bounded debug **details** stored separately for support investigations. - Groundwork for a future auto-pause / rollback decision engine. Reports are **not** a logging pipeline, an APM, or a crash-analytics platform, and they intentionally avoid collecting user identity. ## Enabling reports Report ingestion is gated by a dedicated environment flag, similar to TUF routes. When `REPORTS_ENABLED=false`, neither the ingestion endpoint nor the read API is registered. ```env REPORTS_ENABLED=true REPORTS_MAX_BODY_BYTES=262144 REPORTS_MAX_DETAILS_COMPRESSED_BYTES=131072 REPORTS_MAX_DETAILS_DECOMPRESSED_BYTES=1048576 REPORTS_BLOB_RETENTION_DAYS=30 REPORTS_MAX_BLOBS_PER_GROUP=10 REPORTS_STORAGE_PREFIX=reports REPORTS_RATE_LIMIT_PER_KEY_PER_MINUTE=100 ``` Notes: - Rate limits are kept in Redis, which every faynoSync instance requires. - Detail blobs reuse the existing `STORAGE_DRIVER` / `S3_*` configuration but are written to the **private** bucket (`S3_BUCKET_NAME_PRIVATE`) and are only retrievable through short-lived presigned URLs. ## Authentication model There are two distinct auth paths: - **Ingestion** (`POST /reports/ingest`) is a public client endpoint authenticated by a per-app **report key** (`rpk_` + 64 hex), sent as `Authorization: Bearer rpk_...`. It does **not** require JWT. Report keys are managed through the existing `report_keys` lifecycle (one key per app with `reports` enabled). - **Read API** (`GET /reports/groups`, `GET /reports/groups/:groupHash/blobs`) is authenticated with a JWT and gated by `CheckPermission(download, apps)`. Admins see every app under their account; team users only see apps in their `allowed_apps`. - **Management API** (`PATCH /reports/groups/:groupHash`, `DELETE /reports/groups/:groupHash`) is also JWT-authenticated but gated by stronger permissions: `edit` for updates and `delete` for deletion. All management endpoints are scoped to the requester's accessible apps. Report keys are effectively public client credentials shipped inside the client; abuse protection comes from the `REPORTS_ENABLED` gate, the per-app `reports` flag, and rate limits — not from key secrecy. ## Grouping model Every report is reduced to a deterministic `groupHash` built **only** from stable dimensions: ```text sha256(application.name + "|" + application.version + "|" + application.channel + "|" + system.platform + "|" + system.arch + "|" + event.type + "|" + event.reason) ``` Debug `details`, timestamps, client IP, and device identifiers are **excluded** from the hash, so unique stack traces never fragment a group. Because app names are unique only per `(app_name, owner)`, the grouping identity stored in Mongo is the composite `(app_id, groupHash)`, which keeps tenants fully isolated. ## Lifecycle status Every group carries a lifecycle `status` used for triage: - `open` — the default; new groups are created `open`. In list queries, `status=open` also matches pre-existing groups that predate the field (those with no `status`). - `resolved` — marked as handled. Resolving records `resolved_at` and `resolved_by` (the requester's username). - `muted` — suppressed noise; muted groups stay suppressed even when new events arrive. Alongside status, groups support two free-form triage fields that are independent of the lifecycle state: `tags` (up to 20, each `^[a-zA-Z0-9._-]{1,64}$`) and a `note` (up to 2000 characters). Sending an empty tags array or empty note clears the respective field. ### Regression auto-reopen A new event on a `resolved` group automatically flips it back to `open` and clears the resolution markers (`resolved_at` / `resolved_by`), so a recurring failure resurfaces instead of staying hidden. Tags and note are preserved across the reopen. `muted` groups are never auto-reopened. Status, tags, and note are set through the [Update Report Group](/docs/api/reports/update-group) endpoint, and groups can be permanently removed with [Delete Report Group](/docs/api/reports/delete-group). ## Privacy boundary Reports avoid user identity by design. Allowed dimensions are app name, version, channel, platform, arch, event type, reason, an anonymous `X-Device-ID` (used only for rate limits and dedup, never stored in report documents or the hash), and optional technical details in a blob. Stripping secrets and personal data from `details` is the responsibility of the client/SDK. ## Reports endpoints - [Ingest Report](/docs/api/reports/ingest) - [List Report Groups](/docs/api/reports/list-groups) - [List Group Detail Blobs](/docs/api/reports/list-detail-blobs) - [Update Report Group](/docs/api/reports/update-group) - [Delete Report Group](/docs/api/reports/delete-group) --- ## Update Report Group Partially updates a report group's triage metadata: its lifecycle `status`, `tags`, and `note`. Any combination of the three fields may be sent, but at least one is required. This is an authenticated (JWT) route, active only when `REPORTS_ENABLED=true`. ## Endpoint ``` PATCH /reports/groups/:groupHash ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | Gated by `CheckPermission(edit, apps)`: - **Admins** can update every group under their account. - **Team users** must have the `apps.edit` permission and can only update groups whose app is in their `allowed_apps`. The update is scoped by accessible `app_id`, so a group belonging to another owner (even with an identical hash) is never matched. ## Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `groupHash` | string | ✅ | The group's SHA-256 hash; must be exactly 64 hex characters (otherwise `400`) | ## Request Body All fields are optional pointers, but **at least one** must be present. | Field | Type | Description | |-------|------|-------------| | `status` | string | New lifecycle status: `open`, `resolved`, or `muted` | | `tags` | array of strings | Up to 20 tags, each matching `^[a-zA-Z0-9._-]{1,64}$`. An empty array clears all tags | | `note` | string | Free-form note, up to 2000 characters. An empty string clears the note | ```json { "status": "resolved", "tags": ["ui", "needs-repro"], "note": "tracked in JIRA-123" } ``` ### Behavior - **Resolving** (`status: "resolved"`) records `resolved_at` (current time) and `resolved_by` (the requester's username). - Setting any other status (`open` / `muted`) clears `resolved_at` / `resolved_by`. - `tags` and `note` are triage metadata independent of `status` — they can be set or cleared without touching the lifecycle state. ## Example Request ```bash curl -s -X PATCH 'http://localhost:9000/reports/groups/9c460a0d02a7a80a1456b49d88033100cad27266848feb2a1cdd7480d50efbcc' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{"status":"resolved","tags":["ui","needs-repro"],"note":"tracked in JIRA-123"}' ``` ## Response ### Success Response (200 OK) ```json { "group_hash": "9c460a0d02a7a80a1456b49d88033100cad27266848feb2a1cdd7480d50efbcc", "updated": true } ``` ### Error Responses | Status | Condition | |--------|-----------| | `400` | Invalid group hash, malformed body, no fields to update, invalid status, invalid tags, or note too long | | `401` | Missing or invalid JWT | | `404` | No matching group in an accessible app | | `500` | Internal error | ## Notes - A new event on a `resolved` group automatically reopens it (see [regression auto-reopen](/docs/api/reports/reports-introduction#lifecycle-status)); `muted` groups stay suppressed regardless of new events. - To list groups filtered by status, use [List Report Groups](/docs/api/reports/list-groups) with the `status` query parameter. - To permanently remove a group, use [Delete Report Group](/docs/api/reports/delete-group). --- ## Create User Create a new team user with specific permissions for different resources. ## Endpoint ``` POST /user/create ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `username` | string | ✅ | Username for the new team user | | `password` | string | ✅ | Password for the new team user | | `permissions` | object | ✅ | Object containing permission settings for different resources | ### Permissions Object Structure | Resource | Type | Description | |----------|------|-------------| | `apps` | object | Permissions for application management | | `channels` | object | Permissions for channel management | | `platforms` | object | Permissions for platform management | | `archs` | object | Permissions for architecture management | ### Permission Fields for Each Resource | Field | Type | Description | |-------|------|-------------| | `create` | boolean | Permission to create new resources | | `delete` | boolean | Permission to delete resources | | `edit` | boolean | Permission to edit existing resources | | `download` | boolean | Permission to download files (apps only) | | `upload` | boolean | Permission to upload files (apps only) | | `allowed` | array | List of resource IDs the user has access to | ## Example Request ```bash curl -X POST \ 'http://localhost:9000/user/create' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "username": "teamuser1", "password": "password123", "permissions": { "apps": { "create": true, "delete": false, "edit": true, "download": true, "upload": false, "allowed": [""] }, "channels": { "create": true, "delete": false, "edit": true, "allowed": [""] }, "platforms": { "create": true, "delete": false, "edit": true, "allowed": [""] }, "archs": { "create": true, "delete": false, "edit": true, "allowed": [""] } } }' ``` ## Response ### Success Response (200 OK) ```json { "message": "Team user created successfully" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `message` | string | Confirmation message indicating successful user creation | ## Notes - Team users have restricted access based on their assigned permissions - The `allowed` array contains IDs of specific resources the user can access - Passwords should follow security best practices - Only admin users can create team users --- ## Delete Specific User Delete a specific team user by their unique identifier. ## Endpoint ``` DELETE /user/delete ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | The unique identifier of the user to delete | ## Example Request ```bash curl -X DELETE \ 'http://localhost:9000/user/delete' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "id": "67f8e842675a6e160d48a59c" }' ``` ## Response ### Success Response (200 OK) ```json { "message": "Team user deleted successfully" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `message` | string | Confirmation message indicating successful user deletion | ## Notes - This operation permanently deletes the team user - The deletion is irreversible - deleted users cannot be recovered - Only admin users can delete team users - The user's permissions and access rights are also removed - Ensure the user is not currently active before deletion --- ## List Users Retrieve a list of all your team users with their permissions and access rights. ## Endpoint ``` GET /users/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl -X GET http://localhost:9000/users/list \ -H "Authorization: Bearer " ``` ## Response ### Success Response (200 OK) ```json { "users": [ { "id": "67fd2a63a70df16a87034b83", "username": "teamuser1", "permissions": { "Apps": { "Create": true, "Delete": false, "Edit": true, "Download": true, "Upload": false, "Allowed": [ "67f8070fba341940a3ae4542" ] }, "Channels": { "Create": true, "Delete": false, "Edit": true, "Allowed": [ "67f826fa5c0a2b68411b2111" ] }, "Platforms": { "Create": true, "Delete": false, "Edit": true, "Allowed": [ "67f82615a27c60636bd57308" ] }, "Archs": { "Create": true, "Delete": false, "Edit": true, "Allowed": [ "67f8261aa27c60636bd57309" ] } }, "updated_at": "2025-04-14T18:31:47.575+03:00" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `users` | array | Array of team user objects | #### User Object Fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Unique identifier of the user | | `username` | string | Username of the team user | | `permissions` | object | Detailed permissions for different resource types | | `updated_at` | string | Last update timestamp | #### Permission Object Fields | Field | Type | Description | |-------|------|-------------| | `Create` | boolean | Permission to create new resources | | `Delete` | boolean | Permission to delete resources | | `Edit` | boolean | Permission to edit existing resources | | `Download` | boolean | Permission to download files (Apps only) | | `Upload` | boolean | Permission to upload files (Apps only) | | `Allowed` | array | List of resource IDs the user has access to | ## Notes - This endpoint returns all team users created by the authenticated admin - The `Allowed` array contains IDs of specific resources the user can access - Only admin users can view the list of team users - Results are typically sorted by creation date (newest first) --- ## Update User Update an existing team user's permissions and credentials. ## Endpoint ``` POST /user/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `username` | string | ✅ | Username of the team user to update | | `password` | string | ✅ | New password for the team user | | `permissions` | object | ✅ | Updated permission settings for different resources | ### Permissions Object Structure | Resource | Type | Description | |----------|------|-------------| | `apps` | object | Permissions for application management | | `channels` | object | Permissions for channel management | | `platforms` | object | Permissions for platform management | | `archs` | object | Permissions for architecture management | ### Permission Fields for Each Resource | Field | Type | Description | |-------|------|-------------| | `create` | boolean | Permission to create new resources | | `delete` | boolean | Permission to delete resources | | `edit` | boolean | Permission to edit existing resources | | `download` | boolean | Permission to download files (apps only) | | `upload` | boolean | Permission to upload files (apps only) | | `allowed` | array | List of resource IDs the user has access to | ## Example Request ```bash curl -X POST \ 'http://localhost:9000/user/update' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "username": "teamuser1", "password": "password1234", "permissions": { "apps": { "create": false, "delete": true, "edit": true, "download": true, "upload": true, "allowed": ["", ""] }, "channels": { "create": true, "delete": true, "edit": true, "allowed": [""] }, "platforms": { "create": true, "delete": false, "edit": true, "allowed": [""] }, "archs": { "create": true, "delete": false, "edit": true, "allowed": [""] } } }' ``` ## Response ### Success Response (200 OK) ```json { "message": "Team user updated successfully" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `message` | string | Confirmation message indicating successful user update | ## Notes - Only admin users can update team user permissions - Password changes take effect immediately - Permission changes affect the user's access rights immediately - The `allowed` array can be updated to grant or revoke access to specific resources --- ## Update Admin User Update an existing admin user's password. ## Endpoint ``` POST /admin/update ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | ID of the admin to update (cannot be changed) | | `username` | string | ✅ | Username of the admin to update (cannot be changed) | | `password` | string | ✅ | New password for the admin | ## Example Request ```bash curl -X POST \ 'http://localhost:9000/admin/update' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{ "id": "67ffc3f5a2120e73468ce66c", "username": "admin", "password": "newpassword123" }' ``` ## Response ### Success Response (200 OK) ```json { "message": "Admin updated successfully" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `message` | string | Confirmation message indicating successful admin update | ## Notes - Only admin users can update admin passwords - The `id` and `username` fields are used for identification and cannot be changed - Password changes take effect immediately - New passwords should follow security best practices - The admin will need to re-authenticate with the new password --- ## Users Management The Team Based Authorization Matrix is a permission system, designed to support multi-team collaboration while ensuring strict access boundaries between users and resources. It enables administrators to create isolated teams of users, assign granular permissions per resource type, and maintain full control over data ownership and visibility. This model ensures: - Clear separation between administrators and their team users. - Scoped access to applications, channels, platforms, and architectures. - Automatic ownership and permission tracking. - Full traceability of all actions performed by team users. The following API endpoints allow administrators to manage team users and control their access within the boundaries of their own account. :::tip For more information, please refer to [this page](../../team_based_auth). ::: --- ## Create Token Create a scoped automation token for upload workflows. ## Endpoint ``` POST /token/create ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | ✅ | Display name of the token (duplicates are allowed) | | `allowed_apps` | array of string | ✅ | App IDs this token can upload to | | `expires_at` | string (ISO 8601) | ✅ | Expiration timestamp | ## Example Request ```bash curl --location 'http://localhost:9000/token/create' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "name": "GitHub Actions - MyApp", "allowed_apps": ["69259667351f660c2fd62d39", "69259667351f660c2fd62d40"], "expires_at": "2026-12-31T00:00:00Z" }' ``` ## Response ### Success Response (201 Created) ```json { "allowed_apps": [ "69259667351f660c2fd62d39", "69259667351f660c2fd62d40" ], "created_at": "2026-02-20T16:39:16.233976+02:00", "expires_at": "2026-12-31T00:00:00Z", "id": "699872148c1ed923e48bc2c9", "name": "GitHub Actions - MyApp", "token": "fns_22ba5585a1d3d9e2c7c42d4339ab486e34d965adc7a1a68534a8d72025deabd0", "token_prefix": "fns_22ba5585" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Token identifier | | `name` | string | Token display name | | `allowed_apps` | array of string | Effective list of app IDs allowed for upload | | `expires_at` | string | Expiration timestamp | | `created_at` | string | Creation timestamp | | `token` | string | Full token secret, returned only once | | `token_prefix` | string | Short non-secret prefix for identification | ## Notes - Save `token` immediately. It cannot be fetched again later. - `allowed_apps` may be normalized to unique app IDs in the response. - Token names can be reused across multiple tokens. - Tokens cannot be updated after creation; revoke and recreate if needed. --- ## Delete Token Revoke a token by ID. Revoked tokens can no longer be used. ## Endpoint ``` DELETE /token/delete ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | | `Content-Type` | `application/json` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | Token ID to revoke | ## Example Request ```bash curl --location --request DELETE 'http://localhost:9000/token/delete' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "id": "69972c0c93be639a3a718e6b" }' ``` ## Response ### Success Response (200 OK) ```json { "message": "Token revoked" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `message` | string | Confirmation that the token was revoked | ## Notes - Token revocation is the only way to change token access or lifetime. - After deletion, create a new token if you need updated `allowed_apps` or expiration. - Deleted tokens cannot be restored. --- ## List Tokens Return all tokens visible to the authenticated administrator. ## Endpoint ``` GET /token/list ``` ## Authentication | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Example Request ```bash curl --location 'http://localhost:9000/token/list' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) ```json { "tokens": [ { "id": "699872148c1ed923e48bc2c9", "name": "GitHub Actions - MyApp", "token_prefix": "fns_22ba5585", "allowed_apps": [ "69259667351f660c2fd62d39", "69259667351f660c2fd62d40" ], "expires_at": "2026-12-31T00:00:00Z", "last_used_at": "2026-02-20T14:46:18.133Z", "created_at": "2026-02-20T14:39:16.233Z" } ] } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `tokens` | array | List of token objects | #### Token Object Fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Token identifier | | `name` | string | Token display name | | `token_prefix` | string | Non-secret token prefix for identification | | `allowed_apps` | array of string | App IDs this token can upload to | | `expires_at` | string | Expiration timestamp | | `last_used_at` | string or null | Last successful usage timestamp | | `created_at` | string | Creation timestamp | ## Notes - Full token secret values are never returned by this endpoint. - Use `token_prefix` and `id` to identify the token you want to revoke. - This endpoint is useful for token audits and cleanup. --- ## CI/CD Token Management Use tokens when you want to automate uploads without sharing a full user JWT in CI/CD pipelines or scripts. Tokens are created by an administrator and are currently designed for one primary use case: uploading new app versions. This keeps automation simple and safer than broad account credentials. ## What tokens can do - Upload new versions of applications (primary purpose) - Read lists of apps, channels, platforms, and architectures ## What tokens cannot do - Use most non-token routes (you should expect authorization errors there) - Update token configuration after creation - Recover full token value after creation (it is shown only once) ## Important behavior 1. **One-time secret display**: The `token` field is returned only once during creation. Save it immediately in your secret manager. 2. **Scoped uploads**: You can restrict a token to specific apps using `allowed_apps`. 3. **Recreate instead of update**: Tokens are immutable. To change access, revoke and create a new token. 4. **Flexible lifetime**: You can set token lifetime as needed, as long as it is not negative. 5. **Non-unique names**: Token names may repeat and have no strict length/uniqueness constraints. --- ## Check Bootstrap Check whether bootstrap has already been performed for a specific application and whether it completed successfully. Use this endpoint before starting a new bootstrap to determine if the TUF repository for the given app is already initialized. If bootstrap has not been done yet, the system is available for bootstrap; if it has completed, you will receive the existing bootstrap identifier and lock information. ## Endpoint ``` GET /tuf/v1/bootstrap?appName= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application to check bootstrap status for | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/bootstrap?appName=' \ --header 'Authorization: Bearer ' ``` ## Response ### Bootstrap Not Yet Performed (200 OK) When bootstrap has not been done for this application, the system is available for bootstrap: ```json { "data": { "bootstrap": false, "id": "", "redis_locks": { "bootstrap_lock": "", "pre_locks": [] }, "state": null }, "message": "System available for bootstrap." } ``` ### Bootstrap Already Completed (200 OK) When bootstrap has already completed successfully for this admin and application: ```json { "data": { "bootstrap": true, "id": "8085748f-a572-491b-b1b0-d047daa3ae05", "redis_locks": { "bootstrap_lock": "8085748f-a572-491b-b1b0-d047daa3ae05", "pre_locks": [] }, "state": null }, "message": "Bootstrap already completed for this admin." } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.bootstrap` | boolean | `true` if bootstrap has been completed for this app; `false` if not yet performed | | `data.id` | string | Bootstrap session identifier; empty when bootstrap has not been done, UUID when completed | | `data.redis_locks.bootstrap_lock` | string | Redis lock value for bootstrap; empty or matches `id` when bootstrap is in use or completed | | `data.redis_locks.pre_locks` | array | List of pre-lock identifiers (if any) | | `data.state` | object \| null | Optional state payload; may be `null` | | `message` | string | Human-readable status: "System available for bootstrap." or "Bootstrap already completed for this admin." | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Call this endpoint before starting bootstrap to avoid duplicate initialization. - When `bootstrap` is `false`, you can proceed with the [Start Bootstrap](/docs/api/tuf/start_bootstrap) flow. - When `bootstrap` is `true`, the TUF repository for this app is already set up; no need to run bootstrap again for this admin. --- ## Check Task Retrieve the status and result of a TUF-related background task by its UUID. Tasks are created for almost all TUF metadata operations: bootstrap, signing, metadata rotation, publishing artifacts, and similar actions. Each operation returns a `task_id`; use this endpoint to poll for completion and get the outcome. Pass the task UUID to check whether the task is still running, has succeeded, or has failed. ## Endpoint ``` GET /tuf/v1/task?task_id= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `task_id` | string (UUID) | ✅ | The unique identifier of the task to check | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/task?task_id=ecca335d-9192-4fc2-bbb0-3d7a38a55c84' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) When the task has completed successfully (e.g. bootstrap finished): ```json { "data": { "task_id": "ecca335d-9192-4fc2-bbb0-3d7a38a55c84", "state": "SUCCESS", "result": { "message": "Bootstrap completed successfully", "status": true, "task": "bootstrap", "last_update": "2026-02-04T14:28:13.316292+02:00" } }, "message": "Task state." } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.task_id` | string | UUID of the task | | `data.state` | string | Task state (e.g. `SUCCESS`, `PENDING`, `FAILED`); exact values may vary by implementation | | `data.result` | object | Task outcome; structure depends on task type (bootstrap, sign, rotation, publish, etc.) | | `data.result.message` | string | Human-readable result message | | `data.result.status` | boolean | Indicates success when `true` | | `data.result.task` | string | Task type (e.g. `"bootstrap"`) | | `data.result.last_update` | string | ISO8601 timestamp of the last update | | `message` | string | Top-level message (e.g. `"Task state."`) | ## Task types and result shape The `data.result` object varies by the operation that created the task. For bootstrap tasks it includes `message`, `status`, `task`, and `last_update`. For other TUF operations (signing, metadata rotation, publishing artifacts), the result may contain different fields. The `data.state` indicates whether the task is still in progress (`PENDING`), finished successfully (`SUCCESS`), or failed (`FAILED` or similar). ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Use the `task_id` returned from [Start Bootstrap](/docs/api/tuf/start_bootstrap) or other TUF endpoints to poll until `state` is no longer pending. - A single UUID is enough to check the result; no app name or other parameters are required. - For long-running operations, poll this endpoint periodically until `state` reflects completion or failure. --- ## Delete Artifact (TUF) Remove TUF signatures for selected artifacts of a given application version. The artifacts themselves remain stored; only their TUF metadata entries are removed so that TUF-enabled clients will no longer trust or offer those artifacts for update. This endpoint is similar to [Delete artifact](/docs/api/apps/delete-specific-artifact): you pass the version's application ID, app name, version, and an array of artifact indices to remove from TUF. Deletion runs in the background; use the returned `task_id` with [Check Task](/docs/api/tuf/check_tasks) to verify completion. ## Endpoint ``` POST /tuf/v1/artifacts/delete ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `text/plain` | | `Authorization` | `Bearer ` | ## Request Body The body must be a JSON object with the application version identifier and the artifact indices to remove from TUF. | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string | ✅ | Unique identifier of the specific version of the application | | `app_name` | string | ✅ | Name of the application | | `version` | string | ✅ | Version of the application (e.g. `0.0.0.5`) | | `artifacts_to_delete` | array | ✅ | Array of artifact indices to remove from TUF (e.g. `["0", "1"]`); indices match the artifact list for that version | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/artifacts/delete' \ --header 'Content-Type: text/plain' \ --header 'Authorization: Bearer ' \ --data '{"id": "697100362fadc9dccd0e3a96", "app_name": "", "version": "0.0.0.5", "artifacts_to_delete": ["0"]}' ``` ## Response ### Success Response (202 Accepted) When deletion is accepted, it runs in the background: ```json { "data": { "app_id": "697100362fadc9dccd0e3a96", "artifacts": [ "-////-" ], "last_update": "2026-02-04T17:40:24.73467+02:00", "task_id": "3fbccbb0-c600-48bc-b6bc-fea9902511be", "version": "0.0.0.5" }, "message": "Artifact(s) deletion started" } ``` ### Error Response (400 Bad Request) When none of the requested artifacts are valid for TUF deletion (e.g. not TUF-signed): ```json { "error": "No valid artifacts to delete (all must be TUF signed)" } ``` ### Response Fields (202 Accepted) | Field | Type | Description | |-------|------|-------------| | `data.app_id` | string | Application version ID from the request | | `data.artifacts` | array | List of artifact paths removed from TUF (e.g. `-////-.`) | | `data.last_update` | string | ISO8601 timestamp when deletion was started | | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify completion | | `data.version` | string | Version from the request | | `message` | string | `"Artifact(s) deletion started"` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - This endpoint **removes TUF signatures** for the given artifacts; it does **not** delete the artifact files from storage. To delete the files themselves, use [Delete artifact](/docs/api/apps/delete-specific-artifact). - Only artifacts that are **TUF-signed** can be removed from TUF; if none of the requested indices refer to TUF-signed artifacts, the API returns `400` with "No valid artifacts to delete (all must be TUF signed)". - Artifact indices in `artifacts_to_delete` are the same as in the app version's artifact list (e.g. `"0"`, `"1"` for first and second artifact). - You can remove multiple artifacts in one request by including multiple indices in `artifacts_to_delete`. - When `task_id` is present, poll [Check Task](/docs/api/tuf/check_tasks) until the task completes to ensure TUF metadata has been updated. --- ## Delete Unsigned Metadata Cancel the signing process by **deleting unsigned metadata** (the draft) from Redis. Use this endpoint when you need to **abort** a root signing flow that was started via [Metadata rotation](/docs/api/tuf/metadata_rotation) with empty or incomplete signatures. The server removes the draft (e.g. under `ROOT_SIGNING__`) so that no metadata is left pending signing. After a successful call, there is no longer any unsigned root to [retrieve](/docs/api/tuf/get_unsigned_root_metadata) or [add signatures](/docs/api/tuf/post_unsigned_root_metadata) to; you can start a new rotation later if needed. ## Endpoint ``` POST /tuf/v1/metadata/sign/delete?appName= ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose unsigned metadata to delete | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `role` | string | ✅ | Role whose signing draft to remove; use `"root"` for root metadata | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/sign/delete?appName=' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "role": "root" }' ``` ## Response ### Success Response (202 Accepted) When the unsigned metadata (draft) is found and deletion is accepted, it runs in the background: ```json { "data": { "task_id": "bc1b28ea-08e9-475c-a9c6-00d177ae35f0", "last_update": "2026-02-05T14:46:24.064325+02:00" }, "message": "Metadata sign delete accepted." } ``` ### Error Response (400 Bad Request) When there is no signing process in progress for the given role (no draft in Redis): ```json { "error": "The root role is not in a signing process.", "message": "No signing process for root." } ``` ### Response Fields (202 Accepted) | Field | Type | Description | |-------|------|-------------| | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify completion | | `data.last_update` | string | ISO8601 timestamp when the delete was accepted | | `message` | string | `"Metadata sign delete accepted."` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Use this endpoint only when you want to **cancel** the root signing process (e.g. after [Metadata rotation](/docs/api/tuf/metadata_rotation) with incomplete signatures). The draft is removed from Redis; the current trusted root is **not** changed. - If the root role is **not** in a signing process (no draft stored), the server returns **400** with "The root role is not in a signing process." / "No signing process for root." - When `task_id` is present, poll [Check Task](/docs/api/tuf/check_tasks) until the task completes to ensure the draft has been removed. --- ## Force Metadata Update Trigger a **background update** (regeneration and signing) of TUF metadata for the **online roles**: snapshot, timestamp, and optionally targets. Use this endpoint when bootstrap is already complete and you need to "roll" metadata versions and re-sign (e.g. after changes to targets, or simply to refresh snapshot and timestamp). **When to use:** - Bootstrap is **completed** (status is not empty and not in a pre-* or signing-* state). - You want to force-update metadata for **snapshot** and **timestamp** (and optionally **targets** if it uses an online key). The operation runs in the background; use the returned `task_id` with [Check Task](/docs/api/tuf/check_tasks) to verify completion. ## Endpoint ``` POST /tuf/v1/metadata/online?appName= ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose TUF metadata to update | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `roles` | array of strings | ❌ | Roles to update: `"snapshot"`, `"timestamp"`, and optionally `"targets"`. Do **not** include `"root"`—root is updated via [Metadata rotation](/docs/api/tuf/metadata_rotation). If omitted or empty, defaults apply (see below). | ### Default behavior when `roles` is omitted or empty - The server uses **`["snapshot", "timestamp"]`** by default. - If **targets** is configured to use an **online key** (e.g. `TARGETS_ONLINE_KEY_...` is `"true"` / `"1"` / `"True"`), **`"targets"`** is added to the list automatically. ### Including `"targets"` when targets is offline If you pass **`"targets"`** in `roles` but the targets role is configured as **offline**, the server returns **404** with a message that targets is offline and a different update method is required (e.g. signing targets metadata manually or via another flow). ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/online?appName=' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "roles": ["targets", "snapshot", "timestamp"] }' ``` ## Response ### Success Response (200 OK) When the update is accepted, it runs in the background: ```json { "data": { "task_id": "0340496a-90bb-46b1-bc3c-e5c66cf98149", "last_update": "2026-02-05T14:31:57.992386+02:00" }, "message": "Force online metadata update accepted." } ``` ### Error Response (400 Bad Request) When `"root"` is included in `roles`: ```json { "error": "Root role cannot be updated via this endpoint" } ``` Root metadata must be updated via [Metadata rotation](/docs/api/tuf/metadata_rotation) and the signing flow, not this endpoint. ### Error Response (404 Not Found) When `"targets"` is in `roles` but the targets role is configured as **offline**: The server returns 404 with a message that targets is offline and that another method is required to update targets metadata. ### Response Fields (200 OK) | Field | Type | Description | |-------|------|-------------| | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify completion | | `data.last_update` | string | ISO8601 timestamp when the update was accepted | | `message` | string | `"Force online metadata update accepted."` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - The repository must already be [bootstrapped](/docs/api/tuf/start_bootstrap) and not in a pre-* or signing-* state. - Use this endpoint to refresh **snapshot** and **timestamp** (and **targets** when it uses an online key) after changes or on a schedule. For root updates, use [Metadata rotation](/docs/api/tuf/metadata_rotation) and the signing flow instead. - When `task_id` is present, poll [Check Task](/docs/api/tuf/check_tasks) until the task completes to ensure metadata has been regenerated and published to S3. --- ## Get Config Retrieve the current TUF repository configuration for an application. The settings are stored in Redis and include role expiration values, key counts, and thresholds for root, timestamp, snapshot, and targets roles, as well as the bootstrap task identifier. Use this endpoint to inspect how the TUF repository for a given app is configured (e.g. after [bootstrap](/docs/api/tuf/start_bootstrap) or before [metadata rotation](/docs/api/tuf/metadata_rotation)). ## Endpoint ``` GET /tuf/v1/config?appName= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose TUF config to retrieve | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/config?appName=' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) When the repository has been bootstrapped and config exists in Redis: ```json { "data": { "bootstrap": "ecca335d-9192-4fc2-bbb0-3d7a38a55c84", "role_expiration": 90, "root_expiration": 364, "root_num_keys": 2, "root_threshold": 2, "snapshot_expiration": 6, "snapshot_num_keys": 1, "snapshot_threshold": 1, "targets_expiration": 6, "targets_num_keys": 1, "targets_online_key": 1, "targets_threshold": 1, "timestamp_expiration": 1, "timestamp_num_keys": 1, "timestamp_threshold": 1 }, "message": "Current Settings" } ``` ### Error Response (404 Not Found) When bootstrap has not been completed for this app or no repository config exists in Redis: ```json { "error": "It requires bootstrap. State: ", "message": "No Repository Settings/Config found." } ``` ### Response Fields (200 OK) | Field | Type | Description | |-------|------|-------------| | `data.bootstrap` | string | Bootstrap task ID (UUID) associated with this repository | | `data.role_expiration` | number | Default role expiration in days | | `data.root_expiration` | number | Root metadata expiration in days | | `data.root_num_keys` | number | Number of root keys | | `data.root_threshold` | number | Number of root signatures required | | `data.snapshot_expiration` | number | Snapshot metadata expiration in days | | `data.snapshot_num_keys` | number | Number of snapshot keys | | `data.snapshot_threshold` | number | Snapshot signature threshold | | `data.targets_expiration` | number | Targets metadata expiration in days | | `data.targets_num_keys` | number | Number of targets keys | | `data.targets_online_key` | number | Number of online targets keys | | `data.targets_threshold` | number | Targets signature threshold | | `data.timestamp_expiration` | number | Timestamp metadata expiration in days | | `data.timestamp_num_keys` | number | Number of timestamp keys | | `data.timestamp_threshold` | number | Timestamp signature threshold | | `message` | string | `"Current Settings"` on success | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Config is stored in Redis; this endpoint returns the current in-memory repository settings for the given app. - A `404` response means the app has not been bootstrapped yet or the repository config is missing—run [Start Bootstrap](/docs/api/tuf/start_bootstrap) first. - Use the returned values to understand key counts and expiration settings before performing [metadata rotation](/docs/api/tuf/metadata_rotation) or other TUF operations. --- ## Get Current Delegated Metadata Retrieve the **current trusted delegated targets metadata** for a specific delegated role in a TUF repository. The response contains signatures and the full delegated `targets` payload for that role. Use this endpoint to inspect delegated target entries, verify role-specific versions and expiration, and troubleshoot delegated publishing flows. ## Endpoint ``` GET /tuf/v1/metadata/delegated?appName=&roleName= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose delegated metadata to retrieve | | `roleName` | string | ✅ | Delegated role name (for example `default`) | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/delegated?appName=tuf&roleName=default' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) When trusted delegated metadata exists for the selected role: ```json { "data": { "trusted_delegated": { "signatures": [ { "keyid": "aa556e04d0c3f7de8ef26915c8384b566c58ff4e25ae5a3cebd0f3a9991b5e28", "sig": "304402205bf2bfd95e4a2098b53aeb6351b014a1211191f99700759f7e6d8fdec9336adc02201edb5232c1c2c00a7e3d81b018c86e0e8d7e1c609ed36c6b8efa502eab9c7d14" } ], "signed": { "_type": "targets", "expires": "2026-07-28T12:18:53.795098Z", "spec_version": "1.0.31", "targets": { "tuf-admin/nightly/darwin/arm64/tuf-0.0.0.1": { "hashes": { "sha256": "a87fc2748969b4eafb685fa1ca48ac78c862fbb318e7fdb37eb25014865d1880", "sha512": "85713058addb0bcd700440561f3ad046c41aa9b6cea3e25bb8d8ddc02a5a07024a882ee703d2f1602a55a73d8921a6a67139e6d51d7c7f1bfb93b4c422ff1c5b" }, "length": 10090514 }, "tuf-admin/nightly/darwin/arm64/tuf-0.0.0.2": { "hashes": { "sha256": "a87fc2748969b4eafb685fa1ca48ac78c862fbb318e7fdb37eb25014865d1880", "sha512": "85713058addb0bcd700440561f3ad046c41aa9b6cea3e25bb8d8ddc02a5a07024a882ee703d2f1602a55a73d8921a6a67139e6d51d7c7f1bfb93b4c422ff1c5b" }, "length": 10090514 } }, "version": 6 } } } } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.trusted_delegated` | object | Current trusted delegated targets metadata for the selected role | | `data.trusted_delegated.signatures` | array | List of signatures (keyid + sig) that satisfy the delegated role threshold | | `data.trusted_delegated.signed` | object | Delegated targets payload: `_type`, `version`, `spec_version`, `expires`, `targets` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - `roleName` must reference an existing delegated role configured in targets delegations. - Use this endpoint to verify delegated role metadata state after delegated publish/update operations. - The returned metadata is the active trusted delegated metadata used by clients through normal TUF verification flow. --- ## Get Current Root Metadata Retrieve the **current trusted root metadata** for a TUF repository. This is the root that is published and in use (e.g. stored in S3); it includes all signatures and the full root payload (keys, roles, version, expires). Use this endpoint as a helper to inspect the active root, compare keyids and roles before [Metadata rotation](/docs/api/tuf/metadata_rotation), or verify the root after bootstrap or rotation. ## Endpoint ``` GET /tuf/v1/metadata/root?appName= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose current root metadata to retrieve | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/root?appName=' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) When the repository has been bootstrapped and a trusted root exists: ```json { "data": { "trusted_root": { "signatures": [ { "keyid": "18aa7db1a150ab12b7fcd143d782afec2ba2600d654d352557b9d048a0d7b6b0", "sig": "110df00870d2b87f40c4422183d5edc753ac66eb7d6ba692121888f4b43d975adaf4a5831170c90ab4990796a1c37b4b5afd3a042bccae85de20270fcadd9c09" }, { "keyid": "42d7f85d3981872a7b687a3c11a8d6adc856b9048242784cfeeae88ec4bf69a5", "sig": "fa625b8cb977a1c5d2df567a4f470dcd08036ab4a198037fe7fa8135055b5fbdafcf603b3f6c9fc4cae7299bed8ff5beb887b2d31fc53ae60dcb67b622e7cd0d" } ], "signed": { "_type": "root", "consistent_snapshot": true, "expires": "2028-01-29T12:47:14Z", "keys": { "": { "keytype": "ed25519", "keyval": { "public": "" }, "scheme": "ed25519" } }, "roles": { "root": { "keyids": ["", ...], "threshold": 2 }, "snapshot": { "keyids": [""], "threshold": 1 }, "targets": { "keyids": [""], "threshold": 1 }, "timestamp": { "keyids": [""], "threshold": 1 } }, "spec_version": "1.0.31", "version": 2 } } } } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.trusted_root` | object | Current trusted root metadata | | `data.trusted_root.signatures` | array | List of signatures (keyid + sig) that satisfy the root role threshold | | `data.trusted_root.signed` | object | Root payload: `_type`, `version`, `spec_version`, `expires`, `consistent_snapshot`, `keys`, `roles` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - The repository must already be [bootstrapped](/docs/api/tuf/start_bootstrap) for this app; otherwise there is no trusted root to return. - Use this endpoint to inspect the active root (keyids, roles, expiration) before planning [Metadata rotation](/docs/api/tuf/metadata_rotation) or to confirm the root after rotation. - The returned root is the one clients use to verify the TUF repository; it is the same as the root published to S3. --- ## Get Current Targets Metadata Retrieve the **current trusted targets metadata** for a TUF repository. This is the active `targets` metadata published for the app, including signatures and the full signed payload. Use this endpoint to inspect delegated role mappings, verify expiration and version values, and validate target delegation rules before rotation or publishing changes. ## Endpoint ``` GET /tuf/v1/metadata/targets?appName= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose current targets metadata to retrieve | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/targets?appName=' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response (200 OK) When the repository has active trusted targets metadata: ```json { "data": { "trusted_targets": { "signatures": [ { "keyid": "a9d1cf6b5aa853fcae2278f9d42dc2c823d24d43ba1a0d82ef7651a37b98f7e3", "sig": "3045022100ed5200084f17f1299ae441b73b942c59f88cc82db5e79041865d36257042fedd022008b9a1bd36402e65580b7613eb5ff20efdb927f71681f62dceb4e2cc648a5aab" } ], "signed": { "_type": "targets", "delegations": { "keys": { "aa556e04d0c3f7de8ef26915c8384b566c58ff4e25ae5a3cebd0f3a9991b5e28": { "keytype": "ecdsa", "keyval": { "public": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuApbtgReemcou+nmtN8qtd6shoZn\nTiTc2GTlaRFuXQ3+4/G9yhGF3im6xuEz48CjcGTo7KSv3kj1VlWgP4ioZw==\n-----END PUBLIC KEY-----\n" }, "scheme": "ecdsa-sha2-nistp256" } }, "roles": [ { "keyids": [ "aa556e04d0c3f7de8ef26915c8384b566c58ff4e25ae5a3cebd0f3a9991b5e28" ], "name": "default", "paths": [ "admin/tuf/*", "tuf-admin/*", "electron-builder/tuf-admin/*", "squirrel_windows/tuf-admin/*", "tuf-admin/nightly/darwin/arm64/tuf-0.0.0.1" ], "terminating": false, "threshold": 1 } ] }, "expires": "2026-05-05T11:22:31.168771Z", "spec_version": "1.0.31", "targets": {}, "version": 9 } } } } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.trusted_targets` | object | Current trusted targets metadata | | `data.trusted_targets.signatures` | array | List of signatures (keyid + sig) that satisfy the targets role threshold | | `data.trusted_targets.signed` | object | Targets payload: `_type`, `version`, `spec_version`, `expires`, `targets`, optional `delegations` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - The repository must already be bootstrapped and have targets metadata for this app. - Delegations in `signed.delegations.roles` define which delegated role is trusted for specific target path patterns. - Use this endpoint to inspect active targets metadata before delegated changes or metadata rotation workflows. --- ## Get Unsigned Root Metadata Retrieve the root metadata that is currently stored in Redis and **not yet fully signed** (the draft created after [Metadata rotation](/docs/api/tuf/metadata_rotation) with empty or incomplete signatures). The response includes both the **unsigned draft** (`root` with `signatures: []` or partial) and the **current trusted root** (`trusted_root` with full signatures) so you can see what needs to be signed and by which keyids. Use this endpoint as a helper when collecting signatures: after sending a new root via `POST /tuf/v1/metadata` with no or partial signatures, call **GET** on this path to fetch the draft and the trusted root. You can then have each key holder sign the draft and submit their signature via [Add Signature to Root Metadata](/docs/api/tuf/post_unsigned_root_metadata). ## Endpoint ``` GET /tuf/v1/metadata/sign?appName= ``` ## Headers | Header | Value | |--------|-------| | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose unsigned root metadata to retrieve | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/sign?appName=' \ --header 'Authorization: Bearer ' ``` ## Response ### Success Response — Metadata Pending Signing (200 OK) When there is a root draft in Redis (e.g. under `ROOT_SIGNING__`), the server returns the unsigned root and the current trusted root: ```json { "data": { "metadata": { "root": { "signatures": [], "signed": { "_type": "root", "consistent_snapshot": true, "expires": "2028-01-29T12:47:14Z", "keys": { "": { "keytype": "ed25519", "keyval": { "public": "" }, "scheme": "ed25519" } }, "roles": { "root": { "keyids": ["", ...], "threshold": 2 }, "snapshot": { "keyids": [""], "threshold": 1 }, "targets": { "keyids": [""], "threshold": 1 }, "timestamp": { "keyids": [""], "threshold": 1 } }, "spec_version": "1.0.31", "version": 2 } }, "trusted_root": { "signatures": [ { "keyid": "", "sig": "" } ], "signed": { "_type": "root", "consistent_snapshot": true, "expires": "2027-01-29T12:47:14Z", "keys": { ... }, "roles": { ... }, "spec_version": "1.0.31", "version": 1 } } } }, "message": "Metadata role(s) pending signing" } ``` - **`data.metadata.root`** — The new root draft (unsigned or partially signed). Use `root.signed` to know what each key must sign; `root.signatures` may be empty or partial. - **`data.metadata.trusted_root`** — The current trusted root (fully signed). Useful to compare keyids and roles with the new root. ### Success Response — Nothing Pending (200 OK) When there is no root draft in Redis (everything is already signed or no rotation was started): ```json { "data": null, "message": "No metadata pending signing available" } ``` ### Response Fields (when metadata pending) | Field | Type | Description | |-------|------|-------------| | `data.metadata.root` | object | New root metadata draft: `signatures` (array, may be empty or partial) and `signed` (root payload: keys, roles, version, expires, etc.) | | `data.metadata.trusted_root` | object | Current trusted root: `signatures` (full list) and `signed` (root payload) | | `message` | string | `"Metadata role(s) pending signing"` or `"No metadata pending signing available"` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Use **GET** on this path to **retrieve** unsigned metadata; use **POST** on the same path to [add a signature](/docs/api/tuf/post_unsigned_root_metadata). - When `data.metadata` is present, a draft exists in Redis (e.g. after [Metadata rotation](/docs/api/tuf/metadata_rotation) with incomplete signatures). Use the returned `root.signed` and `trusted_root` to have key holders produce signatures, then submit each via POST `/tuf/v1/metadata/sign`. - When `data` is `null`, there is no metadata pending signing—either no draft was stored or the root has already been finalized and the Redis key removed. --- ## Metadata Rotation Start or complete a root metadata rotation (root key rotation) for a TUF repository. You send the new root metadata; the server either finalizes and publishes it to S3 (when the signature threshold is met) or stores a draft in Redis for later signing. Use this endpoint when you need to rotate root keys, extend root expiration, or update the root metadata. The operation may complete immediately or require additional signatures via the [sign endpoint](/docs/api/tuf/post_unsigned_root_metadata). ## Two flows ### 1. Root with all signatures (threshold met) Send `metadata.root` with a full set of `signatures` that satisfy the root role's threshold. **Result:** The server validates the root, finalizes the update, saves it to S3, and returns `200 OK` with a `task_id`. The new root is published. ### 2. Root with no or incomplete signatures Send `metadata.root` with `signatures: []` or with fewer signatures than the threshold. **Result:** The server validates the root content but cannot finalize (signatures insufficient). It stores this root in Redis under a key such as `ROOT_SIGNING__` and returns `200 OK`. You then add signatures one by one via [POST unsigned root metadata](/docs/api/tuf/post_unsigned_root_metadata) until the threshold is met; the server will then finalize and publish. ## Endpoint ``` POST /tuf/v1/metadata?appName= ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose TUF repository to rotate | ## Request Body The body must be a JSON object with the new root metadata under `metadata.root`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `metadata.root` | object | ✅ | New root metadata: `signatures` (array, may be empty or partial) and `signed` (root payload) | | `metadata.root.signatures` | array | ✅ | List of signatures (keyid + sig); may be `[]` or incomplete for draft flow | | `metadata.root.signed` | object | ✅ | Root payload: `_type`, `version`, `spec_version`, `expires`, `consistent_snapshot`, `keys`, `roles` | The `signed` object must include `keys` (keyid → key definition with keytype, scheme, keyval.public), `roles` (root, timestamp, snapshot, targets with keyids and threshold), and standard TUF root fields. ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata?appName=' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "metadata": { "root": { "signatures": [], "signed": { "_type": "root", "consistent_snapshot": true, "expires": "2026-12-21T12:52:51.723305Z", "keys": { "": { "keytype": "ed25519", "scheme": "ed25519", "keyval": { "public": "" } } }, "roles": { "root": { "keyids": ["", ...], "threshold": 2 }, "timestamp": { "keyids": [""], "threshold": 1 }, "snapshot": { "keyids": [""], "threshold": 1 }, "targets": { "keyids": [""], "threshold": 1 } }, "spec_version": "1.0.31", "version": 2 } } } }' ``` Use TUF tooling or the FaynoSync admin panel to generate the correct root payload and, when ready, signatures. ## Response ### Success Response (200 OK) The server accepts the root and either finalizes it (if threshold met) or stores a draft in Redis: ```json { "data": { "task_id": "97423b94-b1b8-4649-a9b1-7de18b30e9a9", "last_update": "2026-02-05T13:39:59.899287+02:00" }, "message": "Metadata Update Processed" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify completion (e.g. when finalization runs) | | `data.last_update` | string | ISO8601 timestamp when the update was processed | | `message` | string | `"Metadata Update Processed"` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - The repository must already be [bootstrapped](/docs/api/tuf/start_bootstrap) for this app. - If you send a root with **no or incomplete signatures**, the server stores the draft in Redis; add signatures via [POST unsigned root metadata](/docs/api/tuf/post_unsigned_root_metadata) until the root threshold is met, then the server finalizes and publishes to S3. - If you send a root with **enough signatures**, the server validates, finalizes, and publishes in the background; use `task_id` with [Check Task](/docs/api/tuf/check_tasks) to confirm completion. - Root metadata (keys, roles, version, expires) is TUF-specific; generate it with TUF tooling or the FaynoSync admin panel. --- ## Stage Delegated Key Rotation Metadata Stage delegated metadata updates for **offline signing** during delegated key rotation. This endpoint stores pending metadata in Redis and creates signing task state, then you complete signatures through the existing sign flow. Use this route when you rotate keys for an existing delegated role and need to safely stage: 1. updated delegator metadata (usually `targets`) that authorizes new delegated keys; 2. delegated role metadata signed by the new delegated keys. ## Endpoint ``` POST /tuf/v1/metadata/delegated/rotate?appName= ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Auth and Permissions - Requires a valid JWT in the `Authorization` header. - Caller must be an admin for the selected app. - `appName` is required and determines which TUF repository is updated. ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose delegated metadata rotation is staged | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `role` | string | ✅ | Delegated role name to rotate keys for; must **not** be a top-level role (`root`, `targets`, `snapshot`, `timestamp`) | | `delegator` | string | ❌ | Delegating role name; defaults to `targets` | | `metadata` | object | ✅ | Staged metadata payloads for rotation flow | | `metadata.targets` | object | ⚠️ Conditional | New delegator targets metadata that authorizes new delegated keys (recommended, often required in practice) | | `metadata.` | object | ⚠️ Conditional | New delegated role metadata for `role`, signed by the rotated delegated keys (recommended) | At least one of `metadata.targets` or `metadata.` must be present. ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/delegated/rotate?appName=tuf-admin' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "role": "default", "delegator": "targets", "metadata": { "targets": { "signatures": [ { "keyid": "b11cb77f6ab74c7557f3cd2c90a9ef861601f9f1d5484ee8f3f9e1f2dd01aabc", "sig": "" } ], "signed": { "_type": "targets", "version": 8, "spec_version": "1.0.31", "expires": "2027-01-15T00:00:00Z", "targets": {}, "delegations": { "keys": { "9bc0...newkey": { "keytype": "ed25519", "scheme": "ed25519", "keyval": { "public": "" } } }, "roles": [ { "name": "default", "keyids": ["9bc0...newkey"], "threshold": 1, "paths": ["tuf-admin/nightly/*"] } ] } } }, "default": { "signatures": [ { "keyid": "9bc0...newkey", "sig": "" } ], "signed": { "_type": "targets", "version": 6, "spec_version": "1.0.31", "expires": "2027-01-15T00:00:00Z", "targets": {} } } } }' ``` ## Validation Rules and Security Notes The server validates staged delegated rotation metadata before storing it: - **Bootstrap state must be completed.** Pre-bootstrap (`pre-*`) and signing bootstrap (`signing-*`) states are rejected. - **Signed metadata type must be `targets`.** Any other `_type` is rejected. - **Expiration must be in the future.** Expired metadata cannot be staged. - **Duplicate signature keyids are rejected.** One keyid may appear only once per metadata object. - **Delegation keyid must match key material.** Key IDs are recomputed from key material and must match declared keyids. - **Version monotonicity is strict.** Incoming staged version must be greater than current trusted version. - **Delegation authorization is required.** `delegator` must authorize `role` in its `delegations` block; otherwise staging is rejected. These checks protect against rollback, freeze, unauthorized delegated signer injection, and malformed signature sets. ## Response ### Success Response (200 OK) When validation passes, pending metadata is stored in Redis under keys like `{ROLE}_SIGNING_{admin}_{app}` and a signing task state is created: ```json { "data": { "task_id": "8d6d0dc1-7e8b-4ad9-a6d8-0f6e857d5f7b", "last_update": "2026-05-14T16:31:22.512345Z" }, "message": "Metadata rotation staged for signing" } ``` ## Step-by-Step Flow with `/metadata/sign` Use this safe sequence for delegated key rotation: 1. **Prepare updated delegator metadata (`targets` by default)** with new delegated keyids in `delegations`. 2. **Stage delegator metadata** (and optionally delegated metadata) via `POST /tuf/v1/metadata/delegated/rotate`. 3. **Collect required signatures offline** and submit one-by-one through `POST /tuf/v1/metadata/sign?appName=`. 4. **Repeat sign calls** until each staged metadata object reaches threshold. 5. **Finalize through existing pipeline**, which updates snapshot and timestamp consistently. 6. **Monitor task completion** via [Check Task](/docs/api/tuf/check_tasks) using returned `task_id`. Recommended order for safer rotation: 1. Stage and sign updated `targets` that authorizes new delegated keys. 2. Stage and sign delegated role metadata using those new keys. ## Troubleshooting ### Unauthorized keyid - Confirm signer `keyid` is listed in the role's authorized keyids. - Recompute keyid from key material and verify exact match. - Ensure signatures are produced from the correct private key for that keyid. ### No delegation found - Check that `delegator` is correct (default is `targets`). - Verify `delegator.signed.delegations.roles` contains the delegated `role`. - If authorization changed, stage updated delegator metadata first. ### Version not monotonic - Compare with currently trusted delegated/delegator metadata versions. - Increase `signed.version` so it is strictly greater than trusted. - Do not reuse stale metadata blobs from previous drafts. ### Expired metadata - Ensure `signed.expires` is in the future at request processing time. - Use sufficient validity window to avoid expiry during offline signing delay. --- ## Add Signature to Root Metadata Add **one** signature to root metadata that is already stored in Redis (e.g. after [Metadata rotation](/docs/api/tuf/metadata_rotation) with empty or incomplete `signatures`). The server appends the signature to the list, checks the root role threshold; when the threshold is reached, root (and targets) are finalized, the Redis key is removed, and the updated metadata is published to S3. Use this endpoint when you have sent a root via `POST /tuf/v1/metadata` with no or partial signatures. The server then holds a draft under a key such as `ROOT_SIGNING__` in Redis. **Each request adds a single key's signature**; call the endpoint in sequence (e.g. once per root key holder) until the root threshold is met. :::info Example: threshold 2 with key rotation If the root role has `threshold: 2` and you are rotating from two old keys to two new keys, you must submit **four** signatures in total: two from the old keyids and two from the new keyids. Send one signature per request; the error response tells you which keyids are still missing (old vs new). ::: ## Endpoint ``` POST /tuf/v1/metadata/sign?appName= ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose root metadata is being signed | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `role` | string | ✅ | Role being signed; use `"root"` for root metadata | | `signature` | object | ✅ | A single signature: `keyid` and `sig` (hex) from one root key | | `signature.keyid` | string | ✅ | Key ID of the signer (hex) | | `signature.sig` | string | ✅ | Signature bytes in hex | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/metadata/sign?appName=' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "role": "root", "signature": { "keyid": "18aa7db1a150ab12b7fcd143d782afec2ba2600d654d352557b9d048a0d7b6b0", "sig": "110df00870d2b87f40c4422183d5edc753ac66eb7d6ba692121888f4b43d975adaf4a5831170c90ab4990796a1c37b4b5afd3a042bccae85de20270fcadd9c09" } }' ``` ## Response ### Success Response (200 OK) When the threshold is reached after adding this signature, the server finalizes the root, removes the draft from Redis, and publishes to S3: ```json { "data": { "task_id": "4ff21bf0-982b-4248-9282-31dcf04b358d", "last_update": "2026-02-05T14:00:12.238659+02:00" }, "message": "Metadata update finished" } ``` ### Error Response (400 Bad Request) When the signature is invalid or the threshold is not yet reached, the server returns progress and lists which keyids still need to sign: **Example (1st request — 1 of 4 collected):** ```json { "error": "Invalid signature or threshold not reached: unsigned metadata error: Verifying root failed, not enough signatures, got 1, want 2 Progress: 1/4 signatures collected (0 old + 1 new). 3 more required (2 old + 1 new). New keys signed: [18aa7db1a150ab12b7fcd143d782afec2ba2600d654d352557b9d048a0d7b6b0]. Missing old keys: [968352800a111846dad3f17a9371eb09a273ddef7c774252801be50cfda5520d 9faebed624cd1be5521cc9b5c4285afb441a1d042053c78229da2ef8336a33f0]. Missing new keys: [42d7f85d3981872a7b687a3c11a8d6adc856b9048242784cfeeae88ec4bf69a5].", "message": "Signature Failed" } ``` **Example (3rd request — 3 of 4 collected):** ```json { "error": "Invalid signature or threshold not reached: unsigned metadata error: Verifying root failed, not enough signatures, got 1, want 2 Progress: 3/4 signatures collected (1 old + 2 new). 1 more required (1 old + 0 new). Old keys signed: [968352800a111846dad3f17a9371eb09a273ddef7c774252801be50cfda5520d]. New keys signed: [18aa7db1a150ab12b7fcd143d782afec2ba2600d654d352557b9d048a0d7b6b0 42d7f85d3981872a7b687a3c11a8d6adc856b9048242784cfeeae88ec4bf69a5]. Missing old keys: [9faebed624cd1be5521cc9b5c4285afb441a1d042053c78229da2ef8336a33f0].", "message": "Signature Failed" } ``` The `error` field includes: - **Progress:** `X/Y signatures collected (A old + B new)` — how many signatures have been submitted and how many old vs new keyids have signed. - **Missing old keys:** keyids from the previous root that have not yet signed. - **Missing new keys:** keyids from the new root that have not yet signed. Use this to know which key must sign next until the threshold is satisfied. ### Response Fields (200 OK) | Field | Type | Description | |-------|------|-------------| | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify publication completed | | `data.last_update` | string | ISO8601 timestamp when the metadata was finalized | | `message` | string | `"Metadata update finished"` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Use this endpoint **only after** [Metadata rotation](/docs/api/tuf/metadata_rotation) with incomplete signatures; the server must have a draft in Redis under `ROOT_SIGNING__`. - **One signature per request.** Repeat the call with different keyids until the root threshold is met. The 400 response lists missing old and new keyids to guide the next signer. - When the threshold is reached, the server finalizes root (and targets), deletes the Redis draft, and publishes to S3; you receive `200 OK` and can use `task_id` with [Check Task](/docs/api/tuf/check_tasks) to confirm. - Signatures must be produced offline (or in your signing pipeline) with the private keys corresponding to the keyids in the root metadata. --- ## Publish Artifacts Sign all artifacts for a given application version with TUF metadata and publish the updated metadata to S3. After this endpoint runs, client applications that use TUF verification can trust and download those artifacts. You only need to pass the **app ID** and **version**; the API signs every artifact that belongs to that version (all channels, platforms, and architectures). The operation runs in the background and returns a `task_id`—use [Check Task](/docs/api/tuf/check_tasks) to poll for completion. :::important Important: TUF publish vs FaynoSync publish This endpoint is **not** related to the "publish" parameter or behavior of the regular FaynoSync app/version API. If you publish a version or upload artifacts using the standard FaynoSync API only, **client applications with TUF integration will not be able to download those new versions** until the artifacts are signed via this route. You **can** sign artifacts with this endpoint **before** the version is published (made available) via the normal FaynoSync API. Sign first, then publish the version when ready. ::: ## Endpoint ``` POST /tuf/v1/artifacts/publish ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `app_id` | string | ✅ | The unique identifier of the application | | `version` | string | ✅ | The version number whose artifacts should be signed (e.g. `0.0.0.5`) | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/artifacts/publish' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "app_id": "69259667351f660c2fd62d39", "version": "0.0.0.5" }' ``` ## Response ### Publishing Started (202 Accepted) When there are artifacts to sign, publishing is started in the background: ```json { "data": { "app_id": "69259667351f660c2fd62d39", "artifacts": [ "-////-" ], "last_update": "2026-02-04T16:59:30.428313+02:00", "task_id": "28dbd3d4-a3f9-40a8-8e0b-94ad0437e36d", "version": "0.0.0.5" }, "message": "Artifact(s) publishing started" } ``` ### Nothing to Sign (200 OK) When every signable artifact of this version is already signed with TUF metadata: ```json { "data": { "app_id": "69259667351f660c2fd62d39", "artifacts": [], "version": "0.0.0.5" }, "message": "Nothing to sign: all signable artifacts are already signed" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.app_id` | string | Application ID from the request | | `data.artifacts` | array | List of artifact paths (e.g. `-////-`); empty when nothing needed signing | | `data.last_update` | string | ISO8601 timestamp when publishing was started (only when publishing started) | | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify completion (only when publishing started) | | `data.version` | string | Version from the request | | `message` | string | `"Artifact(s) publishing started"` or `"Nothing to sign: all signable artifacts are already signed"` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - This endpoint **signs existing artifacts** with TUF metadata and **uploads updated metadata to S3**; it does not upload the artifact files themselves. Artifacts must already exist (uploaded via the normal FaynoSync API). - TUF-enabled clients will only trust and download artifacts that have been signed via this route. Publishing a version through the standard FaynoSync API alone is not enough for TUF clients. - You may sign artifacts **before** making the version publicly available (publish) in FaynoSync. - When `task_id` is present, poll [Check Task](/docs/api/tuf/check_tasks) until the task completes to ensure metadata has been fully published. - Updater feed files (`releases.{channel}.json`, `appcast*.xml`, `RELEASES`, `*.yml`) are not TUF targets and are never signed. - Artifacts uploaded through [presigned uploads](/docs/presigned-uploads) carry hashes supplied by the uploader. Before signing, publish verifies them against the stored bytes — reading the SHA-256 computed by storage on Garage and AWS S3, or streaming the object once on DigitalOcean Spaces and GCS. If any artifact does not match, no artifact of the version is signed and the task fails with the list of mismatching objects. See [TUF: hashes are verified before signing](/docs/presigned-uploads#tuf-hashes-are-verified-before-signing). --- ## Put Config Update the TUF repository configuration for an application. Only a subset of settings can be changed via this endpoint: the **expiration** values (in days) for the **targets**, **snapshot**, and **timestamp** roles. Other config values (e.g. root expiration, key counts, thresholds) are set at bootstrap and are not updated here. The update runs in the background. Use the returned `task_id` with [Check Task](/docs/api/tuf/check_tasks) to verify completion. To read the current config, use [Get Config](/docs/api/tuf/get_config). ## Endpoint ``` PUT /tuf/v1/config?appName= ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `appName` | string | ✅ | Name of the application whose TUF config to update | ## Request Body Only the following fields are updatable. All values are expiration periods in **days**. | Field | Type | Required | Description | |-------|------|----------|-------------| | `settings.expiration.targets` | number | ❌ | Targets metadata expiration in days | | `settings.expiration.snapshot` | number | ❌ | Snapshot metadata expiration in days | | `settings.expiration.timestamp` | number | ❌ | Timestamp metadata expiration in days | Include only the expiration values you want to change. Omitted fields keep their current values. ## Example Request ```bash curl --location --request PUT 'http://localhost:9000/tuf/v1/config?appName=' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "settings": { "expiration": { "targets": 100, "snapshot": 50, "timestamp": 20 } } }' ``` ## Response ### Success Response (202 Accepted) When the config update is accepted, it is applied in the background: ```json { "data": { "task_id": "25626a1a-ddf8-4647-b70b-292d426f960d", "last_update": "2026-02-04T18:48:48.128053+02:00" }, "message": "Settings successfully submitted." } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.task_id` | string | UUID of the background task; use [Check Task](/docs/api/tuf/check_tasks) to verify completion | | `data.last_update` | string | ISO8601 timestamp when the update was submitted | | `message` | string | `"Settings successfully submitted."` | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Only **expiration** for **targets**, **snapshot**, and **timestamp** can be updated. Root expiration, key counts, and thresholds are fixed at bootstrap. - The repository must already be bootstrapped for this app; otherwise the config cannot be updated. - When `task_id` is present, poll [Check Task](/docs/api/tuf/check_tasks) until the task completes to ensure the new settings are applied. - Use [Get Config](/docs/api/tuf/get_config) to read the current config before or after updating. --- ## Bootstrap Recovery Start a background recovery process that rebuilds Redis bootstrap state for an application from already published TUF metadata. This endpoint is intended for disaster-recovery scenarios, for example when Redis data is lost and cannot be restored from backup. Since bootstrap-related state can be reconstructed from metadata stored in S3, the system can repopulate Redis and restore consistency for the selected app. ## Endpoint ``` POST /tuf/v1/bootstrap/recovery ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Request Body The body must be a JSON object describing which app should be recovered. | Field | Type | Required | Description | |-------|------|----------|-------------| | `appName` | string | ✅ | Application name to recover (for example `tuf`) | | `timeout` | integer | ✅ | Recovery operation timeout value | ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/bootstrap/recovery' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "appName": "tuf", "timeout": 2 }' ``` ## Response ### Recovery Accepted (202 Accepted) When recovery is required, the request is accepted and a background task starts: ```json { "data": { "last_update": "2026-04-27T11:50:42+03:00", "task_id": "0e0095ed-fe33-480b-8a65-f782211c3cd4" }, "message": "Bootstrap recovery accepted and started in background" } ``` ### Recovery Not Required (409 Conflict) When Redis state is already complete and consistent, no recovery task is started: ```json { "error": "Recovery not required: Redis state is already complete and consistent" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.last_update` | string | ISO8601 timestamp when recovery was accepted | | `data.task_id` | string | UUID of the background recovery task | | `message` | string | Confirmation that background recovery has started | | `error` | string | Explains why recovery was not started | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - Recovery runs asynchronously; use `task_id` with [Check Task](/docs/api/tuf/check_tasks) to track progress. - This operation rebuilds Redis state from existing published metadata; it does not publish new metadata by itself. - If recovery is not needed, the endpoint returns `409 Conflict` and leaves current state unchanged. --- ## Start Bootstrap Start the TUF bootstrap process for an application. Bootstrap initializes the TUF repository (root and role metadata, expiration settings, and delegations) for the given app. The operation runs in the background; use the returned `task_id` to check progress if needed. Call [Check Bootstrap](/docs/api/tuf/check_bootstrap) first to ensure bootstrap has not already been completed for this admin and app. ## Endpoint ``` POST /tuf/v1/bootstrap ``` ## Headers | Header | Value | |--------|-------| | `Content-Type` | `application/json` | | `Authorization` | `Bearer ` | ## Request Body The body must be a JSON object with TUF bootstrap payload: role settings, root metadata (signed), and the application name. | Field | Type | Required | Description | |-------|------|----------|-------------| | `appName` | string | ✅ | Name of the application to bootstrap | | `settings` | object | ✅ | Role and delegation settings (expiration for root, timestamp, snapshot, targets; delegations keys and roles with paths) | | `metadata` | object | ✅ | TUF metadata; must include signed `root` metadata with `signatures` and `signed` (keys, roles, version, spec_version, expires, etc.) | ### Generating the payload The bootstrap payload is TUF-specific and must be generated before calling this endpoint. You can: - **Use the FaynoSync admin panel** — it can generate a script that produces the required files and payload for your instance. - **Generate it manually** — follow [The Update Framework (TUF) documentation](https://theupdateframework.io/) and use TUF tooling to create root metadata, sign it, and build the `settings` and `metadata` structure expected by this API. The `settings.roles` object defines expiration (in days) for each role (`root`, `timestamp`, `snapshot`, `targets`) and `delegations` (keys and roles with keyids, threshold, and paths). The `metadata.root` object must contain `signatures` (array of keyid + sig) and `signed` (root content: keys, roles, version, spec_version, expires, consistent_snapshot). ## Example Request ```bash curl --location 'http://localhost:9000/tuf/v1/bootstrap' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer ' \ --data '{ "settings": { "roles": { "root": { "expiration": 364 }, "timestamp": { "expiration": 1 }, "snapshot": { "expiration": 6 }, "targets": { "expiration": 6 }, "delegations": { "keys": { "": { "keytype": "ed25519", "scheme": "ed25519", "keyval": { "public": "" } } }, "roles": [ { "name": "default", "terminating": false, "keyids": [""], "threshold": 1, "paths": ["/tuf/", "tuf-/", "electron-builder/tuf-/", "squirrel_windows/tuf-/"] } ] } } }, "metadata": { "root": { "signatures": [ { "keyid": "", "sig": "" } ], "signed": { "_type": "root", "version": 1, "spec_version": "1.0.31", "expires": "", "consistent_snapshot": true, "keys": { ... }, "roles": { "root": { "keyids": [...], "threshold": 2 }, "timestamp": { "keyids": [...], "threshold": 1 }, "snapshot": { "keyids": [...], "threshold": 1 }, "targets": { "keyids": [...], "threshold": 1 } } } } }, "appName": "" }' ``` Use the script or instructions from the FaynoSync admin panel to generate the full payload with real keys and signatures for your environment. ## Response ### Bootstrap Accepted (200 OK) When bootstrap has not been completed yet, the request is accepted and bootstrap runs in the background: ```json { "data": { "last_update": "2026-02-04T14:28:12+02:00", "task_id": "ecca335d-9192-4fc2-bbb0-3d7a38a55c84" }, "message": "Bootstrap accepted and started in background" } ``` ### Bootstrap Already Completed When bootstrap has already been completed for this admin and app, the API returns an error and the existing task info: ```json { "data": { "admin": "ku9n", "app": "tuf", "status": "completed", "task_id": "ecca335d-9192-4fc2-bbb0-3d7a38a55c84" }, "error": "Bootstrap already completed for this admin and app" } ``` ### Response Fields | Field | Type | Description | |-------|------|-------------| | `data.last_update` | string | ISO8601 timestamp of the last update (when bootstrap was accepted) | | `data.task_id` | string | UUID of the bootstrap task; use it to check task status if needed | | `data.admin` | string | Admin username (present when bootstrap was already completed) | | `data.app` | string | Application name (present when bootstrap was already completed) | | `data.status` | string | Task status, e.g. `"completed"` (present when bootstrap was already completed) | | `message` | string | `"Bootstrap accepted and started in background"` when the bootstrap was started | | `error` | string | `"Bootstrap already completed for this admin and app"` when bootstrap had already been done | ## Notes - Requires a valid JWT in the `Authorization` header (admin user). - The request payload must be generated externally (TUF tooling or the script from the FaynoSync admin panel); the API does not generate keys or sign root metadata for you. - Bootstrap runs asynchronously; use `task_id` with the task-status API if you need to wait for completion. - Paths in `delegations.roles[].paths` should match how your app and updaters (e.g. electron-builder, squirrel_windows) store TUF metadata (e.g. `/tuf/`, `tuf-/`). --- ## TUF (The Update Framework) Management **go-tuf** is an implementation of [The Update Framework](https://theupdateframework.io/) (TUF), a security-focused standard for securing software update systems. It helps prevent compromise of the update process by ensuring that clients can verify the authenticity and integrity of updates before installing them. This section describes how to use TUF with **FaynoSync**: enabling it on your instance, managing keys and metadata, and how it fits into your update workflow. In FaynoSync, TUF metadata and related files on S3 are **organized separately per administrator and per application name**. Each app has its own TUF repository, so keys and metadata are isolated by account and app. ## Key storage principles - **Private keys** must be kept secret and must never be published or exposed. - **Root keys** should be stored on an **offline system**. They are used to establish trust and sign other keys; keeping them offline reduces the risk of compromise. - **Keys for the targets, snapshot, and timestamp roles** must be stored in the directory specified by the `ONLINE_KEY_DIR` environment variable, for example: - `ONLINE_KEY_DIR=~/tuf_keys/private_keys` ## Enabling go-tuf on FaynoSync To enable TUF support on your FaynoSync instance, set: - `TUF_ENABLED=true` - `ONLINE_KEY_DIR=~/tuf_keys/private_keys` (or your chosen path for online signing keys) Public metadata files are stored on S3 and are **publicly accessible**. They are signed with the private keys above; clients use these signed metadata files to verify that an update is trusted and that the build they download is the one intended by the publisher. ### Metadata caching `timestamp.json` is the only metadata file that faynoSync overwrites under a fixed name, and it is the first file a TUF client fetches. faynoSync writes it with `Cache-Control: public, max-age=60, must-revalidate`, so clients and any CDN in front of the bucket see a new publish within about a minute. Versioned files (`N.root.json`, `N.snapshot.json`, `N.targets.json` and delegated roles) never change once written and keep the storage default, which lets them be cached for as long as the provider allows. Caching cannot weaken verification: a client never accepts a timestamp older than the one it already trusts, and signatures and expiration are checked on every file. A cached `timestamp.json` only delays when clients notice a new release. If you put a CDN in front of the metadata, do not cache `timestamp.json` longer than its `Cache-Control` allows. ## Client-side configuration TUF verification happens on the **client application**. Your app must be configured to consume TUF metadata and verify updates before applying them. Refer to the [TUF documentation](https://theupdateframework.io/) and the go-tuf client usage to learn how to configure your application for secure update verification. The following API endpoints allow you to manage TUF bootstrap, metadata, configuration, and publishing within FaynoSync. ## TUF endpoints - [Check Bootstrap](/docs/api/tuf/check_bootstrap) - [Start Bootstrap](/docs/api/tuf/start_bootstrap) - [Check Task](/docs/api/tuf/check_tasks) - [Publish Artifacts](/docs/api/tuf/publish) - [Delete Artifact (TUF)](/docs/api/tuf/delete_artifact) - [Get Config](/docs/api/tuf/get_config) - [Put Config](/docs/api/tuf/put_config) - [Metadata Rotation](/docs/api/tuf/metadata_rotation) - [Get Unsigned Root Metadata](/docs/api/tuf/get_unsigned_root_metadata) - [Add Signature to Root Metadata](/docs/api/tuf/post_unsigned_root_metadata) - [Stage Delegated Key Rotation Metadata](/docs/api/tuf/post_stage_targets_delegated_metadata) - [Delete Unsigned Metadata](/docs/api/tuf/delete_unsigned_metadata) - [Force Metadata Update](/docs/api/tuf/force_metadata_update) - [Get Current Root Metadata](/docs/api/tuf/get_current_root_metadata) - [Get Current Targets Metadata](/docs/api/tuf/get_current_targets_metadata) - [Get Current Delegated Metadata](/docs/api/tuf/get_current_delegated_metadata) - [Recovery](/docs/api/tuf/recovery)