Kaynağa Gözat

Merge branch 'goal/dcba' of clanker/usm-web into web-stack

clanker 1 ay önce
ebeveyn
işleme
7fd02c1d94

+ 72 - 23
README.md

@@ -4,25 +4,31 @@ A Statum + Spry application that browses **and serves** any number of USM
 repositories: human-facing pages for exploring packages (an all-repository
 overview plus per-repository browse views), plus the exact HTTP endpoints a
 real USM client (`usm repository list` / `verify` / `install`) expects to
-fetch from a repository host.
+fetch from a repository host. Alongside USM source repositories it also
+serves **DCBA artifact repositories** — repositories of pre-built build
+artifacts (`.dcba`) that `usm install` fetches instead of building a package
+from source (`usm dcba add-repo` / `usm dcba repository list`).
 
 ## What it serves
 
 | Endpoint | Purpose |
 |---|---|
-| `GET /` | Overview: every repository (name, summary, package count, total size), per-repo add-repository instructions (`sudo usm add-repo <repo.usmr URL>`), the global install-USM block, and a combined package table with repo badges + server-side search across all repositories |
-| `GET /repo/{name}` | Single-repo browse: that repository's overview card, instructions and package table, search scoped to it |
-| `GET /repo/{name}/package/{file}` | Package detail: manifest fields read out of the `.usmc` (licences, flags, provides/depends tables), sha512, on-disk size/mtime, download button |
-| `GET /repo/{name}/PACKAGES.usml` | That repository's listing streamed **verbatim** (`application/json`) |
+| `GET /` | Overview: every repository of both kinds as a card (name, summary, chip, kind-appropriate counts and `sudo usm add-repo` / `sudo usm dcba add-repo` instructions), the global install-USM block, and a **source-package-only** combined table (usmc rows from USM repositories, repo badges) with server-side search across them — DCBA repositories appear as cards, never as package rows |
+| `GET /repo/{name}` | Single-repo browse: that repository's overview card and instructions. A USM repository renders its package table with search scoped to it; a DCBA repository renders an artifacts table (name, version, size, download) instead — search stays source-package-only |
+| `GET /repo/{name}/package/{file}` | Package detail: manifest fields read out of the `.usmc` (licences, flags, provides/depends tables), sha512, on-disk size/mtime, download button, plus a "Pre-built artifacts" section listing every DCBA repository carrying an artifact for this exact name + version |
+| `GET /repo/{name}/artifact/{file}` | Artifact detail (DCBA repositories): listing-derived name/version, sha512, on-disk size/mtime, download button, the `usm dcba add-repo` instruction and — on an exact name+version match — a one-directional link to the source package's detail page |
+| `GET /repo/{name}/PACKAGES.usml` | That USM repository's listing streamed **verbatim** (`application/json`); a DCBA repository has none and answers 404 |
+| `GET /repo/{name}/ARTIFACTS.usml` | That DCBA repository's artifact listing streamed **verbatim** (`application/json`) so signatures and checksums stay valid; a USM repository answers 404 |
 | `GET /repo/{name}/repo.usmr` | A rewritten copy of the `.usmr` with its embedded `url` replaced by the derived base + `repo/{name}/` — the URL `sudo usm add-repo` consumes |
