Skip to content

Pilet API ​

A pilet is a packaged microfrontend module that an application shell loads at runtime (the term comes from Piral). The Manager stores pilet packages per project, decides which version each application serves, and publishes a Piral-compatible feed that the browser reads. The HTTP routes and GraphQL operations behind that are below, for people who upload pilets from CI, build a frontend shell, or debug why a pilet does not load.

What the Manager does with a pilet ​

  • Stores the uploaded bytes (a gzipped tarball or a single JavaScript file), together with a SHA-256 hash and an Ed25519 signature made with the Manager's signing key.
  • Keeps every version. A (project, name, version) triple is immutable: uploading it again returns 409 Conflict.
  • Serves a feed per application (/pilet-feed/...) listing exactly one version of each enabled pilet, with a download link, hash, signature and keyId.
  • Serves bundle bytes to browsers (/pilet-bundle/...) and to operators (/pilets/...).
  • Keeps frontend settings in sync: an upload adds name@version to AvailableModules on every frontend settings row of the project; a delete removes that version from AvailableModules and EnabledModules.
  • Records uploads and deletes in the project's audit log, in the same transaction as the change.

Pilets belong to a project, not an application, so several applications in the same project can share them. Which pilets an application actually loads is decided by the EnabledModules list in its merged frontend configuration (project → application → tenant → tenant × application, where the application layer contributes module fields only; see Configuration Layers) and by any rollout configured for the pilet.

Signature and hash are both checked in the browser

@productifyfw/core's loadPilets verifies the SHA-256 against the feed's hash and the Ed25519 signature against a key from the injected window.__PRODUCTIFY__.config.pilet_signing_keys trust anchor before it runs a bundle. See Pilet Loading.

In production a Manager without pilets.signing.private_key / key_id rejects uploads rather than storing an unsigned bundle the loader would refuse. In development a fake dev- key is derived automatically. See Configuration.

Two route families ​

The routes have different callers, different authentication, and must not be confused.

FamilyRoutesCallerAuthentication
Browser read pathGET /pilet-feed/:applicationId, GET /pilet-feed/:applicationId/:tenantId, GET /pilet-bundle/:applicationId/:projectId/:name/:version, GET /pilet-keys/:applicationIdAn end user's browser, through the proxyProxy secret (X-Proxy-Auth) only. No user credential.
Management APIGET /pilets/:projectId, GET/POST/DELETE /pilets/:projectId/:name/:versionThe Manager UI; the CLI (pfy pilet ...) and CI only against a Manager reached without a rendered vhostProxy secret, then a proxy-forwarded browser session, then project membership

The management API requires the caller to be a member of the project (user2project), so it can never serve an application's end users, who are tenant members with no project role. A browser always goes through the feed and /pilet-bundle/*.

