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
.azpfile 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 optionalEvery 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/codepayload and nocapabilities— just anappheader (see extension-manifest.md § app and companion-app.md). The tree ismanifest.json+LICENSE(+ optionalpreview).mcp— an MCP server a host's MCP client connects to. A header (anmcpblock) that declares how to reach the server; no/code, nocapabilities(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 (apackblock); no/code, nocapabilities, 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
.abrbecomesassets/*.pngplus 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.
type | Wire format |
|---|---|
brush, pattern, stamp | PNG (8-bit gray or RGBA) |
lut | .cube (Adobe Cube LUT) |
shader | GLSL or ISF source (UTF-8 text); params.format = glsl | isf |
transition | GLSL source (UTF-8 text); params.format = gl-transition |
image | PNG, JPEG, or WebP |
vector | SVG — the static SVG 1.1 / SVG Tiny 1.2 profile (see below) |
font | SFNT (.otf / .ttf), or WOFF2 |
mesh, material | glTF 2.0 — .glb (binary) or .gltf with its referenced resources bundled under /assets; a material travels as a glTF material |
hdri | Radiance .hdr, or OpenEXR .exr |
audio | WAV / PCM (the uncompressed floor), or Ogg/Opus, FLAC, MP3 |
video | MP4 (H.264 + AAC), or WebM (VP9 + Opus) |
motion | JSON keyframe/animation preset — host-neutral, keys per extension-manifest.md params conventions |
palette | JSON — see § Palette format |
overlay | PNG (or APNG / animated WebP) |
template | JSON layout referencing bundled image/vector assets, with fillable slots under params |
tflite, litert, onnx, sherpa-bundle | the 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:
{
"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#RGBis 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).finishis 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 withparams.inputTransfer—srgb(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.jsonMUST carry a digest of every payload file underfiles(path → digest). The digest issha256-<hex>— the literal prefixsha256-followed by the lowercase-hex SHA-256 of the file's exact bytes (e.g.sha256-2c26b46b68ffc6…). Only SHA-256 is defined for0.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 fixedsha256-+lowercase-hex form@azphalt/azp'sdigest()emits.) - Authenticity:
signature.jsonis a detached Ed25519 signature over the exactmanifest.jsonbyte 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 readmanifest.jsonfrom the container as-is and check the signature against those bytes. Signing the manifest transitively signs the payload through thefilesdigests. The public key is base64 SPKI,algised25519. A worked test vector — private key, public key, manifest bytes, and expected signature — lives atpackages/azp/test/vectors/signature-vector.jsonfor cross-implementation checks. Implemented in@azphalt/azp(signAzp/generateSigningKey);verifyAzpvalidates it when present. - Trust model. A signature alone is tamper-evidence, not identity —
verifyAzponly 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 insignature.jsonundercountersignature(publicKey,signature, optionalkeyId, optional nestedcountersignature); 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:
- Verify file-level integrity via the
manifest.filesdigests (enforced byverifyAzp()), and reject any failure. - For a signed package, verify the signature (
verifyAzpfolds this intook), and — where provenance matters — check the signer against a trust store viaverifyTrust. - 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").
- For any remote asset — an
assets[]entry (orfiles[]member) delivered byremoteUrlrather than bundled — verify the downloaded bytes against itschecksum(sha256-<hex>) before use, and reject on mismatch. These bytes aren't in thefilesmap, soverifyAzpcan't cover them; the check happens lazily at fetch time (see extension-manifest.md § assets). The signed manifest makes thechecksumvalue 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
countersignaturechaining 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 ownversionis semver.