-| `GET /repo/{name}/{file}.usmc` | Package archives streamed from that repository's `public/` (`application/octet-stream`, exact content-length) |
+| `GET /repo/{name}/repo.dcbar` | The rewritten DCBA counterpart: a copy of the `.dcbar` with its `url` replaced by the derived base + `repo/{name}/` — the URL `sudo usm dcba add-repo` consumes (the route name is canonical regardless of the on-disk descriptor's filename) |
+| `GET /repo/{name}/{file}.usmc` / `GET /repo/{name}/{file}.dcba` | Package archives / pre-built artifacts streamed from that repository's `public/` (`application/octet-stream`, exact content-length); the suffix selects the repository kind, so the wrong kind (or a listing the other kind owns) answers 404 |
 | `GET /install-usm.sh` | The configured installer script (`installer.path` mode only), served by the root-level catch-all |
 
 The derived base is `base_url` from the config when set, else
 `X-Forwarded-Proto`/`X-Forwarded-Host`, else the request's own scheme/host —
 so clients behind a reverse proxy resolve against the public name. Each
-repository is addressed by the `name` in its `.usmr`, which is also its
-routing segment.
+repository is addressed by the `name` in its `.usmr` or `.dcbar`, which is
+also its routing segment.
 
 ## Configuration
 
@@ -44,8 +50,8 @@ carries the static Statum keys plus an optional `"usm-web"` section:
 
 | Key | Meaning |
 |---|---|
-| `repo` | The parent directory holding the repositories (each a subdirectory with a `.usmr` and `public/`); relative paths resolve against the config file's directory |
-| `repositories` | Optional explicit list of repository subdirectories to serve (in that order); when absent or empty every subdirectory containing a `.usmr` is discovered by scanning |
+| `repo` | The parent directory holding the repositories (each a subdirectory with a `.usmr` **or** a `.dcbar` plus `public/`); relative paths resolve against the config file's directory |
+| `repositories` | Optional explicit list of repository subdirectories to serve, of either kind, in that order; when absent or empty every subdirectory containing a `.usmr` or a `.dcbar` is discovered by scanning |
 | `installer.url` | External installer location; the homepage links out and shows `curl -fsSL <url> \| sh` |
 | `installer.path` | Installer script this app serves at `/install-usm.sh`; relative paths resolve against the config file's directory |
 | `base_url` | Optional override of the request-derived repository base (normalised to a trailing slash) |
@@ -67,17 +73,60 @@ the `USM_WEB_REPO_DIR` environment variable, the config's `repo` key, or the
 │   └── public/
 │       ├── PACKAGES.usml
 │       └── <name>-<version>.usmc
+├── prebuilt/
+│   ├── prebuilt.dcbar   (same JSON shape as a .usmr)
+│   ├── keys/
+│   └── public/
+│       ├── ARTIFACTS.usml
+│       └── <name>-<version>.dcba
 └── extra/
     └── …
 ```
 
+USM source repositories and DCBA artifact repositories live side by side
+under the same parent — there is no separate configuration for the second
+kind. A subdirectory is classified by its descriptor: `.usmr` makes it a
+USM source repository (`.usmc` packages in `public/PACKAGES.usml`), `.dcbar`
+a DCBA artifact repository (`.dcba` artifacts in `public/ARTIFACTS.usml`).
+A directory holding **both** descriptors is a configuration error; split it
+into two subdirectories instead.
+
 A legacy single-repository layout with the `.usmr` directly inside the
-configured directory is still served as one repository. Discovery re-runs on
+configured directory is still served as one repository (this fallback stays
+`.usmr`-only; a bare `.dcbar` in the parent is not discovered). Discovery re-runs on
 every request, so repositories added or removed on disk are picked up without
-a restart, and each repository's `.usmr` and `PACKAGES.usml` re-read when
-their mtimes change — one repository can be rebuilt without disturbing the
-others. There is **no database and no authentication**; all Statum state is
-stateless PAGE slots.
+a restart, and each repository's descriptor (`.usmr`/`.dcbar`) and listing
+(`PACKAGES.usml`/`ARTIFACTS.usml`) re-read when their own mtimes change —
+one repository can be rebuilt without disturbing the others. There is **no
+database and no authentication**; all Statum state is stateless PAGE slots.
+
+### DCBA repositories
+
+A DCBA repository distributes **pre-built build artifacts**: one `.dcba`
+archive per package name + version (built and published upstream with
+`usm dcba repository init` / `add` / `publish`), described by a `.dcbar`
+(the same JSON shape as a `.usmr`) and listed in a signed
+`public/ARTIFACTS.usml`. With such a repository configured, `usm install`
+fetches the artifact instead of building the package from source.
+
+Two things differ from the usmc flow when pointing a client at one:
+
+- the verb carries the `dcba` subcommand: `sudo usm dcba add-repo <url>`
+  (not `sudo usm add-repo`), where `<url>` is the served
+  `.../repo/<name>/repo.dcbar` rewrite;
+- clients match artifacts by **plain name + version string comparison**, so
+  the package detail pages surface pre-built artifacts only when a DCBA
+  repository carries that exact version — a newer or older artifact version
+  never shows up as "for" that package.
+
+The views keep the two kinds apart: the home page lists DCBA repositories
+as cards (with artifact counts and their own add-repo instruction) but the
+package table, the Packages/Total-size hero counts and search stay
+usmc-only; a DCBA repository's browse page shows its artifacts table, and
+each artifact has a detail page with its sha512 and a one-directional link
+to the matching source package (a package page then lists every DCBA
+repository carrying its pre-built artifacts, so the pair is discoverable
+from both sides).
 
 ## Development
 
@@ -107,8 +156,8 @@ ninja -C builddir
 
 `--repo` points at the parent directory holding the repositories (see the
 layout above). Repo parsing uses libusm (`Usm.Repository`,
-`Usm.RepositoryListing`, `Usm.Manifest.from_package`) — no hand-rolled
-usml/usmr mappers.
+`Usm.RepositoryListing`, `Usm.DcbaListing`, `Usm.Manifest.from_package`) —
+no hand-rolled usml/usmr/dcbar mappers.
 
 ## Layout
 
@@ -116,13 +165,13 @@ usml/usmr mappers.
 |---|---|
 | `src/main.vala` | application wiring: modules, config, pages, action, endpoints |
 | `src/UsmWebConfig.vala` | the `"usm-web"` config section (repo parent, repositories, installer url\|path, base_url, name) |
-| `src/RepositoryService.vala` | multi-repository discovery + libusm-backed read side with per-repo mtime reload |
+| `src/RepositoryService.vala` | multi-repository discovery (`.usmr` and `.dcbar` subdirectories, kind-guarded members) + libusm-backed read side with per-repo mtime reload |
 | `src/DerivedBase.vala` | base-URI derivation from config/forwarded headers |
-| `src/PackagesState.vala` | shared `packages` slot-state builder (overview + repo-scoped) and size/fingerprint formatting |
-| `src/entrypoints/` | `HomeEntrypoint` (/), `RepoBrowseEntrypoint` (/repo/{name}) and `PackageDetailEntrypoint` (/repo/{name}/package/{file}) |
+| `src/PackagesState.vala` | shared `packages` slot-state builder (overview + repo-scoped, both repository kinds, DCBA artifact rows) and size/fingerprint formatting |
+| `src/entrypoints/` | `HomeEntrypoint` (/), `RepoBrowseEntrypoint` (/repo/{name}), `PackageDetailEntrypoint` (/repo/{name}/package/{file}) and `ArtifactDetailEntrypoint` (/repo/{name}/artifact/{file}) |
 | `src/actions/` | `SearchPackagesAction` — server-side table filtering (all repositories or one) |
-| `src/endpoints/` | the USM-facing endpoints listed above |
-| `src/pages/*.html` | Statum pages, compiled by `statum-mkpstm` at build time |
+| `src/endpoints/` | the USM/DCBA-facing endpoints listed above (`PackagesListingEndpoint`, `RepositoryDefinitionEndpoint`, `ArtifactsListingEndpoint`, `DcbaDefinitionEndpoint`, `PackageDownloadEndpoint`, `InstallerScriptEndpoint`) |
+| `src/pages/*.html` | Statum pages (`home`, `repo`, `package`, `artifact`), compiled by `statum-mkpstm` at build time |
 | `src/Static/main.css` | stylesheet embedded by `statum-mkres` |
 | `MANIFEST.usm` | USM package manifest: `provides bin:usm-web`, build/runtime/manage deps |
 | `usm-scripts/` | USM build + install scripts (meson staging) |
@@ -174,4 +223,4 @@ podman run -d -p 8080:8080 \
 ```
 
 `/repo` inside the container is the repositories **parent** directory: every
-subdirectory holding a `.usmr` is served at `/repo/<name>/…`.
+subdirectory holding a `.usmr` or a `.dcbar` is served at `/repo/<name>/…`.

+ 10 - 0
meson.build

@@ -49,6 +49,12 @@ package_detail_page = custom_target('package-detail-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'PackageDetailPage', '--ns', 'UsmWeb', '@INPUT@'],
     depend_files: files('src/pages/main.html')
 )
+artifact_detail_page = custom_target('artifact-detail-page',
+    input: 'src/pages/artifact.html',
+    output: 'ArtifactDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ArtifactDetailPage', '--ns', 'UsmWeb', '@INPUT@'],
+    depend_files: files('src/pages/main.html')
+)
 # spry:pages-end
 
 app_sources = files(
@@ -61,9 +67,12 @@ app_sources = files(
     'src/entrypoints/HomeEntrypoint.vala',
     'src/entrypoints/RepoBrowseEntrypoint.vala',
     'src/entrypoints/PackageDetailEntrypoint.vala',
+    'src/entrypoints/ArtifactDetailEntrypoint.vala',
     'src/actions/SearchPackagesAction.vala',
     'src/endpoints/PackagesListingEndpoint.vala',
+    'src/endpoints/ArtifactsListingEndpoint.vala',
     'src/endpoints/RepositoryDefinitionEndpoint.vala',
+    'src/endpoints/DcbaDefinitionEndpoint.vala',
     'src/endpoints/PackageDownloadEndpoint.vala',
     'src/endpoints/InstallerScriptEndpoint.vala',
     # spry:sources-end
@@ -75,6 +84,7 @@ app_generated = [
     home_page,
     repo_browse_page,
     package_detail_page,
+    artifact_detail_page,
     # spry:generated-end
 ]
 

+ 106 - 0
src/PackagesState.vala

@@ -16,14 +16,42 @@ namespace UsmWeb {
         public string repo { get; set; default = ""; }
     }
 
+    /**
+     * One artifact row in a DCBA repository's browse table: the listing
+     * entry's name/version plus the page links the row renders — the
+     * detail page ({@link href}) and the raw `.dcba` download
+     * ({@link download_href}).
+     */
+    public class ArtifactRow : Object {
+        public string name { get; set; default = ""; }
+        public string version { get; set; default = ""; }
+        public string size { get; set; default = ""; }
+        public string file { get; set; default = ""; }
+        public string href { get; set; default = ""; }
+        public string download_href { get; set; default = ""; }
+    }
+
     /**
      * One repository summary in the overview: the stats card fields plus the
      * per-repository add-repository instruction fields ({@link base_uri},
      * {@link usmr_href}, {@link repos_d_snippet}).
+     *
+     * Both repository kinds render a card; {@link kind} (the
+     * {@link RepositoryKind.to_string} wire name) picks which instruction
+     * block and stats the card shows — a USM card carries its package count
+     * and byte total, a DCBA card its artifact count and artifact byte
+     * total — that total sums the listing's `size` fields, so artifacts
+     * from listings written before the field existed (size 0) contribute
+     * nothing while still counting toward {@link artifact_count}. The
+     * `*_href`/`*_snippet` members are named for the USM card;
+     * a DCBA summary fills them with the `repo.dcbar` / `usm dcba
+     * add-repo` counterparts so the card markup stays one block with a
+     * kind branch.
      */
     public class RepoSummary : Object {
         public string name { get; set; default = ""; }
         public string summary { get; set; default = ""; }
+        public string kind { get; set; default = "usm"; }
         public int package_count { get; set; }
         public string total_size { get; set; default = ""; }
         public string href { get; set; default = ""; }
@@ -31,6 +59,8 @@ namespace UsmWeb {
         public string base_uri { get; set; default = ""; }
         public string usmr_href { get; set; default = ""; }
         public string repos_d_snippet { get; set; default = ""; }
+        public int artifact_count { get; set; }
+        public string artifact_total_size { get; set; default = ""; }
     }
 
     /**
@@ -51,11 +81,29 @@ namespace UsmWeb {
          * per-repository add instructions, and one combined table). Rows are
          * filtered by the case-insensitive `query` substring over name and
          * summary (an empty query keeps every entry).
+         *
+         * The overview lists repositories of **both** kinds as summary
+         * cards, in discovery order, but only the USM source repositories
+         * feed the package table and the package counts and byte totals —
+         * a DCBA entry contributes a card (its artifact count and artifact
+         * byte total from the `ARTIFACTS.usml` sizes) and nothing else, so
+         * pre-built artifacts surface in the detail views, never in the
+         * home package table.
+         *
+         * A repo-scoped build for a DCBA repository renders that
+         * repository's browse view instead: the same overview-card
+         * fields (carrying its artifact count and artifact byte total),
+         * the `usm dcba add-repo` instruction and one
+         * {@link ArtifactRow} per `ARTIFACTS.usml` entry. The query
+         * never filters artifacts (search is source-package-only), so
+         * those rows ignore `query` entirely and the page omits the
+         * search form on the DCBA branch.
          */
         public static State build(RepositoryService repositories, UsmWebConfig config,
                 ActionRegistry action_registry, string base_uri, string query, string? repo_name = null) throws Error {
             var repo_rows = new Vector<RepoSummary>();
             var package_rows = new Vector<PackageRow>();
+            var dcba_artifact_rows = new Vector<ArtifactRow>();
             int64 total_bytes = 0;
             uint total_count = 0;
             uint matched = 0;
@@ -66,6 +114,50 @@ namespace UsmWeb {
                     continue;
                 }
 
+                if (entry.kind == RepositoryKind.DCBA) {
+                    int64 artifact_bytes = 0;
+                    uint artifact_count = 0;
+                    var artifact_rows = new Vector<ArtifactRow>();
+                    if (repo_name != null) {
+                        foreach (var artifact_entry in entry.dcba_listing_entries()) {
+                            var file = Path.get_basename(artifact_entry.path);
+                            artifact_bytes += (int64) artifact_entry.size;
+                            artifact_count++;
+                            artifact_rows.add(new ArtifactRow() {
+                                name = artifact_entry.name,
+                                version = artifact_entry.version,
+                                size = artifact_entry.size > 0
+                                    ? format_size((int64) artifact_entry.size)
+                                    : "?",
+                                file = file,
+                                href = "/repo/" + repo.name + "/artifact/" + file,
+                                download_href = "/repo/" + repo.name + "/" + file
+                            });
+                        }
+                        dcba_artifact_rows.add_all(artifact_rows);
+                    } else {
+                        foreach (var artifact_entry in entry.dcba_listing_entries()) {
+                            artifact_bytes += (int64) artifact_entry.size;
+                            artifact_count++;
+                        }
+                    }
+
+                    repo_rows.add(new RepoSummary() {
+                        name = repo.name,
+                        summary = repo.summary,
+                        kind = RepositoryKind.DCBA.to_string(),
+                        artifact_count = (int) artifact_count,
+                        artifact_total_size = format_size(artifact_bytes),
+                        href = "/repo/" + repo.name,
+                        key_fingerprint = fingerprint(repo.key.to_hex()),
+                        base_uri = base_uri + "repo/" + repo.name + "/",
+                        usmr_href = base_uri + "repo/" + repo.name + "/repo.dcbar",
+                        repos_d_snippet = "sudo usm dcba add-repo %srepo/%s/repo.dcbar"
+                            .printf(base_uri, repo.name)
+                    });
+                    continue;
+                }
+
                 int64 repo_bytes = 0;
                 uint repo_count = 0;
                 var rows = new Vector<PackageRow>();
@@ -93,6 +185,7 @@ namespace UsmWeb {
                 repo_rows.add(new RepoSummary() {
                     name = repo.name,
                     summary = repo.summary,
+                    kind = RepositoryKind.USM.to_string(),
                     package_count = (int) repo_count,
                     total_size = format_size(repo_bytes),
                     href = "/repo/" + repo.name,
@@ -121,13 +214,17 @@ namespace UsmWeb {
                 var summary = repo_rows.first();
                 dict.set_native<string>("repo_name", summary.name);
                 dict.set_native<string>("repo_summary", summary.summary);
+                dict.set_native<string>("repo_kind", summary.kind);
                 dict.set_native<string>("key_fingerprint", summary.key_fingerprint);
                 dict.set_native<int>("package_count", summary.package_count);
                 dict.set_native<string>("total_size", summary.total_size);
                 dict.set_native<string>("base_uri", summary.base_uri);
                 dict.set_native<string>("usmr_href", summary.usmr_href);
                 dict.set_native<string>("repos_d_snippet", summary.repos_d_snippet);
+                dict.set_native<int>("artifact_count", summary.artifact_count);
+                dict.set_native<string>("artifact_total_size", summary.artifact_total_size);
             }
+            dict["artifacts"] = artifact_list(dcba_artifact_rows);
             dict.set_native<string>("query", query);
             dict.set_native<string>("result_note", result_note(query, matched, total_count));
             dict.set_native<string>("installer_mode", config.installer.to_string());
@@ -162,6 +259,15 @@ namespace UsmWeb {
             return new JsonElement.from_elements(arr);
         }
 
+        /** Builds the artifact rows' JSON array (the same pattern as {@link list}). */
+        public static JsonElement artifact_list(Lot<ArtifactRow> rows) throws Error {
+            var arr = new JsonArray();
+            foreach (var row in rows) {
+                arr.add(new JsonElement.from_properties(GObjectMapping.to_properties(row)));
+            }
+            return new JsonElement.from_elements(arr);
+        }
+
         /** Builds the repository summaries' JSON array. */
         public static JsonElement repo_list(Lot<RepoSummary> rows) throws Error {
             var arr = new JsonArray();

+ 300 - 58
src/RepositoryService.vala

@@ -4,61 +4,121 @@ using Usm;
 
 namespace UsmWeb {
 
+    /** The kind of a discovered repository directory. */
+    public enum RepositoryKind {
+        /** A USM source repository: `.usmr` + `public/PACKAGES.usml` + `.usmc` packages. */
+        USM,
+        /** A DCBA artifact repository: `.dcbar` + `public/ARTIFACTS.usml` + `.dcba` artifacts. */
+        DCBA;
+
+        /** The wire/display name used on generated pages. */
+        public string to_string() {
+            switch (this) {
+                case RepositoryKind.USM:
+                    return "usm";
+                case RepositoryKind.DCBA:
+                    return "dcba";
+                default:
+                    assert_not_reached();
+            }
+        }
+    }
+
     /**
-     * One on-disk USM repository discovered under the configured parent
-     * directory: a subdirectory holding a `.usmr` beside a `public/`
-     * directory, built on libusm via {@link Usm.Repository.from_file} and
-     * {@link Usm.RepositoryListing.from_stream}.
+     * One on-disk repository discovered under the configured parent
+     * directory, built on libusm via {@link Usm.Repository.from_file}.
+     *
+     * A USM entry is a subdirectory holding a `.usmr` beside a `public/`
+     * directory with `PACKAGES.usml`; a DCBA entry is the artifact-repo
+     * counterpart holding a `.dcbar` (the same JSON shape) beside a
+     * `public/` directory with `ARTIFACTS.usml` and `.dcba` files. The
+     * two kinds share the loading machinery but expose disjoint members;
+     * reaching for a member of the other kind throws instead of
+     * silently answering nonsense.
      *
-     * Each entry tracks its `.usmr` and `public/PACKAGES.usml` mtimes
-     * independently, so one repository can be rebuilt and re-read without
-     * disturbing the others.
+     * Each entry tracks its descriptor (`.usmr`/`.dcbar`) and listing
+     * (`PACKAGES.usml`/`ARTIFACTS.usml`) mtimes independently, so one
+     * repository can be rebuilt and re-read without disturbing the
+     * others, and both file roles reload when their own mtime moves.
      */
     public class RepositoryEntry : Object {
 
         /** The repository's directory (absolute). */
         public string dir_path { get; private set; }
 
-        /** The absolute path of the `.usmr` inside {@link dir_path}. */
-        public string usmr_path { get; private set; }
+        /**
+         * The absolute path of the `.usmr` (USM entries) or `.dcbar`
+         * (DCBA entries) inside {@link dir_path}.
+         */
+        public string descriptor_path { get; private set; }
+
+        /** This repository's kind (source or artifact). */
+        public RepositoryKind kind { get; private set; }
+
+        /**
+         * Compatibility alias for {@link descriptor_path} — the endpoint
+         * serving `repo.usmr` reads the raw descriptor through this name.
+         */
+        public string usmr_path {
+            get { return descriptor_path; }
+        }
 
         private Repository? repository;
         private RepositoryListing? listing;
-        private int64 usmr_mtime;
+        private DcbaListing? dcba_listing;
+        private int64 descriptor_mtime;
         private int64 listing_mtime;
 
-        public RepositoryEntry(string dir_path, string usmr_path) {
+        public RepositoryEntry.usm(string dir_path, string usmr_path) {
             this.dir_path = dir_path;
-            this.usmr_path = usmr_path;
+            this.kind = RepositoryKind.USM;
+            this.descriptor_path = usmr_path;
         }
 
-        /** Whether both the `.usmr` and the listing are currently loaded. */
+        public RepositoryEntry.dcba(string dir_path, string dcbar_path) {
+            this.dir_path = dir_path;
+            this.kind = RepositoryKind.DCBA;
+            this.descriptor_path = dcbar_path;
+        }
+
+        /** Whether the descriptor and this kind's listing are currently loaded. */
         public bool is_loaded {
-            get { return repository != null && listing != null; }
+            get {
+                if (kind == RepositoryKind.DCBA) {
+                    return repository != null && dcba_listing != null;
+                }
+                return repository != null && listing != null;
+            }
         }
 
         /**
-         * Reloads the `.usmr` and listing when either file changed on disk
-         * (or was never loaded); a failure throws while keeping the
+         * Reloads the descriptor and listing when either file changed on
+         * disk (or was never loaded); a failure throws while keeping the
          * previously loaded snapshot, so an in-flight rebuild cannot lose
          * what is already held.
          */
         public void ensure_loaded() throws Error {
-            int64 current_usmr_mtime = file_mtime(usmr_path);
-            int64 current_listing_mtime = file_mtime(listing_location());
+            int64 current_descriptor_mtime = file_mtime(descriptor_path);
+            int64 current_listing_mtime = file_mtime(current_listing_path());
             if (is_loaded
-                    && current_usmr_mtime == usmr_mtime
+                    && current_descriptor_mtime == descriptor_mtime
                     && current_listing_mtime == listing_mtime) {
                 return;
             }
 
-            if (repository == null || current_usmr_mtime != usmr_mtime) {
-                repository = new Repository.from_file(usmr_path);
-                usmr_mtime = current_usmr_mtime;
+            if (repository == null || current_descriptor_mtime != descriptor_mtime) {
+                repository = new Repository.from_file(descriptor_path);
+                descriptor_mtime = current_descriptor_mtime;
             }
 
-            if (listing == null || current_listing_mtime != listing_mtime) {
-                var file = File.new_for_path(listing_location());
+            if (kind == RepositoryKind.DCBA) {
+                if (dcba_listing == null || current_listing_mtime != listing_mtime) {
+                    var file = File.new_for_path(current_listing_path());
+                    dcba_listing = new DcbaListing.from_stream(new DataInputStream(file.read()));
+                    listing_mtime = current_listing_mtime;
+                }
+            } else if (listing == null || current_listing_mtime != listing_mtime) {
+                var file = File.new_for_path(current_listing_path());
                 listing = new RepositoryListing.from_stream(new DataInputStream(file.read()));
                 listing_mtime = current_listing_mtime;
             }
@@ -70,13 +130,35 @@ namespace UsmWeb {
             return (!)repository;
         }
 
-        /** The loaded `PACKAGES.usml` entries, in listing order. */
+        /**
+         * The loaded `PACKAGES.usml` entries, in listing order. USM
+         * entries only — DCBA entries answer with
+         * {@link dcba_listing_entries}.
+         */
         public Enumerable<RepositoryListingEntry> listing_entries() throws Error {
+            if (kind == RepositoryKind.DCBA) {
+                throw_wrong_kind("PACKAGES.usml entries");
+            }
             ensure_loaded();
             return ((!)listing).entries;
         }
 
-        /** The listing entry whose package file is `file`, or null. */
+        /**
+         * The loaded `ARTIFACTS.usml` entries, in listing order. DCBA
+         * entries only — USM entries answer with {@link listing_entries}.
+         */
+        public Enumerable<DcbaListingEntry> dcba_listing_entries() throws Error {
+            if (kind != RepositoryKind.DCBA) {
+                throw_wrong_kind("ARTIFACTS.usml entries");
+            }
+            ensure_loaded();
+            return ((!)dcba_listing).entries;
+        }
+
+        /**
+         * The listing entry whose package file is `file`, or null. USM
+         * entries only.
+         */
         public RepositoryListingEntry? find_entry(string file) throws Error {
             foreach (var entry in listing_entries()) {
                 if (Path.get_basename(entry.path) == file) {
@@ -86,16 +168,102 @@ namespace UsmWeb {
             return null;
         }
 
-        /** The absolute path of the verbatim-served `PACKAGES.usml`. */
-        public string listing_location() {
-            return Path.build_filename(dir_path, "public", "PACKAGES.usml");
+        /**
+         * The artifact entry whose package name and version match `name`
+         * and `version` exactly (a plain string comparison on both, per
+         * the DCBA specification — listing strings never mutate through
+         * a {@link Usm.Version} round trip), or null when this repository
+         * carries no artifact for that package. DCBA entries only.
+         */
+        public DcbaListingEntry? find_dcba_artifact(string name, string version) throws Error {
+            foreach (var entry in dcba_listing_entries()) {
+                if (entry.name == name && entry.version == version) {
+                    return entry;
+                }
+            }
+            return null;
+        }
+
+        /**
+         * The absolute path of the verbatim-served `PACKAGES.usml`. USM
+         * entries only — DCBA entries answer with
+         * {@link artifacts_listing_location}.
+         */
+        public string listing_location() throws Error {
+            if (kind == RepositoryKind.DCBA) {
+                throw_wrong_kind("PACKAGES.usml location");
+            }
+            return Path.build_filename(dir_path, "public",
+                Usm.RepositoryClient.LISTING_NAME);
+        }
+
+        /**
+         * The absolute path of the verbatim-served `ARTIFACTS.usml`. DCBA
+         * entries only — USM entries answer with {@link listing_location}.
+         */
+        public string artifacts_listing_location() throws Error {
+            if (kind != RepositoryKind.DCBA) {
+                throw_wrong_kind("ARTIFACTS.usml location");
+            }
+            return Path.build_filename(dir_path, "public",
+                Usm.RepositoryClient.DCBA_LISTING_NAME);
         }
 
-        /** The absolute path of a package archive inside `public/`. */
-        public string package_location(string file) {
+        /**
+         * The path of this kind's listing file, unguarded: both kinds
+         * keep their listing under `public/`, so {@link ensure_loaded}
+         * watches one location per kind.
+         */
+        private string current_listing_path() {
+            var listing_name = kind == RepositoryKind.DCBA
+                ? Usm.RepositoryClient.DCBA_LISTING_NAME
+                : Usm.RepositoryClient.LISTING_NAME;
+            return Path.build_filename(dir_path, "public", listing_name);
+        }
+
+        /**
+         * The actual descriptor filename inside the repository
+         * directory (`name.usmr` or `name.dcbar`), for messages and
+         * sibling-route naming.
+         */
+        public string descriptor_basename() {
+            return Path.get_basename(descriptor_path);
+        }
+
+        /**
+         * The absolute path of a package archive inside `public/`. USM
+         * entries only — DCBA entries answer with
+         * {@link artifact_location}.
+         */
+        public string package_location(string file) throws Error {
+            if (kind == RepositoryKind.DCBA) {
+                throw_wrong_kind("package location");
+            }
+            return Path.build_filename(dir_path, "public", file);
+        }
+
+        /**
+         * The absolute path of a `.dcba` artifact inside `public/`. DCBA
+         * entries only — USM entries answer with {@link package_location}.
+         */
+        public string artifact_location(string file) throws Error {
+            if (kind != RepositoryKind.DCBA) {
+                throw_wrong_kind("artifact location");
+            }
             return Path.build_filename(dir_path, "public", file);
         }
 
+        /**
+         * Raises the cross-kind misuse error: every member documented as
+         * kind-specific guards against being reached through the wrong
+         * kind, so a DCBA entry never answers for a `PACKAGES.usml` and
+         * vice versa.
+         */
+        private void throw_wrong_kind(string member) throws Error {
+            throw new UsmWebError.INVALID_CONFIGURATION(
+                @"Repository at \"$dir_path\" is a $kind repository — $member is only available for $(kind == RepositoryKind.DCBA ? RepositoryKind.USM : RepositoryKind.DCBA) repositories");
+        }
+
         private static int64 file_mtime(string path) {
             try {
                 var info = File.new_for_path(path).query_info(
@@ -108,22 +276,33 @@ namespace UsmWeb {
     }
 
     /**
-     * Read-side access to the on-disk USM repositories this app browses and
-     * serves.
+     * Read-side access to the on-disk repositories this app browses and
+     * serves, in a mixed layout.
      *
      * The repositories live under {@link UsmWebConfig.repo_dir}: every
-     * subdirectory containing a `.usmr` is discovered by scanning, or only
-     * those named by {@link UsmWebConfig.repositories} are used when that
-     * list is configured. A repository is addressed by the name in its
-     * `.usmr`, which is also its routing segment (`/repo/<name>/…`). A
-     * legacy single-repository layout with the `.usmr` directly inside the
-     * configured directory is still served as one repository.
+     * subdirectory containing a `.usmr` is a USM source repository
+     * (`.usmc` packages listed in `public/PACKAGES.usml`) and every
+     * subdirectory containing a `.dcbar` is a DCBA artifact repository
+     * (`.dcba` files listed in `public/ARTIFACTS.usml`); both are
+     * discovered by the same scan, or only those named by
+     * {@link UsmWebConfig.repositories} are used when that list is
+     * configured — the list applies to both kinds. A repository is
+     * addressed by the name in its descriptor, which is also its routing
+     * segment (`/repo/<name>/…`). A directory holding both a `.usmr`
+     * and a `.dcbar` is a configuration error. A legacy
+     * single-repository layout with the `.usmr` directly inside the
+     * configured directory is still served as one USM repository.
+     *
+     * {@link entries} returns every loadable repository of either kind,
+     * in discovery order; {@link usm_entries} and {@link dcba_entries}
+     * filter it by kind for callers that iterate one world at a time
+     * (the home package table and search only see source repositories).
      *
      * Discovery re-runs on every {@link ensure_loaded}, so repositories
      * added or removed on disk are picked up without a restart; each
-     * repository's `.usmr` and `PACKAGES.usml` reload independently when
-     * their mtimes change. A repository that fails to (re)load is skipped
-     * with a warning rather than taking the whole app down.
+     * repository's descriptor and listing reload independently when
+     * their mtimes change. A repository that fails to (re)load is
+     * skipped with a warning rather than taking the whole app down.
      */
     public class RepositoryService : Object {
 
@@ -193,10 +372,41 @@ namespace UsmWeb {
         }
 
         /**
-         * The repository whose `.usmr` is named `name`, or null when no
-         * loaded repository carries that name. A discovered repository that
-         * cannot be read surfaces its error (so callers can 500 rather than
-         * 404) unless another repository matches cleanly first.
+         * The currently loadable USM source repositories, in discovery
+         * order — the home package table, search and package detail
+         * flows iterate these. Same load/throw contract as
+         * {@link entries}.
+         */
+        public Enumerable<RepositoryEntry> usm_entries() throws Error {
+            var result = new Vector<RepositoryEntry>();
+            foreach (var entry in entries()) {
+                if (entry.kind == RepositoryKind.USM) {
+                    result.add(entry);
+                }
+            }
+            return result;
+        }
+
+        /**
+         * The currently loadable DCBA artifact repositories, in discovery
+         * order. Same load/throw contract as {@link entries}.
+         */
+        public Enumerable<RepositoryEntry> dcba_entries() throws Error {
+            var result = new Vector<RepositoryEntry>();
+            foreach (var entry in entries()) {
+                if (entry.kind == RepositoryKind.DCBA) {
+                    result.add(entry);
+                }
+            }
+            return result;
+        }
+
+        /**
+         * The repository whose descriptor is named `name`, or null when
+         * no loaded repository carries that name. A discovered repository
+         * that cannot be read surfaces its error (so callers can 500
+         * rather than 404) unless another repository matches cleanly
+         * first.
          */
         public RepositoryEntry? find(string name) throws Error {
             ensure_loaded();
@@ -226,9 +436,9 @@ namespace UsmWeb {
         /**
          * Discovers repository directories: the configured subdirectories
          * when {@link UsmWebConfig.repositories} names them, otherwise every
-         * subdirectory holding a `.usmr`, falling back to the legacy
-         * single-repository layout. Already-known directories keep their
-         * entry (and loaded snapshot).
+         * subdirectory holding a `.usmr` or a `.dcbar`, falling back to the
+         * legacy single-repository layout (`.usmr` only). Already-known
+         * directories keep their entry (and loaded snapshot).
          */
         private Vector<RepositoryEntry> discover() throws Error {
             var result = new Vector<RepositoryEntry>();
@@ -260,15 +470,15 @@ namespace UsmWeb {
                     }
                     continue;
                 }
-                var usmr_path = find_usmr(child_path);
-                if (usmr_path == null) {
+                var descriptor = find_descriptor(child_path);
+                if (descriptor == null) {
                     if (config.repositories.length > 0) {
                         throw new UsmWebError.INVALID_CONFIGURATION(
-                            @"\"$name\" from the configured repositories list has no *.usmr under \"$(config.repo_dir)\"");
+                            @"\"$name\" from the configured repositories list has no *.usmr or *.dcbar under \"$(config.repo_dir)\"");
                     }
                     continue;
                 }
-                result.add(entry_for(child_path, (!)usmr_path));
+                result.add(entry_for(child_path, (!)descriptor));
             }
 
             if (result.length == 0) {
@@ -286,14 +496,21 @@ namespace UsmWeb {
             return result;
         }
 
-        /** Reuses the entry for `dir_path` when discovery finds it again. */
-        private RepositoryEntry entry_for(string dir_path, string usmr_path) {
+        /**
+         * Reuses the entry for `dir_path` when discovery finds it again.
+         * A directory whose descriptor kind changed on disk (a `.dcbar`
+         * replaced by a `.usmr` between discoveries) gets a fresh entry
+         * so no stale snapshot of the old kind lingers.
+         */
+        private RepositoryEntry entry_for(string dir_path, string descriptor_path) {
             RepositoryEntry? existing = null;
             entries_by_dir.try_get(dir_path, out existing);
-            if (existing != null) {
+            if (existing != null && ((!)existing).kind == kind_of((!)descriptor_path)) {
                 return (!)existing;
             }
-            var created = new RepositoryEntry(dir_path, usmr_path);
+            var created = kind_of((!)descriptor_path) == RepositoryKind.DCBA
+                ? new RepositoryEntry.dcba(dir_path, descriptor_path)
+                : new RepositoryEntry.usm(dir_path, descriptor_path);
             entries_by_dir.set(dir_path, created);
             return created;
         }
@@ -311,14 +528,34 @@ namespace UsmWeb {
             }
         }
 
+        /**
+         * The first `*.usmr` or `*.dcbar` directly inside `dir`, or null
+         * when there is none. A directory holding both is a
+         * configuration error.
+         */
+        private static string? find_descriptor(string dir) throws Error {
+            string? usmr = find_suffix(dir, ".usmr");
+            string? dcbar = find_suffix(dir, ".dcbar");
+            if (usmr != null && dcbar != null) {
+                throw new UsmWebError.INVALID_CONFIGURATION(
+                    @"\"$dir\" holds both a .usmr and a .dcbar — a repository directory must be either a USM source repository or a DCBA artifact repository, not both");
+            }
+            return usmr ?? dcbar;
+        }
+
         /** The first `*.usmr` directly inside `dir`, or null when there is none. */
         private static string? find_usmr(string dir) {
+            return find_suffix(dir, ".usmr");
+        }
+
+        /** The first file ending in `suffix` directly inside `dir`, or null. */
+        private static string? find_suffix(string dir, string suffix) {
             try {
                 var handle = Dir.open(dir);
                 string? name = null;
                 string? found = null;
                 while ((name = handle.read_name()) != null) {
-                    if (name.has_suffix(".usmr")) {
+                    if (name.has_suffix(suffix)) {
                         found = name;
                         break;
                     }
@@ -328,5 +565,10 @@ namespace UsmWeb {
                 return null;
             }
         }
+
+        /** The kind implied by a descriptor path's extension. */
+        private static RepositoryKind kind_of(string descriptor_path) {
+            return descriptor_path.has_suffix(".dcbar") ? RepositoryKind.DCBA : RepositoryKind.USM;
+        }
     }
 }

+ 52 - 0
src/endpoints/ArtifactsListingEndpoint.vala

@@ -0,0 +1,52 @@
+using Astralis;
+
+namespace UsmWeb {
+
+    /**
+     * `GET /repo/{name}/ARTIFACTS.usml` — streams that DCBA repository's
+     * artifact listing verbatim so DCBA clients resolve against exactly the
+     * bytes on disk (signature and checksums stay valid), the
+     * artifact-repository counterpart of {@link PackagesListingEndpoint}.
+     *
+     * A USM source repository carries no `ARTIFACTS.usml`, so asking one
+     * for it answers 404 rather than surfacing the cross-kind guard as a
+     * server error: the resource simply does not exist in that repository.
+     */
+    public class ArtifactsListingEndpoint : Object, Endpoint {
+
+        private RepositoryService repositories = Inversion.inject<RepositoryService>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            string name = "";
+            route_context.mapped_parameters.try_get("name", out name);
+
+            RepositoryEntry? entry;
+            try {
+                entry = repositories.find(name);
+            } catch (Error e) {
+                return new HttpStringResult(@"Repository listing unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+            if (entry == null) {
+                return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
+            }
+            if (((!)entry).kind != RepositoryKind.DCBA) {
+                return new HttpStringResult(
+                    @"No ARTIFACTS.usml in \"$name\": that repository is a USM source repository, not a DCBA artifact repository",
+                    StatusCode.NOT_FOUND);
+            }
+
+            var path = ((!)entry).artifacts_listing_location();
+            var file = File.new_for_path(path);
+            if (!file.query_exists()) {
+                return new HttpStringResult("ARTIFACTS.usml not found", StatusCode.NOT_FOUND);
+            }
+
+            var info = yield file.query_info_async(
+                FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE);
+            var result = new HttpStreamResult(yield file.read_async(), info.get_size());
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+    }
+}

+ 65 - 0
src/endpoints/DcbaDefinitionEndpoint.vala

@@ -0,0 +1,65 @@
+using Astralis;
+using Json;
+
+namespace UsmWeb {
+
+    /**
+     * `GET /repo/{name}/repo.dcbar` — serves a rewritten copy of that DCBA
+     * repository's on-disk descriptor (whatever its real filename is) with
+     * its embedded `url` replaced by the derived base ({@link DerivedBase})
+     * plus `repo/<name>/`, the artifact-repository counterpart of
+     * {@link RepositoryDefinitionEndpoint}: a client that drops this file
+     * into its repos.d resolves artifacts from this app no matter which
+     * host it was downloaded through. The route name `repo.dcbar` is the
+     * canonical one regardless of the on-disk descriptor's filename.
+     *
+     * A USM source repository has no DCBA descriptor to serve, so asking
+     * one for `repo.dcbar` answers 404.
+     */
+    public class DcbaDefinitionEndpoint : GLib.Object, Endpoint {
+
+        private RepositoryService repositories = Inversion.inject<RepositoryService>();
+        private UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+
+        public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
+            string name = "";
+            route_context.mapped_parameters.try_get("name", out name);
+
+            RepositoryEntry? entry;
+            try {
+                entry = repositories.find(name);
+            } catch (Error e) {
+                return new HttpStringResult(@"Repository definition unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+            if (entry == null) {
+                return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
+            }
+            if (((!)entry).kind != RepositoryKind.DCBA) {
+                return new HttpStringResult(
+                    @"No repo.dcbar for \"$name\": that repository is a USM source repository, not a DCBA artifact repository",
+                    StatusCode.NOT_FOUND);
+            }
+
+            string contents;
+            try {
+                FileUtils.get_contents(((!)entry).descriptor_path, out contents);
+            } catch (Error e) {
+                return new HttpStringResult(@"Repository definition unavailable: $(e.message)",
+                    StatusCode.INTERNAL_SERVER_ERROR);
+            }
+
+            var repo = ((!)entry).repo();
+            var base_uri = DerivedBase.derive(http_context.request, config);
+
+            var parser = new Parser();
+            parser.load_from_data(contents);
+            var root = parser.get_root();
+            root.get_object().set_string_member("url", base_uri + "repo/" + repo.name + "/");
+
+            var result = new HttpStringResult(Json.to_string(root, false));
+            result.set_header("Content-Type", "application/json");
+            return result;
+        }
+    }
+}

+ 37 - 10
src/endpoints/PackageDownloadEndpoint.vala

@@ -3,15 +3,21 @@ using Astralis;
 namespace UsmWeb {
 
     /**
-     * `GET /repo/{name}/{file}.usmc` — streams a package archive from that
-     * repository's `public/` directory with an exact content-length, exactly
-     * what a USM client fetching through this app expects (the bytes match
-     * the listing's sha512).
+     * `GET /repo/{name}/{file}` — streams a package archive (`.usmc`) or a
+     * pre-built artifact (`.dcba`) from that repository's `public/`
+     * directory with an exact content-length, exactly what a USM client
+     * fetching through this app expects (the bytes match the listings'
+     * checksums).
      *
-     * The route pattern matches any third segment, so it must be registered
-     * after the exact `/repo/{name}/PACKAGES.usml` and
-     * `/repo/{name}/repo.usmr` routes; requests that do not name a `.usmc`
+     * The suffix also selects the repository kind: `.usmc` is served only
+     * from USM source repositories and `.dcba` only from DCBA artifact
+     * repositories — asking the wrong kind answers 404, since the file
+     * has no meaning there. Requests that do not name a recognized file
      * inside `public/` answer 404.
+     *
+     * The route pattern matches any third segment, so it must be registered
+     * after the exact `/repo/{name}/PACKAGES.usml`, `/repo/{name}/repo.usmr`,
+     * `/repo/{name}/ARTIFACTS.usml` and `/repo/{name}/repo.dcbar` routes.
      */
     public class PackageDownloadEndpoint : Object, Endpoint {
 
@@ -23,7 +29,13 @@ namespace UsmWeb {
             route_context.mapped_parameters.try_get("name", out name);
             route_context.mapped_parameters.try_get("file", out file);
 
-            if (!file.has_suffix(".usmc") || file.contains("/") || file.contains("..")) {
+            if (file.contains("/") || file.contains("..")) {
+                return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
+            }
+
+            bool is_usmc = file.has_suffix(".usmc");
+            bool is_dcba = file.has_suffix(".dcba");
+            if (!is_usmc && !is_dcba) {
                 return new HttpStringResult("Not Found", StatusCode.NOT_FOUND);
             }
 
@@ -38,10 +50,25 @@ namespace UsmWeb {
                 return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
             }
 
-            var path = ((!)entry).package_location(file);
+            var kind = ((!)entry).kind;
+            if (is_usmc && kind != RepositoryKind.USM) {
+                return new HttpStringResult(
+                    @"No package \"$file\" in \"$name\": that repository is a DCBA artifact repository and carries no .usmc packages",
+                    StatusCode.NOT_FOUND);
+            }
+            if (is_dcba && kind != RepositoryKind.DCBA) {
+                return new HttpStringResult(
+                    @"No artifact \"$file\" in \"$name\": that repository is a USM source repository and carries no .dcba artifacts",
+                    StatusCode.NOT_FOUND);
+            }
+
+            var path = is_dcba
+                ? ((!)entry).artifact_location(file)
+                : ((!)entry).package_location(file);
             var handle = File.new_for_path(path);
             if (!handle.query_exists()) {
-                return new HttpStringResult("No such package", StatusCode.NOT_FOUND);
+                return new HttpStringResult(is_dcba ? "No such artifact" : "No such package",
+                    StatusCode.NOT_FOUND);
             }
 
             var info = yield handle.query_info_async(

+ 9 - 0
src/endpoints/PackagesListingEndpoint.vala

@@ -6,6 +6,10 @@ namespace UsmWeb {
      * `GET /repo/{name}/PACKAGES.usml` — streams that repository's listing
      * verbatim so USM clients resolve against exactly the bytes on disk
      * (signature and checksums stay valid).
+     *
+     * A DCBA artifact repository carries no `PACKAGES.usml`, so asking one
+     * for it answers 404 rather than surfacing the cross-kind guard as a
+     * server error: the resource simply does not exist in that repository.
      */
     public class PackagesListingEndpoint : Object, Endpoint {
 
@@ -25,6 +29,11 @@ namespace UsmWeb {
             if (entry == null) {
                 return new HttpStringResult(@"No repository named \"$name\"", StatusCode.NOT_FOUND);
             }
+            if (((!)entry).kind != RepositoryKind.USM) {
+                return new HttpStringResult(
+                    @"No PACKAGES.usml in \"$name\": that repository is a DCBA artifact repository, not a USM source repository",
+                    StatusCode.NOT_FOUND);
+            }
 
             var path = ((!)entry).listing_location();
             var file = File.new_for_path(path);

+ 119 - 0
src/entrypoints/ArtifactDetailEntrypoint.vala

@@ -0,0 +1,119 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using InvercargillJson;
+using Statum;
+using Usm;
+
+namespace UsmWeb {
+
+    /**
+     * Entrypoint for /repo/{name}/artifact/{file}: hydrates the `artifact`
+     * PAGE slot from that DCBA repository's `ARTIFACTS.usml` entry whose
+     * path basename is `file` — the name and version shown come from the
+     * listing entry, never re-parsed out of the filename — plus the
+     * `.dcba`'s on-disk size/mtime and the listing's sha512.
+     *
+     * When a loaded USM source repository's `PACKAGES.usml` carries a
+     * package with the same name AND the same version string (a plain
+     * string comparison on both, mirroring how DCBA clients match
+     * artifacts), the page cross-links to that package's detail view.
+     * Unknown repositories redirect to the overview; unknown artifact
+     * files redirect to the repository's browse page.
+     */
+    public class ArtifactDetailEntrypoint : StatumEntrypoint {
+
+        protected RepositoryService repositories = Inversion.inject<RepositoryService>();
+        protected UsmWebConfig config = Inversion.inject<UsmWebConfig>();
+        protected Astralis.HttpContext http_context = Inversion.inject<Astralis.HttpContext>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var name = request.route("name") ?? "";
+            var file = request.route("file") ?? "";
+            var base_uri = DerivedBase.derive(http_context.request, config);
+
+            var entry = repositories.find(name);
+            if (entry == null) {
+                return directives().navigate("/");
+            }
+            if (((!)entry).kind != RepositoryKind.DCBA) {
+                return directives().navigate(@"/repo/$name");
+            }
+
+            DcbaListingEntry? listing_entry = null;
+            foreach (var artifact_entry in ((!)entry).dcba_listing_entries()) {
+                if (Path.get_basename(artifact_entry.path) == file) {
+                    listing_entry = artifact_entry;
+                    break;
+                }
+            }
+            if (listing_entry == null) {
+                return directives().navigate(@"/repo/$name");
+            }
+
+            var repo = ((!)entry).repo();
+            var path = ((!)entry).artifact_location(file);
+            var info = file_info(path);
+
+            var dict = new PropertyDictionary();
+            dict.set_native<string>("name", ((!)listing_entry).name);
+            dict.set_native<string>("version", ((!)listing_entry).version);
+            dict.set_native<string>("repo", repo.name);
+            dict.set_native<string>("file", file);
+            dict.set_native<string>("download_href", @"/repo/$name/$file");
+            dict.set_native<string>("size", ((!)listing_entry).size > 0
+                ? PackagesState.format_size((int64)((!)listing_entry).size)
+                : (info.size > 0 ? PackagesState.format_size(info.size) : "?"));
+            dict.set_native<string>("modified", format_mtime(info.modified));
+            dict.set_native<string>("sha512", PackagesState.fingerprint(((!)listing_entry).sha512sum.to_hex()));
+            dict.set_native<string>("repo_dcbar_href", base_uri + "repo/" + repo.name + "/repo.dcbar");
+            dict.set_native<string>("repos_d_snippet", "sudo usm dcba add-repo %srepo/%s/repo.dcbar"
+                .printf(base_uri, repo.name));
+
+            // Source-package cross-link, one direction only: the first
+            // USM repository whose listing carries a package with the
+            // same name AND version string. Plain string comparison on
+            // both — no Version parse round trip, matching how DCBA
+            // clients match artifacts.
+            foreach (var usm_entry in repositories.usm_entries()) {
+                foreach (var listing in usm_entry.listing_entries()) {
+                    if (listing.manifest.name == ((!)listing_entry).name
+                            && listing.manifest.version.to_string() == ((!)listing_entry).version) {
+                        dict.set_native<string>("source_package_repo", usm_entry.repo().name);
+                        dict.set_native<string>("source_package_href",
+                            @"/repo/$(usm_entry.repo().name)/package/$(Path.get_basename(listing.path))");
+                        break;
+                    }
+                }
+                if (dict.has("source_package_href")) {
+                    break;
+                }
+            }
+
+            var state = new State() {
+                type_name = "artifact",
+                public_data = dict,
+                private_data = new PropertyDictionary()
+            };
+            return directives().set(state_service.new_slot(Scope.PAGE, state));
+        }
+
+        private static FileStat file_info(string path) throws Error {
+            var info = File.new_for_path(path).query_info(
+                FileAttribute.STANDARD_SIZE + "," + FileAttribute.TIME_MODIFIED,
+                FileQueryInfoFlags.NONE);
+            int64 size = info.get_size();
+            int64 modified = (int64) info.get_attribute_uint64(FileAttribute.TIME_MODIFIED);
+            return new FileStat() { size = size, modified = modified };
+        }
+
+        private static string format_mtime(int64 unix_seconds) {
+            var dt = new DateTime.from_unix_local(unix_seconds);
+            return dt.format("%Y-%m-%d %H:%M");
+        }
+
+        private class FileStat : Object {
+            public int64 size { get; set; }
+            public int64 modified { get; set; }
+        }
+    }
+}

+ 47 - 0
src/entrypoints/PackageDetailEntrypoint.vala

@@ -18,6 +18,21 @@ namespace UsmWeb {
         public string ref_name { get; set; default = ""; }
     }
 
+    /**
+     * One pre-built-artifact row: a DCBA repository carrying a `.dcba`
+     * built from this exact package name and version.
+     */
+    public class PrebuiltArtifactRow : Object {
+        public string repo { get; set; default = ""; }
+        public string summary { get; set; default = ""; }
+        public string file { get; set; default = ""; }
+        public string version { get; set; default = ""; }
+        public string size { get; set; default = ""; }
+        public string sha512 { get; set; default = ""; }
+        public string repo_href { get; set; default = ""; }
+        public string download_href { get; set; default = ""; }
+    }
+
     /** One licence row of the detail view. */
     public class LicenceRow : Object {
         public string name { get; set; default = ""; }
@@ -95,6 +110,38 @@ namespace UsmWeb {
             add_phase(depends, "runtime", manifest.dependencies.runtime);
             dict["depends"] = new JsonElement.from_elements(depends);
 
+            // Pre-built artifacts: DCBA repositories carrying this exact
+            // package name AND version (what `usm install` would fetch).
+            // Exact string match only — the listing's version string must
+            // equal the manifest's version string, with no Version parse
+            // round trip on the listing side.
+            var prebuilt = new JsonArray();
+            var version_key = manifest.version.to_string();
+            foreach (var dcba_entry in repositories.dcba_entries()) {
+                var artifact = dcba_entry.find_dcba_artifact(manifest.name, version_key);
+                if (artifact == null) {
+                    continue;
+                }
+                var dcba_repo = dcba_entry.repo();
+                var artifact_file = Path.get_basename(((!)artifact).path);
+                var row = new PrebuiltArtifactRow() {
+                    repo = dcba_repo.name,
+                    summary = dcba_repo.summary,
+                    file = artifact_file,
+                    version = ((!)artifact).version,
+                    size = ((!)artifact).size > 0
+                        ? PackagesState.format_size((int64)((!)artifact).size)
+                        : "?",
+                    sha512 = PackagesState.fingerprint(((!)artifact).sha512sum.to_hex()),
+                    repo_href = @"/repo/$(dcba_repo.name)",
+                    download_href = @"/repo/$(dcba_repo.name)/$artifact_file"
+                };
+                prebuilt.add(new JsonElement.from_properties(GObjectMapping.to_properties(row)));
+            }
+            if (prebuilt.length > 0) {
+                dict["prebuilt_artifacts"] = new JsonElement.from_elements(prebuilt);
+            }
+
             var state = new State() {
                 type_name = "package",
                 public_data = dict,

+ 7 - 3
src/entrypoints/RepoBrowseEntrypoint.vala

@@ -7,9 +7,13 @@ namespace UsmWeb {
     /**
      * Entrypoint for /repo/{name}: hydrates the
      * {@link PackagesState.SLOT_TYPE} PAGE slot scoped to one repository —
-     * its overview card, add-repository instructions and package table.
-     * Unknown repositories redirect to the overview; a load failure degrades
-     * to an error card instead of a failed hydration.
+     * its overview card, add-repository instructions and the table of what
+     * it carries: the package table for a USM source repository, the
+     * artifact table for a DCBA artifact repository (the state's `kind`
+     * picks the branch the page renders; a DCBA repository never shows a
+     * search form — search is source-package-only). Unknown repositories
+     * redirect to the overview; a load failure degrades to an error card
+     * instead of a failed hydration.
      */
     public class RepoBrowseEntrypoint : StatumEntrypoint {
 

+ 6 - 2
src/main.vala

@@ -40,6 +40,7 @@ int main(string[] args) {
         statum.add_page<HomePage, HomeEntrypoint>();
         statum.add_page<RepoBrowsePage, RepoBrowseEntrypoint>();
         statum.add_page<PackageDetailPage, PackageDetailEntrypoint>();
+        statum.add_page<ArtifactDetailPage, ArtifactDetailEntrypoint>();
         // spry:pages-end
 
         // spry:actions-begin
@@ -50,12 +51,15 @@ int main(string[] args) {
         statum.add_resource<MainCssResource>();
         // spry:resources-end
 
-        // USM-serving endpoints. The exact /repo/{name}/PACKAGES.usml and
-        // /repo/{name}/repo.usmr routes must precede /repo/{name}/{file}
+        // USM-serving endpoints. The exact /repo/{name}/PACKAGES.usml,
+        // /repo/{name}/repo.usmr, /repo/{name}/ARTIFACTS.usml and
+        // /repo/{name}/repo.dcbar routes must precede /repo/{name}/{file}
         // (which also matches them), and the /{file} catch-all — serving
         // only /install-usm.sh — must come last.
         application.add_endpoint<PackagesListingEndpoint>(new EndpointRoute("/repo/{name}/PACKAGES.usml"));
         application.add_endpoint<RepositoryDefinitionEndpoint>(new EndpointRoute("/repo/{name}/repo.usmr"));
+        application.add_endpoint<ArtifactsListingEndpoint>(new EndpointRoute("/repo/{name}/ARTIFACTS.usml"));
+        application.add_endpoint<DcbaDefinitionEndpoint>(new EndpointRoute("/repo/{name}/repo.dcbar"));
         application.add_endpoint<PackageDownloadEndpoint>(new EndpointRoute("/repo/{name}/{file}"));
         if (config.installer == InstallerSource.PATH) {
             application.add_endpoint<InstallerScriptEndpoint>(new EndpointRoute("/{file}"));

+ 57 - 0
src/pages/artifact.html

@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/repo/{name}/artifact/{file}</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <div stm-if="artifact != null">
+        <p class="breadcrumb"><a href="/">← All repositories</a>&nbsp;/&nbsp;<a
+            stm-attribute.href="'/repo/' + artifact.repo" stm-text="artifact.repo">repository</a></p>
+
+        <section class="card hero">
+            <div class="chips">
+                <span class="chip mono">DCBA · pre-built artifact</span>
+            </div>
+            <div class="detail-head">
+                <div>
+                    <h1 stm-text="artifact.name">artifact</h1>
+                    <p class="version mono" stm-text="artifact.version">0.0</p>
+                </div>
+                <a class="button" stm-attribute.href="artifact.download_href">Download
+                    <span stm-text="artifact.size">0 B</span></a>
+            </div>
+            <dl class="stats">
+                <div class="stat-wide"><dt>SHA-512</dt>
+                    <dd class="mono fingerprint" stm-text="artifact.sha512">—</dd></div>
+                <div><dt>Artifact</dt><dd class="mono" stm-text="artifact.file">file.dcba</dd></div>
+                <div><dt>Size</dt><dd class="mono" stm-text="artifact.size">—</dd></div>
+                <div><dt>Modified</dt><dd class="mono" stm-text="artifact.modified">—</dd></div>
+            </dl>
+        </section>
+
+        <section class="card" stm-if="artifact.source_package_href != null">
+            <h2>Source package</h2>
+            <p>This artifact was built from the source package of the same name and
+                version in the
+                <a stm-attribute.href="artifact.source_package_href"
+                   stm-text="artifact.source_package_repo">repository</a> repository.</p>
+            <p class="hint">The link is one-directional on purpose: a package page
+                lists every DCBA repository carrying its pre-built artifacts.</p>
+        </section>
+
+        <section class="card">
+            <h2>What is a .dcba?</h2>
+            <p>A <span class="mono">.dcba</span> is a pre-built build artifact
+                distributed by a DCBA artifact repository. When this repository is
+                configured, <span class="mono">usm install</span> fetches it
+                automatically instead of building from source.</p>
+            <p>Add this repository with USM:</p>
+            <pre class="mono snippet"><code stm-text="artifact.repos_d_snippet"></code></pre>
+            <p class="hint">The signed repository definition:
+                <a class="mono" stm-attribute.href="artifact.repo_dcbar_href"
+                   stm-text="artifact.repo_dcbar_href">repo.dcbar</a></p>
+        </section>
+    </div>
+</body>
+</html>

+ 21 - 5
src/pages/home.html

@@ -14,7 +14,8 @@
     <div stm-if="packages != null && (packages.load_error == null || packages.load_error == '')">
         <section class="card hero">
             <h1 stm-text="packages.site_name">Repositories</h1>
-            <p class="summary">Every USM repository served by this host.</p>
+            <p class="summary">Every repository served by this host — USM source
+               repositories and DCBA artifact repositories alike.</p>
             <dl class="stats">
                 <div><dt>Repositories</dt><dd stm-text="packages.repo_count">—</dd></div>
                 <div><dt>Packages</dt><dd stm-text="packages.package_count">—</dd></div>
@@ -33,9 +34,13 @@
         <section class="card">
             <h2>Add a repository</h2>
             <div class="repo-block" stm-for-repo-in="packages.repos" stm-key="repo.name">
+                <span class="chip mono" stm-if="repo.kind == 'dcba'">DCBA · pre-built artifacts</span>
+                <span class="chip mono" stm-else>USM · source packages</span>
                 <h3 stm-text="repo.name">repository</h3>
                 <p class="summary" stm-text="repo.summary">summary</p>
-                <p>Point USM at this repository — its URI is
+                <p stm-if="repo.kind == 'dcba'">Point USM at this artifact repository — its URI is
+                    <code class="mono" stm-text="repo.base_uri">…</code></p>
+                <p stm-else>Point USM at this repository — its URI is
                     <code class="mono" stm-text="repo.base_uri">…</code></p>
                 <ol class="steps">
                     <li>The signed repository definition:
@@ -43,18 +48,29 @@
                            stm-text="repo.usmr_href">repo.usmr</a></li>
                     <li>Add it with USM:</li>
                 </ol>
-                <pre class="mono snippet"><code stm-text="'sudo usm add-repo ' + repo.usmr_href"></code></pre>
+                <pre class="mono snippet" stm-if="repo.kind == 'dcba'"><code
+                    stm-text="'sudo usm dcba add-repo ' + repo.usmr_href"></code></pre>
+                <pre class="mono snippet" stm-else><code
+                    stm-text="'sudo usm add-repo ' + repo.usmr_href"></code></pre>
+                <p class="hint" stm-if="repo.kind == 'dcba'"
+                   stm-text="repo.artifact_count + ' pre-built artifact' + (repo.artifact_count == 1 ? '' : 's') + ' · ' + repo.artifact_total_size + ' total'">2 pre-built artifacts · 1.2 MiB total</p>
+                <p class="hint" stm-else
+                   stm-text="repo.package_count + ' source package' + (repo.package_count == 1 ? '' : 's') + ' · ' + repo.total_size + ' total'">2 source packages · 1.2 MiB total</p>
                 <p class="hint">Signing-key fingerprint:
                     <span class="mono fingerprint" stm-text="repo.key_fingerprint">—</span></p>
-                <p class="hint">Browse the repository's
+                <p class="hint" stm-if="repo.kind == 'dcba'">Browse the repository's
+                    <a stm-attribute.href="repo.href">artifacts</a>.</p>
+                <p class="hint" stm-else>Browse the repository's
                     <a stm-attribute.href="repo.href">packages</a>.</p>
             </div>
         </section>
 
         <section class="card">
             <h2>Packages</h2>
+            <p class="summary">Source packages from every USM repository — pre-built
+                DCBA artifacts are listed on each repository's page.</p>
             <form class="search" stm-action="packages.search">
-                <input type="search" name="query" placeholder="Search all repositories…"
+                <input type="search" name="query" placeholder="Search all source repositories…"
                        stm-attribute.value="packages.query">
                 <button type="submit">Search</button>
             </form>

+ 31 - 0
src/pages/package.html

@@ -31,6 +31,37 @@
             </div>
         </section>
 
+        <section class="card" stm-if="package.prebuilt_artifacts.length > 0">
+            <h2>Pre-built artifacts</h2>
+            <p class="muted">Pre-built for this exact version — installed automatically by
+                <span class="mono">usm install</span> when one of these repositories is configured.</p>
+            <div class="table-scroll">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>Repository</th>
+                            <th>Artifact</th>
+                            <th>Version</th>
+                            <th class="num">Size</th>
+                            <th>SHA-512</th>
+                            <th class="num">Download</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr stm-for-prebuilt-in="package.prebuilt_artifacts">
+                            <td><a stm-attribute.href="prebuilt.repo_href" stm-text="prebuilt.repo">repository</a></td>
+                            <td class="mono" stm-text="prebuilt.file">app-1.0.0.dcba</td>
+                            <td class="mono" stm-text="prebuilt.version">1.0.0</td>
+                            <td class="num mono" stm-text="prebuilt.size">—</td>
+                            <td class="mono fingerprint" stm-text="prebuilt.sha512">—</td>
+                            <td class="num"><a class="download" title="Download .dcba"
+                               stm-attribute.href="prebuilt.download_href">&#8681;</a></td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+
         <section class="card" stm-if="package.licences.length > 0">
             <h2>Licences</h2>
             <div class="table-scroll">

+ 53 - 7
src/pages/repo.html

@@ -15,11 +15,18 @@
         <p class="breadcrumb"><a href="/">← All repositories</a></p>
 
         <section class="card hero">
+            <div class="chips" stm-if="packages.repo_kind == 'dcba'">
+                <span class="chip mono">DCBA · pre-built artifacts</span>
+            </div>
             <h1 stm-text="packages.repo_name">Repository</h1>
             <p class="summary" stm-text="packages.repo_summary">Loading…</p>
             <dl class="stats">
-                <div><dt>Packages</dt><dd stm-text="packages.package_count">—</dd></div>
-                <div><dt>Total size</dt><dd stm-text="packages.total_size">—</dd></div>
+                <div stm-if="packages.repo_kind == 'dcba'"><dt>Artifacts</dt>
+                    <dd stm-text="packages.artifact_count">—</dd></div>
+                <div stm-if="packages.repo_kind == 'dcba'"><dt>Total artifact size</dt>
+                    <dd stm-text="packages.artifact_total_size">—</dd></div>
+                <div stm-if="packages.repo_kind == 'usm'"><dt>Packages</dt><dd stm-text="packages.package_count">—</dd></div>
+                <div stm-if="packages.repo_kind == 'usm'"><dt>Total size</dt><dd stm-text="packages.total_size">—</dd></div>
                 <div class="stat-wide"><dt>Signing key fingerprint</dt>
                     <dd class="mono fingerprint" stm-text="packages.key_fingerprint">—</dd></div>
             </dl>
@@ -34,21 +41,60 @@
         </section>
 
         <section class="card">
-            <h2>Add this repository</h2>
-            <p>Point USM at this repository — its URI is
+            <h2 stm-if="packages.repo_kind == 'dcba'">Add this artifact repository</h2>
+            <h2 stm-if="packages.repo_kind == 'usm'">Add this repository</h2>
+            <p stm-if="packages.repo_kind == 'dcba'">Point USM at this DCBA artifact repository — its URI is
+                <code class="mono" stm-text="packages.base_uri">…</code></p>
+            <p stm-if="packages.repo_kind == 'usm'">Point USM at this repository — its URI is
                 <code class="mono" stm-text="packages.base_uri">…</code></p>
             <ol class="steps">
                 <li>The signed repository definition:
                     <a class="mono" stm-attribute.href="packages.usmr_href"
                        stm-text="packages.usmr_href">repo.usmr</a></li>
-                <li>Add it with USM:</li>
+                <li stm-if="packages.repo_kind == 'dcba'">Add it with USM (DCBA repositories use the
+                    <code class="mono">dcba</code> subcommand):</li>
+                <li stm-if="packages.repo_kind == 'usm'">Add it with USM:</li>
             </ol>
             <pre class="mono snippet"><code stm-text="packages.repos_d_snippet"></code></pre>
-            <p class="hint">Then <code class="mono">usm repository list</code> should show
+            <p class="hint" stm-if="packages.repo_kind == 'dcba'">Then <code class="mono">usm dcba repository list</code> should show
+                <span class="mono" stm-text="packages.repo_name">the repository</span>.</p>
+            <p class="hint" stm-if="packages.repo_kind == 'usm'">Then <code class="mono">usm repository list</code> should show
                 <span class="mono" stm-text="packages.repo_name">the repository</span>.</p>
         </section>
 
-        <section class="card">
+        <section class="card" stm-if="packages.repo_kind == 'dcba'">
+            <h2>Artifacts</h2>
+            <p class="summary">Pre-built build artifacts served by this DCBA repository —
+                fetched automatically by <span class="mono">usm install</span> when this
+                repository is configured. They are not source packages.</p>
+            <div class="table-scroll">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>Name</th>
+                            <th>Version</th>
+                            <th class="num">Size</th>
+                            <th class="num">Download</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        <tr stm-for-artifact-in="packages.artifacts" stm-key="artifact.file">
+                            <td><a stm-attribute.href="artifact.href"
+                               stm-text="artifact.name">artifact</a></td>
+                            <td class="mono" stm-text="artifact.version">0.0</td>
+                            <td class="num mono" stm-text="artifact.size">0 B</td>
+                            <td class="num"><a class="download" title="Download .dcba"
+                               stm-attribute.href="artifact.download_href">&#8681;</a></td>
+                        </tr>
+                        <tr stm-if="packages.artifacts.length == 0">
+                            <td colspan="4" class="empty">This repository lists no artifacts.</td>
+                        </tr>
+                    </tbody>
+                </table>
+            </div>
+        </section>
+
+        <section class="card" stm-if="packages.repo_kind == 'usm'">
             <h2>Packages</h2>
             <form class="search" stm-action="packages.search">
                 <input type="hidden" name="repo" stm-attribute.value="packages.repo_name">