A PAT alone does not authenticate /pilets/*

The route group checks the proxy secret, then the PAT, then the identity headers. The identity check reads only the X-Token-Subject / X-Token-User-Email / X-Token-User-Name headers the proxy injects after a portal login, and never the identity the PAT check established. The Manager's own vhost does not run productify_auth, the proxy middleware that turns a PAT into those headers: it is per-application and fails closed without an application access decision, which the Manager vhost has none of. So a pfy pilet upload through a rendered Manager vhost is answered 401 no authenticated user no matter how valid the PAT is. See Known Issues. The Manager UI works because its same-origin fetches carry the portal cookie and fall through the vhost's authorize with catch-all, which injects the headers.

/pilets/* is not given its own pre-portal handle on the Manager's vhost, and that is deliberate: a handle there would skip the portal and make every upload from the UI unreachable. It falls through to the catch-all instead. /pilet-feed/* and /language-packs/* do get pre-portal handles, because they are the anonymous browser bootstrap and must be servable with no identity at all.

Browser read path ​

Feed ​

http
GET /pilet-feed/:applicationId
GET /pilet-feed/:applicationId/:tenantId

The Manager resolves the project from the application, so the shell does not need to know it. The tenant-scoped form is what a dedicated tenant's vhost uses; it applies that tenant's pilets.pins. On a shared vhost the proxy rewrites the browser's /pilet-feed request to the application-scoped form.

Response (Piral v2 feed):

json
{
  "items": [
    {
      "name": "dashboard",
      "version": "1.0.0",
      "link": "/pilets/550e8400-e29b-41d4-a716-446655440000/dashboard/1.0.0",
      "hash": "abc123...",
      "signature": "<base64 detached Ed25519 signature>",
      "keyId": "prod-2026-01",
      "spec": "v2"
    }
  ]
}

What the feed contains:

  • Only pilets that are enabled (enabled: true on the package) and listed in the application's merged EnabledModules.
  • One version per pilet name. With no rollout configured that is the newest enabled version; otherwise the version the rollout resolver picks for this application (see Rollouts and pins). A pin or rollout that names a disabled version falls back to the newest enabled one.
  • link is root-relative and every segment is percent-encoded, so a scoped name such as @myapp/dashboard becomes %40myapp%2Fdashboard. Fetch it as given; do not build it yourself.
  • signature and keyId are omitted for a legacy unsigned package; the browser loader refuses such a package.

Bundle download ​

The link in the feed points at /pilets/{projectId}/{name}/{version}. On an application vhost rendered by the Manager the proxy does not forward that to the management API. It strips the prefix and rewrites it onto an anonymous, application-scoped route:

http
GET /pilet-bundle/:applicationId/:projectId/:name/:version

The application id is written by the renderer, not the client, so a vhost can only reach /pilet-bundle/<its own app>/.... The handler refuses a project id that is not that application's project, applies the same enablement filter as the feed, and answers a single 404 {"error":"Package not found"} for every rejection. Response headers match the management download below.

Do not route /pilets/* on an application vhost to the management API

It would answer 401/403 for every real browser (tenant users are not project members), and it would expose pilet upload and delete on every customer hostname.

Signing keys ​

http
GET /pilet-keys/:applicationId

Returns {"keys": {"<keyId>": "<base64 public key>", ...}}, the published (active and retired, not revoked) public keys. This is a debugging aid; the browser's trust anchor is the pilet_signing_keys value injected into the page, not this route.

Management API ​

All four routes: proxy secret first, then Authorization: Bearer <PAT> (or a forwarded session), then project membership. Listing and downloading need any project role; uploading and deleting need a role above readonly. System administrators pass every check. A caller who is not a member gets 403 {"error":"access denied to project"}.

Upload ​

http
POST /pilets/:projectId/:name/:version?description=...
Content-Type: multipart/form-data

file=<package file>
  • The file goes in the multipart field file.
  • name may be plain (dashboard) or scoped (@myapp/dashboard); letters, digits, -, _ and . are allowed in each part, up to 214 characters. Percent-encode the / of a scoped name in the URL (%40myapp%2Fdashboard); pfy pilet upload does this for you.
  • Content type is taken from the filename: .gz or .tgz is stored as application/gzip, anything else as application/javascript.
  • Maximum size is pilets.max_upload_bytes (default 50 MiB); larger uploads get 413.
  • An existing (project, name, version) gets 409 Conflict. Versions are immutable; upload a new version instead.

Response 200:

json
{
  "id": "uuid",
  "name": "dashboard",
  "version": "1.0.0",
  "size": 12345,
  "hash": "abc123...",
  "message": "Package uploaded successfully"
}

After a successful upload the Manager adds name@version to AvailableModules on every frontend settings row in the project (all layers). Versions accumulate; an older name@otherversion entry stays, because the rollout resolver, not this list, decides which version is served. The sync is best-effort: a failure is logged and does not fail the upload, since the package row is the authoritative state. The upload is recorded in the project audit log (entity type PiletPackage).

Download ​

http
GET /pilets/:projectId/:name/:version

Returns the package bytes with Content-Type, Content-Length, X-Pilet-Hash (hex SHA-256) and Cache-Control: public, max-age=31536000, immutable. A disabled or unknown package is 404 {"error":"Package not found or not enabled"}.

List ​

http
GET /pilets/:projectId?enabled=true

enabled is optional (true or false).

json
{
  "packages": [
    {
      "id": "uuid",
      "name": "dashboard",
      "version": "1.0.0",
      "description": "Dashboard module",
      "size": 12345,
      "hash": "abc123...",
      "enabled": true,
      "created_at": "2025-01-01T00:00:00Z",
      "updated_at": "2025-01-02T00:00:00Z"
    }
  ],
  "count": 1
}

Delete (disable) ​

http
DELETE /pilets/:projectId/:name/:version

A delete is a soft delete: the package is marked enabled: false and its bytes stay in the database. Response: 200 {"message":"Package disabled successfully"}. The Manager then removes the pilet from AvailableModules and EnabledModules on every frontend settings row in the project (best-effort, as for upload) and records the delete in the project audit log.

GraphQL API ​

The GraphQL surface manages metadata and rollouts; the bytes always go through the HTTP upload. Types and operations are in graph/pilet.graphql.

List packages ​

graphql
query ListPilets($projectId: ID!, $enabled: Boolean) {
  piletPackages(projectId: $projectId, enabled: $enabled) {
    packages {
      id
      name
      version
      description
      size
      hash
      enabled
      createdAt
      updatedAt
    }
    count
  }
}

Get one package ​

graphql
query GetPilet($projectId: ID!, $name: String!, $version: String!) {
  piletPackage(projectId: $projectId, name: $name, version: $version) {
    id
    name
    version
    description
    enabled
  }
}

Update metadata, enable, disable ​

graphql
mutation UpdatePilet(
  $projectId: ID!
  $name: String!
  $version: String!
  $description: String
  $enabled: Boolean
) {
  updatePiletPackage(
    projectId: $projectId
    name: $name
    version: $version
    description: $description
    enabled: $enabled
  ) {
    id
    description
    enabled
  }
}

mutation EnablePilet($projectId: ID!, $name: String!, $version: String!) {
  enablePiletPackage(projectId: $projectId, name: $name, version: $version) {
    id
    enabled
  }
}

mutation DisablePilet($projectId: ID!, $name: String!, $version: String!) {
  disablePiletPackage(projectId: $projectId, name: $name, version: $version) {
    id
    enabled
  }
}

Rollouts and pins ​

A rollout decides which stored version of a pilet each application is served. It is per (project, pilet name):

StrategyEffect
PINNEDEveryone gets stableVersion.
PERCENTAGEA deterministic percentage of applications get canaryVersion; the rest get stableVersion.
COHORTThe applications listed in cohortIds get canaryVersion; the rest get stableVersion.
graphql
query Rollout($projectId: ID!, $name: String!) {
  piletRollout(projectId: $projectId, name: $name) {
    piletName
    strategy
    stableVersion
    canaryVersion
    percentage
    cohortIds
    previousVersion
  }
}

mutation StartCanary($projectId: ID!, $name: String!) {
  setPiletRollout(
    projectId: $projectId
    name: $name
    strategy: PERCENTAGE
    stableVersion: "1.0.0"
    canaryVersion: "1.1.0"
    percentage: 10
  ) {
    strategy
    canaryVersion
    percentage
  }
}

mutation Promote($projectId: ID!, $name: String!) {
  promotePiletCanary(projectId: $projectId, name: $name) {
    stableVersion
  }
}

mutation Rollback($projectId: ID!, $name: String!) {
  rollbackPilet(projectId: $projectId, name: $name) {
    stableVersion
    previousVersion
  }
}

promotePiletCanary makes the canary the new stable and pins it; rollbackPilet re-pins previousVersion. Independently of rollouts, a dedicated tenant can pin individual pilets to a version through the pilets.pins map ({"<name>": "<version>"}) in its tenant × application configuration; the tenant-scoped feed applies those pins.

Enabling a pilet for an application ​

Uploading makes a pilet available; the application only loads it once its name is in EnabledModules. Edit the frontend settings at the layer you want (project, tenant or tenant × application) in the UI, or with GraphQL:

graphql
mutation EnableModules($settingsId: ID!) {
  updateFrontendModuleConfig(
    id: $settingsId
    input: { EnabledModules: ["dashboard", "analytics"] }
  ) {
    moduleConfig {
      AvailableModules
      EnabledModules
    }
  }
}

EnabledModules holds pilet names; which version is served is decided by the rollout, not by the enabled-modules list. See Frontend Settings.

Frontend integration ​

With @productifyfw/core you do not call these routes yourself: loadPilets fetches the feed, follows each link, verifies hash and signature, and evaluates the bundle. If you drive Piral directly, hand it the feed items and let the loader fetch each link verbatim:

javascript
import { createInstance } from "piral";

const instance = createInstance({
  requestPilets() {
    // The proxy rewrites /pilet-feed to the application-scoped Manager route.
    return fetch("/pilet-feed")
      .then((response) => response.json())
      .then((data) => data.items);
  },
});

Do not attach a Bearer token from the browser, and do not construct bundle URLs from EnabledModules; only the feed's link is guaranteed to resolve.

Uploading from CI ​

bash
PROJECT_ID="550e8400-e29b-41d4-a716-446655440000"
MANAGER_URL="https://manager.example.com"   # the proxied entrypoint

# Upload (name and version in the path; scoped names must be percent-encoded)
curl -sS -X POST \
  -H "Authorization: Bearer $PFY_MANAGER_TOKEN" \
  -F "file=@dashboard-1.0.0.tgz" \
  "$MANAGER_URL/pilets/$PROJECT_ID/dashboard/1.0.0?description=Dashboard+v1.0.0"

# Download it back and compare the digest
curl -sS -H "Authorization: Bearer $PFY_MANAGER_TOKEN" \
  "$MANAGER_URL/pilets/$PROJECT_ID/dashboard/1.0.0" -o verify.tgz
sha256sum dashboard-1.0.0.tgz verify.tgz

# List enabled pilets
curl -sS -H "Authorization: Bearer $PFY_MANAGER_TOKEN" \
  "$MANAGER_URL/pilets/$PROJECT_ID?enabled=true" | jq '.packages[] | {name, version, size}'

The CLI does the same with pfy pilet upload --project <slug> --name dashboard --version 1.0.0 --file dashboard-1.0.0.tgz; see pfy pilet.

Stored fields ​

FieldTypeNotes
idUUID
nameStringPlain or scoped (@scope/name)
versionStringSemantic version; part of the immutable key
descriptionString, optional
contentBytesThe package. Not exposed over GraphQL
content_typeStringapplication/gzip (default) or application/javascript
sizeIntegerBytes
hashStringHex SHA-256 of content
signatureBytes, optionalDetached Ed25519 signature over content. Not exposed over GraphQL
signing_key_idString, optionalWhich key produced signature; served as keyId
metadataJSON, optional
enabledBooleanfalse after a delete
created_at / updated_atDateTime

Each package belongs to one project and records the user who uploaded it (creator) and who last changed it (editor).