# 22 · Packaging and Upload

This document is the source of truth for the uploadable extension archive: what
the ZIP must contain, what intake validates, the size limits, a reference
packaging script, and the operator upload flow.

## The archive is the deliverable

Cedros local package intake expects a ZIP archive. The ZIP establishes
inventory, manifest metadata, routing/setup intent, review evidence,
install/enable state, executable admin runtime (when admin modules are
declared), and the server wasm component (when server surfaces are declared).

- The ZIP must contain canonical `cedros-extension.manifest.json` and
  `cedros-extension.bootstrap.json` files generated from the same manifest.
- `cedros-extension.bootstrap.json` must publish a top-level `manifest` that
  deep-equals the manifest file. The host accepts either canonical file alone
  and derives the other, but production handoff archives ship both.
- Archive source files are advisory. Manifest/bootstrap files are authoritative
  for metadata; compiled runtime files are authoritative for executable admin
  UI; `cedros-extension.server.wasm` is authoritative for server behavior.
- For admin modules, Cedros Data resolves the React package member declared by
  `packageFamily.react.packageName`, reads that package's `package.json`
  `exports["./admin/register"]`, and loads the resolved browser ESM file plus
  its relative chunks from the ZIP.
- The runtime must return a bootstrap with the same `extensionId` and exact
  `version` as the uploaded manifest.
- For every admin module, package the exact JSON-safe runtime navigation
  projection in `adminModules[].navigation`. Installation persists the
  manifest and enablement copies its projection into the active admin registry
  for the next shell's first paint; runtime section/group mismatches retain
  the manifest fallback.
- Bare runtime imports are allowed only for the four Cedros host shims:
  `react`, `react/jsx-runtime`, `react/jsx-dev-runtime`, and
  `cedros:admin`. `react-dom` is
  **not** shimmed. Every other runtime dependency must be bundled into the ZIP
  package output — except `react-dom` in **public host-mounted web chunks**,
  which must instead be externalized (not bundled) and never imported, directly
  or transitively; see
  [`14-blocks-templates-and-styling.md`](14-blocks-templates-and-styling.md).
- Both admin and web runtime graphs execute as browser ESM. They have no Node
  globals: no `process`, `Buffer`, `require`, `module`, `global`, `__dirname`,
  `__filename`, or other Node-only runtime API. This applies to every bundled
  dependency and lazy chunk, not only to `react-dom` or public blocks.

Production Vite builds must fold production-only dependency branches while
bundling:

```ts
export default defineConfig({
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
});
```

Do not add a browser `process` polyfill to hide a bad build. Inspect every
resolved admin and web ESM chunk after bundling; a clean entrypoint does not
make a transitive Motion, animation, editor, or UI-library chunk safe.

> **Common wrong assumption:** the ranked loose-JSON fallback names
> (`manifest.json`, `cedros-extension.json`, `extension.json`,
> `bootstrap.json`) are accepted packaging shapes. **Actually:** that ranked
> fallback is a **client-side UI preview convenience only**
> (`ui/src/admin/extensionPackageArchive.ts`). Both server intake endpoints
> require the two canonical entry names via the shared publisher
> (`server/src/extension_platform_archive.rs`), so an archive that relies on a
> loose-JSON name installs nowhere.

## Not a dependency cache

The standard `.cedros-extension.zip` is not a dependency cache. Do not vendor
Rust crates, `node_modules`, package-manager caches, `target`, `.next`, or
other package-manager state. The ZIP may include compiled package outputs that
Cedros Data must execute, such as the React admin `dist` files for declared
admin modules and the compiled server component.

Packaging tools may download from public registries while building the ZIP, but
Cedros Data intake does not. By upload time, every executable file Cedros Data
needs for the extension must already be inside the ZIP.

If the target environment requires air-gapped or true zero-network server
builds, produce a separate offline runtime bundle instead of expanding the
intake ZIP. That bundle should include `Cargo.lock`, `cargo vendor` output,
`.cargo/config.toml`, package checksums, SBOM/provenance, and operator
instructions for building with `--offline`. Attach or reference that bundle
from the release packet; keep the standard local-intake archive small.

