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
hashand an Ed25519signaturemade with the Manager's signing key. - Keeps every version. A
(project, name, version)triple is immutable: uploading it again returns409 Conflict. - Serves a feed per application (
/pilet-feed/...) listing exactly one version of each enabled pilet, with a downloadlink,hash,signatureandkeyId. - Serves bundle bytes to browsers (
/pilet-bundle/...) and to operators (/pilets/...). - Keeps frontend settings in sync: an upload adds
name@versiontoAvailableModuleson every frontend settings row of the project; a delete removes that version fromAvailableModulesandEnabledModules. - 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.
| Family | Routes | Caller | Authentication |
|---|---|---|---|
| Browser read path | GET /pilet-feed/:applicationId, GET /pilet-feed/:applicationId/:tenantId, GET /pilet-bundle/:applicationId/:projectId/:name/:version, GET /pilet-keys/:applicationId | An end user's browser, through the proxy | Proxy secret (X-Proxy-Auth) only. No user credential. |
| Management API | GET /pilets/:projectId, GET/POST/DELETE /pilets/:projectId/:name/:version | The Manager UI; the CLI (pfy pilet ...) and CI only against a Manager reached without a rendered vhost | Proxy 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
GET /pilet-feed/:applicationId
GET /pilet-feed/:applicationId/:tenantIdThe 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):
{
"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: trueon the package) and listed in the application's mergedEnabledModules. - 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.
linkis root-relative and every segment is percent-encoded, so a scoped name such as@myapp/dashboardbecomes%40myapp%2Fdashboard. Fetch it as given; do not build it yourself.signatureandkeyIdare 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:
GET /pilet-bundle/:applicationId/:projectId/:name/:versionThe 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
GET /pilet-keys/:applicationIdReturns {"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
POST /pilets/:projectId/:name/:version?description=...
Content-Type: multipart/form-data
file=<package file>- The file goes in the multipart field
file. namemay 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 uploaddoes this for you.- Content type is taken from the filename:
.gzor.tgzis stored asapplication/gzip, anything else asapplication/javascript. - Maximum size is
pilets.max_upload_bytes(default 50 MiB); larger uploads get413. - An existing
(project, name, version)gets409 Conflict. Versions are immutable; upload a new version instead.
Response 200:
{
"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
GET /pilets/:projectId/:name/:versionReturns 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
GET /pilets/:projectId?enabled=trueenabled is optional (true or false).
{
"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)
DELETE /pilets/:projectId/:name/:versionA 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
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
query GetPilet($projectId: ID!, $name: String!, $version: String!) {
piletPackage(projectId: $projectId, name: $name, version: $version) {
id
name
version
description
enabled
}
}Update metadata, enable, disable
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):
| Strategy | Effect |
|---|---|
PINNED | Everyone gets stableVersion. |
PERCENTAGE | A deterministic percentage of applications get canaryVersion; the rest get stableVersion. |
COHORT | The applications listed in cohortIds get canaryVersion; the rest get stableVersion. |
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:
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:
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
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
| Field | Type | Notes |
|---|---|---|
id | UUID | |
name | String | Plain or scoped (@scope/name) |
version | String | Semantic version; part of the immutable key |
description | String, optional | |
content | Bytes | The package. Not exposed over GraphQL |
content_type | String | application/gzip (default) or application/javascript |
size | Integer | Bytes |
hash | String | Hex SHA-256 of content |
signature | Bytes, optional | Detached Ed25519 signature over content. Not exposed over GraphQL |
signing_key_id | String, optional | Which key produced signature; served as keyId |
metadata | JSON, optional | |
enabled | Boolean | false after a delete |
created_at / updated_at | DateTime |
Each package belongs to one project and records the user who uploaded it (creator) and who last changed it (editor).
Related
- Pilet Management Guide: the UI for uploads, enabling and rollouts
- Frontend Settings: where
EnabledModulesis edited - Configuration Layers: how project, tenant and application settings merge
- Dedicated Tenants: tenant-scoped feed and pins
- Authentication: PATs, sessions and the proxy secret