Skip to content

The .azp package format

Normative. The on-disk container an extension ships as: assets, code, manifest, signature. Format version 0.1 — pre-stable, may break.

Container

  • A .azp file MUST be a ZIP archive (Deflate or Store) — same shape as .vsix / .crx / .brushset. No container-level encryption.
  • Entries MUST use forward-slash paths, UTF-8 names, no absolute paths, no .. traversal. A host MUST reject a package with unsafe paths.
  • For reproducible signing, entries SHOULD be written in sorted path order with fixed timestamps.

Required tree

manifest.json     REQUIRED — the manifest (see extension-manifest.md)
signature.json    REQUIRED once signed — detached signature (§ Signing)
/assets/          asset payload: brush tips, grain maps, .cube LUTs, patterns, stamps
/code/            code payload: entry module(s) — .js and/or .wasm
/ui/              optional — UI schema files, if not inlined in the manifest
LICENSE           REQUIRED — SPDX-identified license text (author's choice)
README.md         optional

Every package MUST contain manifest.json and LICENSE. assets/ and/or code/ are present per kind.

Package kind

Declared in the manifest as kind: asset | code | mixed | app | mcp | pack.

  • asset — data only (brushes, LUTs, patterns). No executable code; a host MAY load it with no runtime. This is what importers produce.
  • code — one or more extensions on the sandbox (JS on QuickJS-in-WASM, or raw WASM).
  • mixed — both (e.g. a filter shipping its own LUTs).
  • app — a companion app (an Android app or PWA the host launches via a declared handoff). Carries no /code payload and no capabilities — just an app header (see extension-manifest.md § app and companion-app.md). The tree is manifest.json + LICENSE (+ optional preview).
  • mcp — an MCP server a host's MCP client connects to. A header (an mcp block) that declares how to reach the server; no /code, no capabilities (see extension-manifest.md § mcp and mcp-server.md).
  • pack — an extension pack: a curated set that references other packages by id (a recommended bundle or an app's base set). A header (a pack block); no /code, no capabilities, no assets. Each member is resolved and free/paid-gated individually (see extension-manifest.md § pack and pack.md).

Assets

  • Assets are normalized, host-neutral data — never a host-proprietary blob. Per-asset parameters (and a shader's or transition's declared inputs) live in the manifest.
  • Importers MUST emit this normalized form — an imported .abr becomes assets/*.png plus manifest params, not a repackaged .abr.

Wire formats

Each asset type (defined in extension-manifest.md § assets) pins the byte format its path (or remoteUrl) delivers, so any conforming host consumes identical bytes identically. Where a row lists several formats, a host MUST support the bold one (the interoperability floor) and MAY support the others; an importer SHOULD emit the bold one unless an asset requires otherwise.

typeWire format
brush, pattern, stampPNG (8-bit gray or RGBA)
lut.cube (Adobe Cube LUT)
shaderGLSL or ISF source (UTF-8 text); params.format = glsl | isf
transitionGLSL source (UTF-8 text); params.format = gl-transition
imagePNG, JPEG, or WebP
vectorSVG — the static SVG 1.1 / SVG Tiny 1.2 profile (see below)
fontSFNT (.otf / .ttf), or WOFF2
mesh, materialglTF 2.0.glb (binary) or .gltf with its referenced resources bundled under /assets; a material travels as a glTF material
hdriRadiance .hdr, or OpenEXR .exr
audioWAV / PCM (the uncompressed floor), or Ogg/Opus, FLAC, MP3
videoMP4 (H.264 + AAC), or WebM (VP9 + Opus)
motionJSON keyframe/animation preset — host-neutral, keys per extension-manifest.md params conventions
paletteJSON — see § Palette format
overlayPNG (or APNG / animated WebP)
templateJSON layout referencing bundled image/vector assets, with fillable slots under params
tflite, litert, onnx, sherpa-bundlethe model's native file(s) (.tflite / .onnx / …); large or multi-part models use the remote-asset / multi-file patterns in extension-manifest.md § assets

Static SVG profile (vector). A vector asset is a self-contained SVG document: no <script>, no active/interactive elements (<foreignObject>, <iframe>, <embed>, <object><foreignObject> in particular can embed arbitrary HTML/XHTML that slips past a naive <script> filter), no event-handler attributes (onload, on*), no external or remote references (<image href="http…">, external <use>, CSS @import, web-font URLs) — any raster it embeds MUST be a data: URI. A host MUST render it as inert graphics and MUST NOT execute script or fetch anything it references.

Self-containment (the moat). No asset's bytes may make a host fetch from the network or read outside the package. The only sanctioned remote path is the manifest's explicit remoteUrl large-file pattern, which is checksum-gated (extension-manifest.md § assets). A host MUST ignore — never fetch — any external reference embedded inside asset bytes.

Palette format

A palette asset (type: "palette") is a UTF-8 JSON file under /assets. It is an ordered list of named sRGB colors — order is significant (swatch order in a picker). Shape:

json
{
  "colors": [
    { "name": "Signal White", "color": "#F2F2F0" },
    { "name": "Wildfire Red", "color": "#C41E1E", "brand": "MTN 94", "sku": "RV-3020", "finish": "matte" }
  ]
}
  • colors — REQUIRED, an ordered array. Each entry:
    • name — REQUIRED, human-readable swatch name.
    • color — REQUIRED, sRGB as #RRGGBB (strictly 7 characters, e.g. #F2F2F0; shorthand #RGB is not permitted; lowercase or uppercase hex; no alpha — a palette entry is an opaque swatch).
    • brand, sku, finish — OPTIONAL colorant metadata mapping the swatch to a physical product (e.g. a spray-paint can). finish is an open vocabulary; blessed values: matte · satin · gloss · metallic · fluorescent. A host ignores keys it doesn't understand.

This lets the ecosystem distribute brand-accurate color sets (Montana, MTN, …) as portable .azp packages a host can match against real colorants.

LUT application

A .cube file carries only a table plus DOMAIN_MIN/DOMAIN_MAX; it says nothing about how to sample it, so two hosts can produce a visibly different grade from identical bytes. To make a LUT a portable look, a host applying a lut asset MUST:

  • Interpolate at least trilinearly. Trilinear is the floor every host matches. A host MAY offer tetrahedral (or another higher-quality) interpolation as an upgrade only if its result stays within a small, stated tolerance of trilinear — never a different look by default.
  • Assume sRGB input for a bare .cube (the pragmatic default). An author MAY declare the LUT's expected input transfer with params.inputTransfersrgb (default) · linear · log-c — and a host MUST convert the pixel into that transfer domain before sampling, then back afterward. (A LUT authored against log input but applied to sRGB-gamma pixels is simply wrong.)
  • Clamp out-of-domain. Coordinates outside [DOMAIN_MIN, DOMAIN_MAX] clamp to the domain edge (no extrapolation).
  • Leave alpha untouched. A LUT transforms RGB only; the alpha channel passes through unchanged.
  • Sample at ≥ 8-bit precision (higher is fine); coarse LUTs (LUT_3D_SIZE ≤ 17) especially depend on the interpolation floor to agree across hosts.

strength blend. After sampling, a host applies the lut asset's params.strength (number 0..1, default 1) as a per-channel dry/wet blend with the original RGB — out = lerp(original, graded, strength)blended in the same encoded domain the LUT is sampled in (sRGB-gamma by default, or the declared inputTransfer domain), not the host's own working/compositor space. Pinning the blend domain is what makes a fractional strength reproduce the same partial-grade on every host (a lerp in linear space gives a visibly different result). See extension-manifest.md § Per-type params. A host renders the control from the asset's canonical ui panel (a single strength slider) when present.

Code

  • Entry module(s) declared in the manifest (entry, runtime). runtime: js → an ES module on QuickJS-in-WASM; runtime: wasm → a WASM module against the host ABI.
  • Code has NO ambient authority (see capability-model.md). It reaches the editor only through host functions, for capabilities it declared and was granted.

Signing

  • Integrity: manifest.json MUST carry a digest of every payload file under files (path → digest). The digest is sha256-<hex> — the literal prefix sha256- followed by the lowercase-hex SHA-256 of the file's exact bytes (e.g. sha256-2c26b46b68ffc6…). Only SHA-256 is defined for 0.1. A host MUST recompute each file's digest and reject any mismatch before use. (This is not SRI base64 or multihash — it is the fixed sha256-+lowercase-hex form @azphalt/azp's digest() emits.)
  • Authenticity: signature.json is a detached Ed25519 signature over the exact manifest.json byte sequence stored in the archive, plus the signer's public key / key id. The bytes are signed and verified verbatim — there is no re-canonicalization (no JCS/RFC 8785, no whitespace normalization, no key reordering): a verifier MUST read manifest.json from the container as-is and check the signature against those bytes. Signing the manifest transitively signs the payload through the files digests. The public key is base64 SPKI, alg is ed25519. A worked test vector — private key, public key, manifest bytes, and expected signature — lives at packages/azp/test/vectors/signature-vector.json for cross-implementation checks. Implemented in @azphalt/azp (signAzp / generateSigningKey); verifyAzp validates it when present.
  • Trust model. A signature alone is tamper-evidence, not identity — verifyAzp only confirms it is internally consistent. Identity is decided by an explicit trusted-key set. A host holds a trust store of Ed25519 public keys; a package is trusted when either its signer key is directly in the store, or its signer key was counter-signed by a registry key in the store. A counter-signature is an Ed25519 signature over the vouched-for key's SPKI public-key bytes (the author at the base of the chain, or the previous counter-signer), stored in signature.json under countersignature (publicKey, signature, optional keyId, optional nested countersignature); it does not disturb the author's manifest signature, so integrity still verifies. This lets a host trust one registry instead of every author. Counter-signatures nest into a chain — a registry key can itself be counter-signed by a higher authority (countersignature.countersignature, each hop signing the key below it) — and a package is trusted when any key along the chain is in the store and every hop down to it verifies. Key distribution (how a host learns which keys/registries to trust) is out-of-band by design. Implemented in @azphalt/azp (verifyTrust(azp, store), countersign).

Signing status

Signing is implemented: @azphalt/azp produces signature.json (signAzp / generateSigningKey), verifyAzp validates it when present, and verifyTrust(azp, store) decides trust. Signing is still optional — the SDK, importers, and CLI emit unsigned packages by default. A host consuming .azp files MUST:

  1. Verify file-level integrity via the manifest.files digests (enforced by verifyAzp()), and reject any failure.
  2. For a signed package, verify the signature (verifyAzp folds this into ok), and — where provenance matters — check the signer against a trust store via verifyTrust.
  3. Treat an unsigned or untrusted-but-valid package as having integrity but no established provenance, and warn accordingly (e.g., "not from a trusted signer; install only from sources you trust").
  4. For any remote asset — an assets[] entry (or files[] member) delivered by remoteUrl rather than bundled — verify the downloaded bytes against its checksum (sha256-<hex>) before use, and reject on mismatch. These bytes aren't in the files map, so verifyAzp can't cover them; the check happens lazily at fetch time (see extension-manifest.md § assets). The signed manifest makes the checksum value itself trustworthy.

Publisher continuity (updates)

Integrity and a valid signature say a package is internally consistent and names its signer; they do not, on their own, say an update comes from the same party as the version already installed — a package id is just a string, and anyone can put com.acme.azphalt.thing in a manifest. To stop a third party from replacing an installed extension via a same-id package, a host that supports updating an installed extension MUST enforce publisher continuity:

  • On first install of an id, record ("pin") the signer's SPKI public key (trust-on-first-use). An unsigned first install pins nothing.
  • On any later install/update of the same id, the new package's signer MUST equal the pinned key. A different key — or a signed→unsigned regression — MUST be rejected as a publisher change rather than silently applied. The rejection is distinct from the generic "untrusted signer" warning: it means "not the party you installed from."
  • A host MAY allow the user to approve a publisher change (a legitimate key rotation) explicitly, after which it re-pins to the new key. A registry countersignature chaining the new key to the old (or to a trusted registry) MAY be accepted automatically in place of a manual prompt.

Because only the holder of the pinned private key can produce a package whose signature verifies against the pinned public key, this makes "only the original publisher can push an accepted update" enforceable offline, without a central authority. Publishers SHOULD therefore sign at package-creation time (keep one signing key per publisher; store the PKCS8 PEM private key as a CI secret and sign the built .azp with signAzp before publishing) so the very first release a user installs establishes the pin. Reference host implementation: Guillotine's AzpPublisherPins + AzpModelInstall.

Versioning

  • azphalt (format version) gates compatibility. The package's own version is semver.