## Archive contract

Preferred ZIP entries:

- `cedros-extension.manifest.json`
- `cedros-extension.bootstrap.json`
- `cedros-extension.server.wasm` when any server surface is declared
- `workspace/README.md`
- `workspace/server/*`
- `workspace/react/package.json`
- `workspace/react/dist/**` when `adminModules[]` is non-empty
- optional `workspace/react/src/**` for source review
- `workspace/react-native/*`
- optional `workspace/examples/*`
- optional `workspace/supply-chain/*` for SBOM, provenance, signature, and
  checksum files that are intentionally bundled for reviewer convenience

Intake matches canonical files by file **name** anywhere in the archive, not by
root placement — root placement is convention. A stray nested copy (for
example `workspace/examples/cedros-extension.manifest.json`) plus the root copy
triggers the "multiple entries" failure. Keep exactly one of each canonical
file name anywhere in the ZIP.

ZIP directory layout is otherwise not enforced. The binding contract for the
admin runtime is: exactly one `package.json` anywhere in the ZIP whose `name`
equals `packageFamily.react.packageName`, with the registration exports
resolving to existing `.js`/`.mjs` files under that package root. Two matching
`package.json` files are rejected.

> **Common wrong assumption:** the ZIP's own filename is load-bearing.
> **Actually:** the server installer ignores it. It defaults a missing name to
> `extension.zip` and stores the original only as advisory metadata
> (`server/src/http_wasm_extensions.rs`, `extension_platform_archive.rs`).
> Neither the `.cedros-extension.zip` suffix nor an `<id>-<version>` prefix is
> a validation requirement; matching is on the **entry** base names inside the
> ZIP.

### Surface graphs are generated by the host

Extension authors continue to publish the existing React package exports:
`./admin/register` and, when the extension has a public runtime,
`./web/register`. While building the ZIP-contained runtime archive, an upgraded
host resolves those exports and parses their relative ESM imports to generate
separate admin and web file graphs. The graph fields are runtime archive
metadata; they do not belong in `cedros-extension.manifest.json` or
`cedros-extension.bootstrap.json`.

Keep both entrypoints and their chunks under the React package root. Keep the
web graph independent from admin-only modules, and use only static string
literals in dynamic `import()` calls. The generated graph includes all
reachable `.js`, `.mjs`, `.css`, and `.json` files, including literal lazy
imports. Lazy imports can defer evaluation, but they do not currently remove
their chunks from the surface archive response.

Validation must walk `surfaceFilePaths.admin` and `surfaceFilePaths.web` and
inspect every resolved `kind: "module"` file. Checking only
`./admin/register` or `./web/register` misses the most common production
failure: a dependency-owned split chunk that retains a Node environment
branch.

Existing packages do not need source changes. Legacy persisted archives that
lack surface metadata still load, but the host must return their complete file
set. Re-uploading or reinstalling the same built ZIP through an upgraded host
regenerates the metadata and enables admin/web payload projection. Follow the
normal release policy for distribution: prefer a patch version for a one-click
unchanged-scope update; a same-version reinstall is supported but repeats the
full permission review.

### Styles ship inside your compiled JS

Runtime stylesheets bundled with the compiled runtime (archive `kind: "style"`
files) are delivered by newer hosts: inlined as critical CSS on public pages
and injected into the admin document. **Do not rely on that as the only
delivery path** — the injection hook shipped in June 2026 and deployed hosts
can predate it while reporting an up-to-date version, so the compiled admin
entry must also self-inject its CSS at module evaluation (Vite `?inline` plus
an id-guarded `<style>`). Scope selectors under your own roots and style colors
through the host theme tokens. See
[`14-blocks-templates-and-styling.md`](14-blocks-templates-and-styling.md) and
the styling section of
[`12-admin-pages-and-dashboard-cards.md`](12-admin-pages-and-dashboard-cards.md).

## The two intake endpoints and their limits

### Non-installing authoring scan

An author without a local Cedros host or MCP-connected test site can use the
[centralized Forge authoring scan](https://cedros.ai/extensions/cedros-forge/skill.md).
Submit the finished ZIP using the profile and exact contract ID advertised by
that live guide, and do not attach site credentials.

The scan is
content-addressed and never installs, enables, or publishes the extension. Its
installed extension-Wasm route currently advertises only `static`; it cannot
host a nested Wasm compiler/runtime. `reference-runtime` evidence remains
unavailable until the separate Forge scanner discovery service advertises a
live endpoint, contract hash, limits, quota behavior, and compile/link support.
Do not treat a missing discovery endpoint or a static result as
`reference-runtime` evidence.

When the ZIP includes the scaffold's optional
`workspace/server/Cargo.toml`, both scan profiles also compare Cargo
`[package].name`, version, and effective library crate name with
`packageFamily.server` and `registration.server.entrypoint`. Source is not
required in every extension ZIP, so this check is skipped when that file is
absent; the copied scaffold includes it and treats a mismatch as an error.

Canonical manifest, bootstrap, and runtime package JSON reject duplicate object
keys at every nesting level. Archive paths are normalized and checked
case-insensitively before the scan accepts the package.

The report's artifact, contract, and report SHA-256 values are stable release
evidence. Browser modules are not evaluated, and no target-site content,
permissions, providers, saved state, or extension inventory is present. A
passing scan therefore complements rather than replaces the browser execution,
saved-preview, accessibility, install, and lifecycle proofs below.

There are two server intake endpoints. Both run the same shared publisher
(`publish_cedros_extension_package_archive`): every non-wasm entry is read as
UTF-8, the canonical manifest/bootstrap entries are parsed and
deep-equality-checked, duplicate canonical names are rejected, and the manifest
passes full standalone validation (including `supplyChain` trust-field
rejection at every nesting level). They differ in what happens *after*
publication.

Authorized operators may manually upload public or privately distributed
archives through either intake endpoint. Every archive passes the same manifest,
compatibility, capability, browser-runtime, and Wasm preflight validation.

Here, **browser-runtime validation is a structural/source preflight**: the host
resolves exports and graphs, enforces loader/import constraints, and rejects
high-confidence executable Node-global references. Intake does not evaluate
arbitrary extension code in a real browser. Authors must still run the browser
execution regression below; do not describe structural intake success as proof
that registration or rendering executed.

**Extension-manager package intake**
(`POST /admin/runtime/cedros-extension-manager/packages/intake`, permission
`ManageExtensions`) — records the version in the extension-manager catalog
(listing, review, publish/deprecate lifecycle) and stores the archive; the
admin Extensions UI drives install/enable from there.

MCP clients can complete that lifecycle without a browser: use
`cedros.extension_admin.extension_manager.package_intake`, confirm the exact
artifact is `host-verified` in
`cedros.extension_admin.extension_manager.snapshot`, then discover and execute
`cedros-extension-manager:review-version` and
`cedros-extension-manager:publish-version` through the extension-capability MCP
tools.

**Server wasm install**
(`POST /admin/runtime/wasm-extensions/install`, permission
`ManageExtensions`) — additionally extracts `cedros-extension.server.wasm`,
verifies it loads under exactly the manifest-granted capabilities, binds
routes/jobs, and hot-swaps the runtime registry.

Shared limits (enforced by the shared publisher):

- request body: at most **64 MB** (both endpoints)
- files: at most **4096** entries
- `cedros-extension.server.wasm`: at most **64 MB**, exempt from the per-entry
  text limits
- every other entry: at most **2 MB** uncompressed, UTF-8 text only
- total uncompressed (excluding the wasm component): at most **8 MB**
- a single binary (non-UTF-8) non-wasm entry — an image, font, or signature
  blob — rejects the **whole archive**; besides the component, ship only UTF-8
  text entries
- no loose-JSON fallback: a canonical manifest or bootstrap entry is
  hard-required

What intake actually rejects: the size/count limits above, duplicate canonical
entries, manifest/bootstrap deep-equality mismatch, manifest validation
failures, and — because both endpoints build the immutable runtime archive
(`extension_package_runtime_archive.rs`) — symlinks, absolute paths, `.`/`..`
path segments, and duplicate paths that differ only in case. The browser
runtime loader repeats the path checks when it loads an admin/web archive.

Author packaging conventions — still enforce these in your packaging script
so a bad archive fails before upload:

- no absolute paths
- no `../`
- no symlinks
- no duplicate case-insensitive paths
- no hidden secret files (intake does not scan for them)

## Packaging script requirements

Prefer a script that:

- reads one canonical manifest source
- writes `cedros-extension.manifest.json`
- writes `cedros-extension.bootstrap.json` with the same manifest
- verifies package names and versions match `packageFamily`
- verifies fixed registration exports exist in package metadata
- verifies `workspace/react/package.json` and the `./admin/register` export are
  included when `adminModules[]` is non-empty
- verifies the admin and web entry graphs resolve only to packaged runtime
  files, use literal dynamic-import specifiers, and do not cross-import
  admin-only code into the web surface
- validates every resolved executable ESM chunk for unsupported Node globals,
  including dependency and lazy chunks
- when the admin runtime ships a stylesheet, fails if the built admin entry
  (plus its chunks) is missing the inlined CSS sentinels — include a sentinel
  from an `@import`-nested sheet so a build-config regression cannot pass
- includes `cedros-extension.server.wasm` when any
  `surfaces.server.routes/jobs/...` are declared
- excludes dependency/build/cache directories
- writes a deterministic ZIP name like
  `${extensionId}-${version}.cedros-extension.zip` — the name is pure
  convention; intake is name-agnostic
- writes entries in stable sorted order with normalized timestamps when the ZIP
  library supports it
- prints a SHA-256 checksum, the archive entries, and byte size
- prints the final enclosing ZIP digest for the release packet; do not put that
  digest inside the manifest because the manifest is part of the ZIP bytes
- verifies every `supplyChain.artifacts[]` digest that points at a package
  member artifact outside the enclosing ZIP
- verifies every external evidence URL has a matching `sha256` field
- refuses `verified`, `marketplaceVerified`, `trusted`, or `slsaLevel` at
  **every nesting level** of `supplyChain` — top level, `.source`, `.build`,
  `.sbom`, and each `artifacts[]` entry. Host intake (UI and server) rejects
  these fields at every level too, so this check mirrors host enforcement.

### Operator offline verification

Before an archive reaches a database or deployment, verify its immutable
handoff identities and compatibility with the current host build:

```sh
cargo run --manifest-path server/Cargo.toml \
  --features wasm-extensions \
  --bin cedros-extension-verify -- \
  --archive "$EXTENSION_ARCHIVE" \
  --expected-archive-sha256 "$ARCHIVE_SHA256" \
  --expected-wasm-sha256 "$WASM_SHA256" \
  --expected-extension-id "$EXTENSION_ID" \
  --expected-version "$EXTENSION_VERSION"
```

The command requires no database or provider credentials. It fails closed on
archive, Wasm, extension-id, version, host-version/capability, or declared WIT
mismatch and emits credential-free JSON containing the verified identities and
declared routes, jobs, capabilities, and required capabilities. It does not
replace server install admission, which additionally loads the component and
reverse-binds its route/job exports before persistence.

The JSON report also contains the normalized public SEO route inventory,
active schema adapter contract version, finite parameter policy, and
`seoConformanceSha256` bound to the enclosing archive digest. Extension release
CI must archive this report and sign it with the organization-approved
artifact-signing system. Cedros includes a dependency-free Ed25519 reference
implementation that canonicalizes and signs a statement binding the archive,
full verifier report, extension identity/version, schema contract, route
inventory digest, signing time, and public-key fingerprint:

```sh
node scripts/extension-seo-evidence-signature.mjs sign \
  --archive "$EXTENSION_ARCHIVE" \
  --report "$VERIFICATION_REPORT" \
  --private-key "$EXTENSION_SIGNING_KEY" \
  --output "$SIGNED_SEO_EVIDENCE"

node scripts/extension-seo-evidence-signature.mjs verify \
  --archive "$EXTENSION_ARCHIVE" \
  --report "$VERIFICATION_REPORT" \
  --signature "$SIGNED_SEO_EVIDENCE" \
  --public-key "$TRUSTED_EXTENSION_PUBLIC_KEY" \
  --max-age-hours 168
```

Private signing keys must remain in the extension owner's protected release
environment; Cedros release CI receives only the public key, archive, verifier
report, and detached evidence envelope. A managed Sigstore/Cosign policy may be
used instead, but it must bind and verify the same immutable statement fields.
Cedros release pipelines must verify the signature and pass the signed report's
archive and conformance digests back to this verifier before accepting the
separately shipped package; private source does not need to enter this
repository. Use `--expected-archive-sha256` and
`--expected-seo-conformance-sha256`; either mismatch fails closed.

### Browser execution regression

Run at least one test in a real browser against the final ZIP graph. Also keep
a fast module-import regression that removes any test runner's Node shim before
importing the packaged registration entrypoint:

```js
test("packaged admin registration imports without Node globals", async () => {
  const priorProcess = globalThis.process;
  try {
    Reflect.deleteProperty(globalThis, "process");
    await import("../dist/admin/register.js");
  } finally {
    if (priorProcess !== undefined) globalThis.process = priorProcess;
  }
});
```

Run the equivalent test for `./web/register` when published. The import must
traverse the packaged graph used by the ZIP, not source files or a dev-server
transform. A headless-browser smoke is the authoritative check because Node
test runners can supply other globals accidentally.

Example Node script outline (see
`examples/minimal-extension/scripts/package-extension.js` for the runnable
version):

```js
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { zipSync, strToU8 } from "fflate";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const manifest = JSON.parse(
  readFileSync(resolve(root, "cedros-extension.manifest.json"), "utf8")
);
const bootstrap = { manifest };
const reactPackage = JSON.parse(
  readFileSync(resolve(root, "packages/react/package.json"), "utf8")
);
const nativePackage = JSON.parse(
  readFileSync(resolve(root, "packages/react-native/package.json"), "utf8")
);
const entries = {
  "cedros-extension.manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
  "cedros-extension.bootstrap.json": strToU8(JSON.stringify(bootstrap, null, 2)),
  "workspace/README.md": readBytes("README.md"),
  "workspace/server/Cargo.toml": readBytes("packages/server/Cargo.toml"),
  "workspace/server/src/lib.rs": readBytes("packages/server/src/lib.rs"),
  "workspace/react/package.json": readBytes("packages/react/package.json"),
  "workspace/react/tsconfig.json": readBytes("packages/react/tsconfig.json"),
  "workspace/react/src/generated-manifest.ts": readBytes("packages/react/src/generated-manifest.ts"),
  "workspace/react/src/manifest.ts": readBytes("packages/react/src/manifest.ts"),
  "workspace/react/src/bootstrap.ts": readBytes("packages/react/src/bootstrap.ts"),
  "workspace/react/src/admin/register.ts": readBytes("packages/react/src/admin/register.ts"),
  "workspace/react/src/web/register.ts": readBytes("packages/react/src/web/register.ts"),
  "workspace/react-native/package.json": readBytes("packages/react-native/package.json"),
  "workspace/react-native/tsconfig.json": readBytes("packages/react-native/tsconfig.json"),
  "workspace/react-native/src/generated-manifest.ts": readBytes("packages/react-native/src/generated-manifest.ts"),
  "workspace/react-native/src/manifest.ts": readBytes("packages/react-native/src/manifest.ts"),
  "workspace/react-native/src/bootstrap.ts": readBytes("packages/react-native/src/bootstrap.ts"),
  "workspace/react-native/src/register.ts": readBytes("packages/react-native/src/register.ts")
};

if ((manifest.adminModules ?? []).length > 0) {
  addDirectoryEntries(entries, "workspace/react/dist", "packages/react/dist");
  assertEntryExists(
    entries,
    packageExportArchivePath("workspace/react", reactPackage.exports?.["./admin/register"]),
    "React admin runtime export"
  );
}

if (
  (manifest.surfaces?.native?.screenIds?.length ?? 0) > 0 ||
  (manifest.surfaces?.native?.stackIds?.length ?? 0) > 0 ||
  (manifest.surfaces?.native?.providerIds?.length ?? 0) > 0
) {
  addDirectoryEntries(entries, "workspace/react-native/dist", "packages/react-native/dist");
  assertEntryExists(
    entries,
    packageExportArchivePath("workspace/react-native", nativePackage.exports?.["./register"]),
    "React Native runtime export"
  );
}

assertDeepEqual(bootstrap.manifest, manifest, "bootstrap manifest");
for (const entryName of Object.keys(entries)) validateArchivePath(entryName);

const fileName = `${manifest.extensionId}-${manifest.version}.cedros-extension.zip`;
const bytes = zipSync(Object.fromEntries(Object.entries(entries).sort()), { level: 6 });
const checksum = createHash("sha256").update(bytes).digest("hex");
verifySupplyChain(manifest);
mkdirSync(resolve(root, "dist"), { recursive: true });
writeFileSync(resolve(root, "dist", fileName), bytes);
console.log(`${fileName} sha256 ${checksum}`);

function readBytes(path) {
  return new Uint8Array(readFileSync(resolve(root, path)));
}

function addDirectoryEntries(target, archiveDir, localDir) {
  for (const entry of readdirSync(resolve(root, localDir), { withFileTypes: true })) {
    const localPath = `${localDir}/${entry.name}`;
    const archivePath = `${archiveDir}/${entry.name}`;
    if (entry.isDirectory()) {
      addDirectoryEntries(target, archivePath, localPath);
    } else if (entry.isFile()) {
      target[archivePath] = readBytes(localPath);
    }
  }
}

function assertDeepEqual(actual, expected, label) {
  if (JSON.stringify(actual) !== JSON.stringify(expected)) {
    throw new Error(`${label} mismatch`);
  }
}

function assertEntryExists(entries, archivePath, label) {
  if (!entries[archivePath]) {
    throw new Error(`${label} missing from archive at ${archivePath}`);
  }
}

function packageExportArchivePath(packageRoot, exportPath) {
  if (typeof exportPath !== "string" || !exportPath.startsWith("./")) {
    throw new Error(`invalid package export path ${String(exportPath)}`);
  }
  return `${packageRoot}/${exportPath.slice(2)}`;
}

function validateArchivePath(path) {
  if (path.startsWith("/") || path.includes("../") || path.includes("\\")) {
    throw new Error(`archive contains unsafe path "${path}"`);
  }
}

function verifySupplyChain(manifest) {
  const supplyChain = manifest.supplyChain;
  if (!supplyChain) return;
  refuseTrustFields(supplyChain, "supplyChain");
  refuseTrustFields(supplyChain.source, "supplyChain.source");
  refuseTrustFields(supplyChain.build, "supplyChain.build");
  refuseTrustFields(supplyChain.sbom, "supplyChain.sbom");
  verifyEvidencePair(supplyChain.build?.provenanceUrl, supplyChain.build?.provenanceSha256, "build provenance");
  verifyEvidencePair(supplyChain.sbom?.url, supplyChain.sbom?.sha256, "SBOM");
  for (const artifact of supplyChain.artifacts ?? []) {
    refuseTrustFields(artifact, `supplyChain.artifacts[${artifact.name}]`);
    if (artifact.packageRef && !["server", "react", "reactNative"].includes(artifact.packageRef)) {
      throw new Error(`${artifact.name} uses unsupported packageRef ${artifact.packageRef}`);
    }
    verifyEvidencePair(artifact.signatureUrl, artifact.signatureSha256, `${artifact.name} signature`);
    verifyEvidencePair(artifact.provenanceUrl, artifact.provenanceSha256, `${artifact.name} provenance`);
    verifyEvidencePair(artifact.sbomUrl, artifact.sbomSha256, `${artifact.name} SBOM`);
  }
}

function refuseTrustFields(node, label) {
  if (!node) return;
  for (const key of ["verified", "marketplaceVerified", "trusted", "slsaLevel"]) {
    if (Object.prototype.hasOwnProperty.call(node, key)) {
      throw new Error(`${label}.${key} is computed by Cedros, not declared`);
    }
  }
}

function verifyEvidencePair(url, sha256, label) {
  if (url && !sha256) throw new Error(`${label} URL requires a SHA-256 digest`);
  if (sha256 && !url) throw new Error(`${label} digest requires a URL`);
}
```

Adapt the outline to the team's package manager and repository layout. Keep the
real script explicit about every included path rather than zipping the whole
repository.

The outline sorts entries but may not normalize timestamps depending on the ZIP
library. A production script should set entry timestamps explicitly when the
library supports it, or document that the checksum is not reproducible across
runs.

## Wasm component checklist

If the manifest declares `surfaces.server.routes` or `surfaces.server.jobs`,
include `cedros-extension.server.wasm`. Omitting it **does** hard-fail both
intake endpoints with a 400 ("declares server routes or jobs but the archive
has no cedros-extension.server.wasm"). A manifest with no server routes or
jobs may omit the component and installs as UI-only; declared
settings/database/providers/customer-tags/telemetry grants are then inert
rather than rejected, so ship a component whenever you declare them. The
other hard-fail is the
reverse case: when a component **is** present it must export and bind every
declared route/job id (`ensure_manifest_server_bindings_present`), so verify
the component's exports/capability imports match the manifest.

If the wasm component handles routes, verify `handle-route` handlers expect
the **full** request path including the `/extensions/{id}` mount prefix —
handlers that match only the bare subpath miss every request and return
extension-owned 404s.

## Upload flow

Archive intake, install, and enable are distinct lifecycle operations. The
current Cedros admin local-upload surface and dev-archive bootstrap compose
them into one ordered flow: validate/import, refresh management inventory,
install, enable, then verify the extension is active. A compatibility,
dependency, permission, or provisioning blocker stops at its owning step and
must leave a visible management entry with the blocker; it must not disappear.

Visibility is source- and surface-specific:

| Package/state | Public catalog | Owner's Extensions management inventory |
|---|---:|---:|
| Public catalog package | visible when published | visible while installable or installed |
| Locally uploaded private package | not added to the public catalog | visible through review, install, enable, and afterward |
| Host-provisioned managed package | hidden | hidden; lifecycle is owned by the host |

`internal` means excluded from public discovery; it does not erase an
operator-uploaded private package from Installed Extensions.

Operator handoff should include:

- archive file path
- SHA-256 checksum when available
- supply-chain verification status using one of the machine values from
  [`04-manifest-reference.md`](04-manifest-reference.md) (`not-provided`,
  `provided-unverified`, `source-unreachable`, `source-revision-matched`,
  `signed-source-verified`, `provenance-verified`, `sbom-present`,
  `artifact-signature-verified`, `rebuild-matched`, `verification-failed`, or
  `not-applicable`)
- extension id and version
- package-family source status
- required services/capabilities
- Wasm server component build/test result, if declared
- host-coordinated native, sidecar, managed provider, bespoke database/migration,
  settings, or content-model work still required
- validation command results

If Cedros marketplace or an operator verifier has already reviewed the
package, include a computed `supplyChainVerification` record in the handoff
packet or marketplace listing, not inside the manifest:

```json
{
  "status": "provided-unverified",
  "summary": "Author supplied source/provenance evidence; marketplace verification has not run.",
  "sourceRepositoryUrl": "https://github.com/example/extension",
  "sourceRevision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "checks": [
    {
      "checkId": "source-revision",
      "label": "Source revision",
      "status": "not-run"
    }
  ]
}
```

For marketplace-ready releases, replace the `provided-unverified` example with
the strongest status actually proven by the verifier and include one check row
per source, signature, SBOM, provenance, artifact digest, and rebuild check.

Upload through Cedros local package intake only after the archive validation
step passes locally. After packaging, produce operator install readiness,
release compatibility, and rollback planning artifacts in the same handoff
packet ([`23-release-versioning-and-upgrades.md`](23-release-versioning-and-upgrades.md)).

## Rules

- Do not generate the manifest twice from divergent sources.
- Production handoff archives must include both canonical root files.
- Do not include secrets or `.env` files.
- Do not include package-manager caches or dependency directories.
- Do not include `.git`, private CI logs, tokens, or raw provenance payloads
  that expose secrets or PII.
- Do not assume private package ZIPs are executable unless Cedros has provided
  a concrete binding path for them.
- Do not include more than one `cedros-extension.manifest.json` or more than
  one `cedros-extension.bootstrap.json` anywhere in the archive.
- Do not ship stale package versions that differ from the manifest without an
  explicit release note.
- Do not include symlinks, absolute paths, parent-directory paths, or hidden
  secret files.
- Compression level does not matter for correctness; deterministic file order,
  normalized timestamps, and checksum reporting matter for review.

Common failure messages:

- missing canonical manifest or bootstrap entry
- manifest/bootstrap deep equality mismatch
- duplicate canonical archive entry
- unsafe path such as absolute path, symlink, `../`, or a case-insensitive
  duplicate (both intake endpoints and the browser runtime loader)
- package version mismatch (packaging-script check only — intake does not
  compare runtime `package.json` versions against `packageFamily`)
- ZIP exceeds intake size or entry limits
- non-UTF-8 (binary) non-wasm entry rejected
- `supplyChain` self-asserts marketplace verification (at any nesting level)
- external evidence URL is missing its SHA-256 digest
- `supplyChain.artifacts[]` tries to self-reference the enclosing upload ZIP

## Acceptance checks

- ZIP opens as a valid archive.
- Archive includes canonical manifest/bootstrap entries (exactly one of each).
- Bootstrap manifest deep-equals the manifest entry.
- Manifest passes Cedros standalone extension validation.
- Package names and versions match `packageFamily`.
- Registration subpaths match the fixed Cedros SDK paths.
- When `adminModules[]` is non-empty, the archive includes
  `workspace/react/package.json`, `workspace/react/dist/**`, and the
  `exports["./admin/register"]` target file.
- Admin and web registration exports resolve to separately derived, complete
  relative import graphs; dynamic imports use static string literals and the
  web graph does not reach admin-only modules.
- Every module in both resolved graphs passes Node-global source validation,
  and the packaged registration entrypoint imports with `globalThis.process`
  absent.
- When the admin runtime has a stylesheet, the compiled `./admin/register`
  artifact in the archive contains the inlined CSS string and a top-level
  style-injection call.
- When server surfaces are declared, the archive includes
  `cedros-extension.server.wasm` and its exports match the declared route/job
  ids.
- Optional `supplyChain` evidence uses immutable source revisions, HTTPS URLs,
  SHA-256 digests, and no author-supplied verification result.
- Final archive checksum is reported in the handoff packet, not embedded in
  the manifest inside that same archive.
- Archive size and entry counts are under intake limits.
- Uploading the ZIP creates or updates the intended management inventory entry;
  after successful enable it remains listed as installed and active.

Next: [`23-release-versioning-and-upgrades.md`](23-release-versioning-and-upgrades.md).
