cfx-uploader
v1.3.2
Published
Upload CFX Portal assets from GitHub releases
Maintainers
Readme
CFX Uploader
Upload CFX Portal asset versions directly from GitHub releases.
CFX Uploader is designed for automated FiveM/RedM resource publishing. It downloads a GitHub release archive, rebuilds the ZIP expected by CFX Portal, authenticates with a CFX passkey or username/password plus 2FA, and uploads the new asset version.
It can be used as:
- a Node.js library for server-side integrations such as Nuxt webhooks
- an HTTP CLI for VPS, CI, and automation
- a browser CLI for debugging the CFX Portal UI flow
The recommended production path is the library or HTTP CLI. Both use Puppeteer only for the initial CFX authentication, then upload through portal-api.cfx.re. An optional encrypted session cache avoids reopening Puppeteer while the CFX session remains valid.
Temporary files are always cleaned up after a run, including failed runs: downloaded release archives, extraction folders, and generated upload ZIPs are not kept.
Table of Contents
- Install
- Quick Start
- Password Authentication
- Passkey Setup
- Resource Configuration
- Library Usage
- CLI Usage
- Release Types
- Version Rules
- CFX 5-Version Limit
- Changelog
- Environment Variables
- Security Notes
- Error Handling
- Troubleshooting
- Additional Documentation
- GitHub Actions
- Project Structure
- Scripts
Install
npm install cfx-uploaderThe package is CommonJS. The documentation uses ESM imports, but CommonJS applications can use:
const { createUploader, checkAuthentication, upload } = require('cfx-uploader');Requirements:
- Node.js
18+ - npm
- a GitHub token with access to the target repository releases
- a CFX passkey credential generated by this package, or CFX username/password credentials with 2FA
Quick Start
- Generate a CFX passkey credential:
npx cfx-uploader-register-passkey- Add
cfx_uploader.jsonto the root of each resource repository:
{
"portalName": "Housing",
"foldersToZip": ["jo_housing"]
}- Upload from server-side code:
import { upload } from 'cfx-uploader';
await upload({
repository: 'Jump-On-Studios/RedM-jo_housing',
releaseTag: 'v1.1.2',
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
});For password authentication, configure asynchronous email-verification and 2FA providers. The providers can wait for a console, Discord workflow, webhook, or any other external input:
import path from 'node:path';
import { createUploader } from 'cfx-uploader';
const uploader = createUploader({
githubToken: process.env.GITHUB_TOKEN,
auth: {
method: 'password',
email: process.env.CFX_UPLOADER_EMAIL,
password: process.env.CFX_UPLOADER_PASSWORD,
emailVerificationLinkProvider: async ({ attempt, timeoutMs }) => {
return await getEmailLoginLinkFromYourWorkflow({ attempt, timeoutMs });
},
twoFactorCodeProvider: async ({ attempt, timeoutMs }) => {
return await getCodeFromYourWorkflow({ attempt, timeoutMs });
},
},
sessionCachePath: path.resolve('.cfx-uploader/session.enc'),
sessionEncryptionKey: process.env.CFX_UPLOADER_SESSION_KEY,
twoFactorTimeoutMs: 10 * 60 * 1000,
emailVerificationTimeoutMs: 10 * 60 * 1000,
browserProfilePath: path.resolve('.cfx-uploader/browser-profile'),
headless: true,
});
await uploader.upload({
repository: 'Jump-On-Studios/RedM-jo_housing',
releaseTag: 'v1.1.2',
});See Password Authentication for the complete provider contracts, first-login flow, cache behavior, error codes, and production guidance.
To validate authentication without downloading or uploading a release, run:
npx cfx-uploader-auth --auth-method=passwordThe command uses the configured encrypted session cache first, then performs one fresh browser authentication when necessary.
Password Authentication
Password authentication is supported by the public upload(), createUploader(), and checkAuthentication() APIs, and by the HTTP and authentication-check CLIs. It is not part of the legacy uploadBrowser flow.
Required configuration
Library mode requires an auth object:
const auth = {
method: 'password',
email: process.env.CFX_UPLOADER_EMAIL,
password: process.env.CFX_UPLOADER_PASSWORD,
async emailVerificationLinkProvider({ attempt, timeoutMs }) {
return await waitForCfxEmailLoginLink({ attempt, timeoutMs });
},
async twoFactorCodeProvider({ attempt, timeoutMs }) {
return await waitForCfxTwoFactorCode({ attempt, timeoutMs });
},
};Provider contracts:
| Provider | When called | Required return value |
|---|---|---|
| emailVerificationLinkProvider | Once when CFX reports a new device or location. Strongly recommended for headless and server environments. | An HTTPS URL on the exact hostname forum.cfx.re, with path /session/email-login/<32 hexadecimal characters>. A query string or trailing slash is accepted; credentials and fragments are rejected. |
| twoFactorCodeProvider | Once after either supported CFX 2FA screen becomes visible. | A string containing exactly six digits. |
Providers may remain pending while another process collects input from a private Discord interaction, admin UI, secret inbox integration, or terminal. They must not retry the CFX login themselves. The package controls the browser transition and calls each provider at most once per authentication attempt.
emailVerificationLinkProvider is technically optional because known browsers may go directly from password login to 2FA. In production, configure it anyway: CFX can require email verification whenever it considers the Chromium profile a new device or location.
Browser flow
A fresh password authentication follows these states:
- Open CFX Portal and enter the Forum SSO handoff.
- Fill the visible modern Forum password form and submit it once.
- Stop immediately if CFX reports a login rate limit.
- If CFX reports a new device or location, request one email-login link and open it in the same Chromium page.
- Reject an expired or already-consumed email link without retrying it.
- Detect either the classic password-login 2FA form or the email-link 2FA form.
- Request one six-digit code and submit the owning form once.
- Require an authenticated Forum or Portal DOM state.
- Validate the session with
GET /v1/me/assets. - If Portal cookies are not ready or the API returns
401/403, perform one Portal SSO handoff and validate once more. - Write the encrypted session cache only after API validation succeeds.
The browser may briefly display an unrecognized empty Forum shell between opening the email link and rendering the 2FA form. The workflow keeps observing DOM state and does not treat that transient page as success.
Session cache and browser profile
For unattended use, configure both an encrypted session cache and a persistent Chromium profile:
const uploader = createUploader({
githubToken: process.env.GITHUB_TOKEN,
auth,
sessionCachePath: '/var/lib/cfx-uploader/session.enc',
sessionEncryptionKey: process.env.CFX_UPLOADER_SESSION_KEY,
browserProfilePath: '/var/lib/cfx-uploader/chromium-profile',
headless: true,
});sessionCachePathstores encrypted CFX cookies and their User-Agent. No cache is created when this option is omitted.sessionEncryptionKeyis required whenever a cache path is configured.browserProfilePathpreserves the browser identity CFX saw during fresh authentication. It does not replace the encrypted session cache.- Use one cache path and one browser profile per CFX account and environment.
- Do not run multiple unrelated accounts against the same paths.
Use a high-entropy cache key from your secret manager. For example:
openssl rand -base64 32On every run, the cache is decrypted and validated against the CFX API. A valid cache skips Puppeteer and sets sessionReused: true; checkAuthentication() reports authMethod: 'cached', while an upload may retain the configured fresh-auth method in authMethod. An authentication 401/403 invalidates the cache and starts one fresh browser authentication. Other API and network failures are surfaced without deleting a potentially valid cache.
Authentication check
Validate credentials and refresh the cache without downloading a GitHub release:
import { checkAuthentication } from 'cfx-uploader';
const result = await checkAuthentication({
auth,
sessionCachePath: '/var/lib/cfx-uploader/session.enc',
sessionEncryptionKey: process.env.CFX_UPLOADER_SESSION_KEY,
browserProfilePath: '/var/lib/cfx-uploader/chromium-profile',
headless: true,
});
console.log(result);
// Fresh login: { authMethod: 'password', sessionReused: false }
// Valid cache: { authMethod: 'cached', sessionReused: true }For an interactive terminal:
npx cfx-uploader-auth --auth-method=passwordThe CLI reads password configuration from environment variables and supplies console providers for both the email-login link and six-digit 2FA code. It requires a real TTY when fresh authentication needs either value.
Production checklist
- Keep the password, session encryption key, email-login link, and 2FA code in private secret or interaction channels.
- Persist writable cache and Chromium-profile directories across process restarts.
- Give each CFX account/environment its own cache and profile paths.
- Implement both asynchronous providers even if a known browser currently skips email verification.
- Set provider timeouts long enough for the operator or external workflow to respond.
- Run
checkAuthentication()during deployment or operational checks, before handling a release event. - Handle the stable error codes below without automatic login retries.
Password authentication errors
Errors are thrown without automatic password retries. Check error.code for stable authentication conditions:
try {
await uploader.upload({ repository, releaseTag });
} catch (error) {
if (error.code === 'CFX_LOGIN_RATE_LIMITED') {
// Wait for the CFX cooldown. Do not retry immediately.
} else if (error.code === 'CFX_EMAIL_LOGIN_LINK_INVALID') {
// Ask the user for a newly generated link, then start a new authentication.
} else if (error.code === 'CFX_EMAIL_VERIFICATION_TIMEOUT') {
// The email challenge was not completed before the configured timeout.
}
throw error;
}Never include the email-login URL, password, 2FA code, cookies, or cache contents in application logs. The package sanitizes diagnostic URLs to origin + pathname and redacts every /session/email-login/<token> path.
Passkey Setup
Passkey registration is required only when using the default/passkey authentication mode. Password mode uses the CFX email/username, password, asynchronous email-link provider, and asynchronous 2FA provider instead.
The passkey registration script is based on work by ilovehugetits/9am-build. Thanks for the original implementation.
From a project where cfx-uploader is installed, run:
npx cfx-uploader-register-passkeyWhen working inside this repository, the equivalent development script is:
npm run register-passkeyA visible browser opens on the CFX forum security page. In the browser:
- Sign in if prompted.
- Open the security page if the login flow redirects elsewhere.
- Click Add passkey.
- Enter a passkey name and confirm.
- Return to the terminal and press
Enter.
The command writes:
./passkey-credential.jsonTo replace an existing credential:
npx cfx-uploader-register-passkey --forcepasskey-credential.json contains private key material. Keep it secret. It is ignored by git and should only be used for local development or for copying values into a secure secret store.
For VPS/Nuxt usage, store the generated passkey as separate environment variables:
CFX_UPLOADER_CREDENTIAL_ID=
CFX_UPLOADER_RP_ID=forum.cfx.re
CFX_UPLOADER_PRIVATE_KEY=
CFX_UPLOADER_USER_HANDLE=
CFX_UPLOADER_SIGN_COUNT=1Resource Configuration
Each uploaded resource repository must contain cfx_uploader.json at its root:
{
"portalName": "Clothingstore",
"foldersToZip": ["jo_clothingstore"],
"deleteOldestVersionWhenCapped": false,
"maxPrereleaseVersionsToKeep": null
}Fields:
| Field | Required | Description |
|---|---:|---|
| portalName | yes | Exact CFX Portal asset name. |
| foldersToZip | yes | Folders from the GitHub release source ZIP to include in the final upload ZIP. |
| deleteOldestVersionWhenCapped | no | Destructive opt-in. When true, CFX Uploader may delete the oldest CFX asset version if the asset has reached the 5-version limit. |
| maxPrereleaseVersionsToKeep | no | Optional HTTP retention policy for prerelease versions. null or omitted keeps the default oldest-version deletion behavior. Values above 4 are capped to 4. |
githubRepository is not part of cfx_uploader.json. It is provided by the library call, CLI env, or GitHub Actions runtime.
Library mode is strict: if cfx_uploader.json is missing from the downloaded release, upload fails with Missing cfx_uploader.json at repository root.... The mock-config.js fallback is only for local CLI development.
Library Usage
upload()
upload() is the simplest API. It uses HTTP upload mode.
import { upload } from 'cfx-uploader';
const result = await upload({
repository: 'Jump-On-Studios/RedM-jo_clothingstore',
releaseTag: 'v1.1.2',
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
headless: true,
});createUploader()
Use createUploader() when several uploads share the same base config:
import { createUploader } from 'cfx-uploader';
const uploader = createUploader({
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
headless: true,
workDir: '/tmp/cfx-uploader',
});
await uploader.upload({
repository: payload.repository.full_name,
releaseTag: payload.release?.tag_name,
releaseCandidate: Boolean(payload.release?.prerelease),
changelog: payload.release?.body,
});Base options and per-upload options are shallow-merged. Keep authentication, cache, GitHub token, logging, and working-directory settings in createUploader(), then pass repository- and release-specific values to uploader.upload().
checkAuthentication()
Use checkAuthentication() to validate or refresh a CFX session without downloading a GitHub release or creating an asset version:
import { checkAuthentication } from 'cfx-uploader';
const result = await checkAuthentication({
auth,
sessionCachePath,
sessionEncryptionKey,
browserProfilePath,
headless: true,
onLog: console.log,
});It returns only:
{
authMethod: 'cached',
sessionReused: true
}checkAuthentication() uses the same cache validation, fresh browser authentication, Portal handoff, timeouts, and password providers as upload().
Minimal webhook example
This example mirrors a common release-webhook integration: upload the published GitHub release to CFX, keep at most 2 prerelease versions, and expose the deleted CFX version for a private team notification.
import { createUploader } from 'cfx-uploader';
const uploader = createUploader({
githubToken: process.env.GITHUB_TOKEN,
passkey: {
credentialId: process.env.CFX_UPLOADER_CREDENTIAL_ID,
rpId: process.env.CFX_UPLOADER_RP_ID,
privateKey: process.env.CFX_UPLOADER_PRIVATE_KEY,
userHandle: process.env.CFX_UPLOADER_USER_HANDLE,
signCount: Number(process.env.CFX_UPLOADER_SIGN_COUNT),
},
headless: true,
workDir: process.env.CFX_UPLOADER_WORKDIR,
});
export async function handleGithubReleaseWebhook(payload) {
if (payload.action !== 'published') {
return { status: 'ignored' };
}
const result = await uploader.upload({
repository: payload.repository.full_name,
releaseTag: payload.release.tag_name,
releaseCandidate: Boolean(payload.release.prerelease),
changelog: payload.release.body || undefined,
deleteOldestVersionWhenCapped: true,
maxPrereleaseVersionsToKeep: 2,
});
if (result.deletedVersion) {
console.log(
`Deleted CFX version ${result.deletedVersion.version} before uploading ${result.version}`
);
}
return result;
}Options
| Option | Required | Description |
|---|---:|---|
| repository | yes | GitHub repository in owner/name format. |
| releaseTag | recommended | GitHub release tag to upload. If omitted, the latest release is used. |
| githubToken | yes | GitHub token used to read releases and download archives. |
| passkey | conditional | Passkey credential object for the default/passkey mode. Required when neither password auth nor a reusable session cache is supplied. |
| passkeyJson | no | Alternative JSON string form of the passkey credential. |
| auth | conditional | Password authentication object with method: 'password', email, password, twoFactorCodeProvider, and a strongly recommended emailVerificationLinkProvider. Alternative to passkey credentials. |
| headless | no | Browser auth mode. Defaults to true. |
| browserProfilePath | no | Persistent Chromium user-data directory used to preserve the recognized browser identity. |
| headlessFingerprint | no | native (default) or normalized for coherent UA, Client Hints, viewport, and screen metrics. |
| workDir | no | Working directory for temporary files. Defaults to an OS temp folder. |
| sessionCachePath | no | Explicit path for the encrypted CFX session cache. No cache is used when omitted. |
| sessionEncryptionKey | conditional | Secret used to encrypt the session cache. Required with sessionCachePath. |
| twoFactorTimeoutMs | no | Maximum time to wait for the 2FA provider. Defaults to 10 minutes. |
| emailVerificationTimeoutMs | no | Maximum time to wait for CFX new-device email verification. Defaults to 10 minutes. |
| releaseCandidate | no | Overrides GitHub pre-release detection. |
| deleteOldestVersionWhenCapped | no | Overrides the project JSON capped-version behavior. |
| maxPrereleaseVersionsToKeep | no | Overrides the project JSON prerelease retention policy. Use null to delete the oldest version regardless of type. Values above 4 are capped to 4. |
| changelog | no | CFX release notes. Defaults to the GitHub release body. |
| onLog | no | Custom logger callback. Defaults to console.log. |
| onProgress | no | Structured progress callback for external UIs, webhooks, or Discord workflow cards. |
auth and passkey are alternative authentication inputs. When auth is omitted, the existing passkey/passkeyJson behavior remains the default. A configured session cache may also be reused before a fresh authentication is needed.
onProgress receives machine-readable events while onLog remains human-readable:
await upload({
repository,
releaseTag,
githubToken,
passkey,
onProgress(event) {
console.log(`[${event.index}/${event.total}] ${event.label}`);
},
});Result
The library returns a structured result on success:
{
success: true,
mode: 'http',
repository,
releaseTag,
resolvedReleaseTag,
releaseCandidate,
portalName,
version,
assetId,
versionId,
deletedVersion,
deletedOldestVersion,
authMethod: 'password',
sessionReused: false
}authMethod is password, passkey, or cached. Because uploads retain the configured authentication source when available, use sessionReused—not authMethod alone—to determine whether Puppeteer was skipped in favor of the encrypted cache.
deletedVersion is null unless deleteOldestVersionWhenCapped was enabled and CFX Uploader had to delete a capped asset version before retrying the upload.
When present:
{
id,
version,
created_at,
releaseCandidate,
reason: 'asset-version-cap'
}deletedOldestVersion is kept as a backward-compatible alias. New integrations should use deletedVersion.
The package also exports lower-level helpers:
import { uploadHttp, uploadBrowser } from 'cfx-uploader';When a session cache is configured, the first run authenticates with Puppeteer and saves the CFX cookies encrypted. After password/email 2FA returns to the authenticated Forum, the HTTP workflow first validates any Portal/API cookies already available through GET /v1/me/assets. It skips the Portal UI when that succeeds; otherwise, missing cookies or a 401/403 triggers one Portal SSO handoff before a second validation. The encrypted payload is written only after validation and contains only CFX cookies, User-Agent, and cache metadata; it never contains the password, email login link, 2FA code, passkey, or GitHub token. Later runs validate the cached session through the CFX API and skip Puppeteer while it remains valid.
These are mainly intended for advanced usage and CLI wrappers.
CLI Usage
After installing the package, the CLI commands are available through npx:
npx cfx-uploader-register-passkey
npx cfx-uploader-auth
npx cfx-uploader-http
npx cfx-uploader-browserWhen working inside this repository, install dependencies first:
npm installThe commands read configuration from process.env, so inject production values through your shell, service manager, container platform, or secret manager. The authentication-check command additionally loads .env from the current working directory:
GITHUB_TOKEN=your_github_tokenUse the published .env.example as a template. Do not depend on a consumer-project .env being auto-loaded by the upload CLI; either export those variables before invoking it or load them in the parent process.
Local CLI runs use:
src/config/mock-config.jsforgithubRepositorypasskey-credential.jsonfor local passkey authcfx_uploader.jsonfrom the downloaded release when present
HTTP Mode
Recommended automation mode:
npx cfx-uploader-httpUse a specific release tag:
npx cfx-uploader-http --release-tag=v1.1.2Show the browser during authentication:
npx cfx-uploader-http --show-browserForce the CFX release type:
npx cfx-uploader-http --release-candidate
npx cfx-uploader-http --full-releaseTo use username/password authentication from the CLI:
CFX_UPLOADER_AUTH_METHOD=password
CFX_UPLOADER_EMAIL=your_email
CFX_UPLOADER_PASSWORD="your_password"
CFX_UPLOADER_SESSION_CACHE_PATH=/var/lib/cfx-uploader/cfx-session.enc
CFX_UPLOADER_SESSION_KEY=your_secret_manager_key
CFX_UPLOADER_2FA_TIMEOUT_MS=600000
CFX_UPLOADER_EMAIL_VERIFICATION_TIMEOUT_MS=600000
CFX_UPLOADER_BROWSER_PROFILE_PATH=/var/lib/cfx-uploader/browser-profile
CFX_UPLOADER_HEADLESS_FINGERPRINT=nativeQuote the password in .env when it contains # (for example, CFX_UPLOADER_PASSWORD="abc#123"). The CLI also preserves an unquoted # in this specific password variable.
Validate and populate the session cache before the first upload:
npx cfx-uploader-auth --auth-method=passwordThen upload:
npx cfx-uploader-http --auth-method=passwordOn a fresh password login, the CLI may first ask for the email-login link sent by CFX and then for the six-digit authenticator code. These prompts appear only when required by the CFX browser state. When the encrypted cache remains valid, authentication completes without launching Chromium or prompting for either value.
Use --show-browser to debug a fresh login:
npx cfx-uploader-auth --auth-method=password --show-browserThe interactive providers require stdin and stdout connected to a TTY. For services, containers without an interactive terminal, webhooks, and Discord bots, use the library API and provide asynchronous providers instead.
Control capped-version cleanup:
npx cfx-uploader-http --delete-oldest-version-when-capped
npx cfx-uploader-http --no-delete-oldest-version-when-capped
npx cfx-uploader-http --max-prerelease-versions-to-keep=2
npx cfx-uploader-http --no-prerelease-retentionBrowser Mode
Browser mode drives the full CFX Portal UI with Puppeteer. It is useful for debugging portal changes. It remains dedicated to the existing passkey flow; username/password authentication and session caching are available through the library, HTTP CLI, and authentication-check CLI.
npx cfx-uploader-browserBrowser mode supports the same flags:
npx cfx-uploader-browser --show-browser
npx cfx-uploader-browser --release-candidate
npx cfx-uploader-browser --full-release
npx cfx-uploader-browser --delete-oldest-version-when-capped
npx cfx-uploader-browser --no-delete-oldest-version-when-capped
npx cfx-uploader-browser --max-prerelease-versions-to-keep=2
npx cfx-uploader-browser --no-prerelease-retentionRelease Types
CFX Uploader maps GitHub pre-releases to CFX Release Candidate / Beta uploads.
Default behavior:
- GitHub normal release -> CFX full release
- GitHub pre-release -> CFX release candidate / beta
Library usage can override the GitHub release type:
await upload({
repository,
releaseTag,
githubToken,
passkey,
releaseCandidate: true,
});CLI usage can also force the type:
npx cfx-uploader-http --release-candidate
npx cfx-uploader-http --full-releaseWhen releaseCandidate is omitted, CFX Uploader uses the resolved GitHub release metadata. This automatic behavior works best when a specific release tag is provided, because GitHub /releases/latest does not return pre-releases.
Version Rules
Release tags can be written with or without a leading v.
Examples:
1.0.0v1.0.0
If one form is requested and only the other exists, CFX Uploader tries the alternate form automatically.
After downloading the release, the resolved tag is compared with fxmanifest.lua version. The leading v is ignored, but all other differences fail before authentication or upload.
CFX 5-Version Limit
CFX Portal currently allows a maximum of 5 versions per asset.
By default, CFX Uploader does not delete anything automatically. If the asset is capped, the upload fails with a clear MAX_VERSIONS_REACHED message.
You can opt in to automatic cleanup from cfx_uploader.json:
{
"portalName": "CFX Uploader Test",
"foldersToZip": ["cfx_uploader_test"],
"deleteOldestVersionWhenCapped": true,
"maxPrereleaseVersionsToKeep": 2
}Or from library code:
await upload({
repository,
releaseTag,
githubToken,
passkey,
deleteOldestVersionWhenCapped: true,
maxPrereleaseVersionsToKeep: 2,
});When enabled, CFX Uploader:
- fails first if the version from
fxmanifest.luaalready exists; - detects CFX
409 MAX_VERSIONS_REACHEDduring version creation; - refetches the asset details;
- deletes the version with the oldest
created_at; - waits until the asset is below the limit;
- retries version creation once.
By default, maxPrereleaseVersionsToKeep is null, which means CFX Uploader deletes the oldest version regardless of type. When a number is configured, HTTP mode applies prerelease-aware retention:
- new prerelease: delete the oldest prerelease if the current prerelease count is already at the limit, otherwise delete the oldest stable version;
- new stable release: delete the oldest stable version when possible;
- if the preferred group is empty, delete the oldest available version.
maxPrereleaseVersionsToKeep is capped at 4, even if a higher value is provided. This keeps at least one slot available for a full release on a 5-version CFX asset.
Browser mode still handles capped assets, but prerelease-aware retention is only guaranteed in HTTP mode because the CFX UI does not expose reliable version metadata.
This is intentionally destructive. Use it only for assets where deleting the oldest CFX version is acceptable.
Changelog
The changelog option is written to the CFX Portal version description.
When omitted, it defaults to the GitHub release body. If no release body is present, it falls back to:
Automated upload from cfx-uploader.The final ZIP filename is based on foldersToZip[0].
Environment Variables
Recommended server-side variables:
| Variable | Required | Description |
|---|---:|---|
| GITHUB_TOKEN | conditional | GitHub token used by uploads. It is not required by checkAuthentication() or cfx-uploader-auth. |
| CFX_UPLOADER_CREDENTIAL_ID | conditional | Passkey credential id. Required for passkey mode. |
| CFX_UPLOADER_RP_ID | conditional | Usually forum.cfx.re; required for passkey mode. |
| CFX_UPLOADER_PRIVATE_KEY | conditional | Passkey private key; required for passkey mode. |
| CFX_UPLOADER_USER_HANDLE | conditional | Passkey user handle; required for passkey mode. |
| CFX_UPLOADER_SIGN_COUNT | conditional | Passkey sign count as a number; required for passkey mode. |
| CFX_UPLOADER_AUTH_METHOD | no | Authentication method for cfx-uploader-http and cfx-uploader-auth: passkey (default) or password. An explicit --auth-method flag takes precedence. |
| CFX_UPLOADER_EMAIL | conditional | CFX email; required for password mode. |
| CFX_UPLOADER_PASSWORD | conditional | CFX password; required for password mode. |
| CFX_UPLOADER_SESSION_CACHE_PATH | no | Explicit encrypted CFX session cache path. |
| CFX_UPLOADER_SESSION_KEY | conditional | Encryption secret required whenever CFX_UPLOADER_SESSION_CACHE_PATH is set. |
| CFX_UPLOADER_2FA_TIMEOUT_MS | no | CLI 2FA provider timeout in milliseconds. Defaults to 600000. |
| CFX_UPLOADER_EMAIL_VERIFICATION_TIMEOUT_MS | no | CLI email-link provider timeout in milliseconds. Defaults to 600000. |
| CFX_UPLOADER_BROWSER_PROFILE_PATH | no | Persistent Chromium profile directory used by fresh browser authentication. Recommended for password mode. |
| CFX_UPLOADER_HEADLESS_FINGERPRINT | no | native (default) or normalized. Start with native; use normalized only when the deployment needs the normalized Chromium metadata profile. |
| CFX_UPLOADER_WORKDIR | no | Optional persistent working directory for integrations. |
CLI-only variables:
| Variable | Description |
|---|---|
| GITHUB_REPOSITORY | Overrides the local mock repository. |
| CFX_UPLOADER_RELEASE_TAG | Sets the release tag without CLI args. |
| CFX_SHOW_BROWSER | Set to true to show the auth browser. |
| PASSKEY_CREDENTIAL_PATH | Custom local passkey credential file path. |
Security Notes
- Never commit
passkey-credential.json. - Never log passkey values, GitHub tokens, or Discord webhook URLs.
- Never log passwords, email-login URLs, 2FA codes, CFX cookies, or session-cache contents.
- Store production passkey values in a secret manager or private server environment.
- Store
CFX_UPLOADER_EMAILandCFX_UPLOADER_PASSWORDin a secret manager. Do not embed them in source code. - Store
CFX_UPLOADER_SESSION_KEYin a secret manager and use one session-cache path per CFX account/environment. - Restrict filesystem access to the session cache and persistent Chromium profile even though the session cache itself is encrypted.
- The interactive CLI uses normal terminal prompts, so typed email-login links and 2FA codes may be visible in terminal capture, scrollback, or copied output. Use library providers backed by private interactions for production automation.
- Diagnostic URLs omit query strings and redact email-login token paths. Application callbacks must apply the same rule to their own logs.
- Use the narrowest GitHub token scope that can read the target repository releases.
- Treat
deleteOldestVersionWhenCappedas destructive and enable it per asset only when acceptable. - Customer-facing notifications should not expose raw upload errors.
Error Handling
upload() throws on failure. In integrations such as Nuxt webhooks, catch the error and forward error.message to private logs or a private Discord channel. Do not expose these messages in customer-facing notifications.
Stable password-authentication conditions are exposed through error.code; the code is not necessarily repeated in error.message.
Common errors:
| Area | Error or code | Meaning |
|---|---|---|
| Library options | Upload options are required. | upload() was called without an options object. |
| Library options | Upload option "repository" is required. | The GitHub repository was not provided. |
| Library options | Upload option "githubToken" is required. | The GitHub token was not provided. |
| Passkey | Missing CFX passkey. Provide passkey or passkeyJson. | No CFX passkey was provided to the library. |
| Passkey | Invalid passkey: ... | One passkey field is missing or invalid. |
| Password auth | Upload auth... / Password authentication requires ... | Email, password, or an asynchronous 2FA provider is missing or invalid. |
| Password auth | CFX email verification link is not an allowed... | The email provider returned a URL that failed the strict HTTPS, hostname, path, token, credentials, or fragment validation. |
| Password auth | CFX email verification link provider failed. | The external email-link provider rejected or failed. Its underlying error is intentionally not exposed. |
| Password auth | CFX_EMAIL_VERIFICATION_TIMEOUT | The new-device email challenge or its provider was not completed before emailVerificationTimeoutMs. No password retry is performed. |
| Password auth | CFX 2FA provider must return exactly six digits. | The external 2FA provider returned an invalid value. |
| Password auth | CFX 2FA code provider timed out. | No 2FA code was supplied before the configured timeout. |
| Password auth | CFX_EMAIL_LOGIN_LINK_INVALID | The supplied email-login link is expired, already consumed, or invalid. Request a new link and restart authentication. |
| Password auth | CFX_LOGIN_RATE_LIMITED | CFX is throttling login attempts. Wait for the cooldown; the library does not retry automatically. |
| Password auth | CFX password login button #login-button was not found or was not actionable. | The current Forum password DOM no longer matches the supported modern form, or the button is hidden/disabled. Retry with headless: false and report the sanitized page state. |
| Password auth | CFX 2FA submission did not reach an authenticated Forum or Portal state. | CFX rejected the code or the post-submit DOM did not reach a supported authenticated state. The package does not click a second time. |
| Session cache | CFX session cache requires ... | A cache path was configured without an encryption key. |
| Session cache | Unable to decrypt CFX session cache... | The cache key is wrong or the encrypted file is corrupted. |
| GitHub release | No downloadable release found for ... | The release/tag could not be found or has no downloadable archive. |
| GitHub release | GitHub API error... / Download failed... | GitHub rejected the release lookup or ZIP download. |
| Project config | Missing cfx_uploader.json at repository root... | The downloaded release does not contain the required config file. |
| Project config | Invalid cfx_uploader.json: portalName... | portalName is missing or invalid. |
| Project config | Invalid cfx_uploader.json: foldersToZip... | foldersToZip is missing or invalid. |
| Project config | Invalid cfx_uploader.json: deleteOldestVersionWhenCapped... | The capped-version option is not a boolean. |
| Project config | Invalid cfx_uploader.json: maxPrereleaseVersionsToKeep... | The prerelease retention option is not null or an integer >= 0. Values above 4 are accepted and capped. |
| ZIP workflow | Folder "..." not found in unzipped release... | A folder listed in foldersToZip does not exist in the release archive. |
| ZIP workflow | Path "..." is not a directory. | A configured upload path exists but is not a directory. |
| ZIP metadata | ZIP metadata missing: no fxmanifest.lua found... | The final ZIP does not contain an fxmanifest.lua. |
| ZIP metadata | ZIP metadata missing: no version detected... | The manifest does not expose a readable version. |
| Version check | release/manifest version mismatch | The GitHub release tag and fxmanifest.lua version differ, ignoring only a leading v. |
| CFX auth | CFX Portal ... did not reach Created Assets. State: ... | Browser authentication or the single Portal SSO handoff did not reach a visible authenticated Portal state. |
| CFX auth | Puppeteer/WebAuthn errors | Chromium failed to launch, inject the virtual authenticator, or complete navigation. |
| CFX session | No Portal/API cookies found after browser authentication | Browser auth completed without usable CFX API cookies. |
| CFX session | CFX HTTP auth failed (401/403): ... | portal-api.cfx.re rejected the authenticated session. |
| CFX API | GET/POST https://portal-api.cfx.re/... failed (...): ... | A CFX API endpoint returned a non-2xx response. |
| CFX API | Invalid JSON response from ... | CFX returned non-JSON where JSON was expected. |
| Asset lookup | Unexpected CFX assets list shape: ... | The CFX assets list response format changed. |
| Asset lookup | CFX asset not found by exact name "...". First assets: ... | portalName does not exactly match an owned CFX asset. |
| Asset lookup | CFX asset "..." is missing an id... | The matching CFX asset response is malformed. |
| Upload validation | Version already exists on asset "...": ... | The version from fxmanifest.lua already exists on the CFX asset. |
| Upload create | CFX asset has reached the maximum of 5 versions... | The asset is capped and deleteOldestVersionWhenCapped is disabled. |
| Upload create | MAX_VERSIONS_REACHED | CFX rejected the upload because the asset already has 5 versions. Enable deleteOldestVersionWhenCapped if automatic deletion is desired. |
| Upload cleanup | DELETE .../versions/... failed (...): ... | Automatic oldest-version deletion was enabled, but CFX rejected the delete request. |
| Upload cleanup | Timed out waiting for CFX version deletion... | The oldest version was deleted but CFX did not report the asset below the cap before timeout. |
| Upload create | CFX re-upload rejected: ... | CFX rejected the version creation payload. |
| Upload create | CFX re-upload response missing version_id: ... | CFX did not return the expected version id. |
| Chunk upload | Chunk upload failed for chunk_id=... (...): ... | One ZIP chunk failed to upload. |
| Complete upload | POST .../complete-upload failed (...): ... | CFX rejected upload finalization. |
| Polling | CFX poll timeout waiting for ACTIVE: ... | The asset/version did not become active before timeout. |
| Local files | ENOENT, permission, or filesystem errors | A downloaded or generated ZIP file could not be read or written. |
Local ZIPs and downloaded archives are not kept after failures. To inspect an upload ZIP, reproduce the run locally with a debugger or inspect the GitHub release source.
Troubleshooting
Puppeteer Chrome dependencies on Ubuntu
CFX Uploader uses Puppeteer to authenticate with CFX before the HTTP upload starts. On a minimal Ubuntu server, Chromium may fail to start if system libraries are missing.
Typical error:
Failed to launch the browser process: Code: 127
error while loading shared libraries: libatk-1.0.so.0: cannot open shared object file: No such file or directoryInstall the common Chromium runtime dependencies:
sudo apt update
sudo apt install -y \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcairo2 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libexpat1 \
libgbm1 \
libglib2.0-0 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libpango-1.0-0 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2Then restart the process running your integration:
pm2 restart <app-name>To verify missing libraries for the Chrome binary downloaded by Puppeteer:
ldd /path/to/puppeteer/chrome/chrome-linux64/chrome | grep "not found"If ldd returns no missing libraries but Chrome still fails, check the error message again. Sandbox-related errors are a separate issue from missing shared libraries.
Additional Documentation
Additional maintainer and integration notes are available in docs/.
- GitHub Actions workflow
- Nuxt library integration plan
- CFX Portal UI exploration
- CFX authentication exploration
- Session notes
GitHub Actions
GitHub Actions usage is documented in:
docs/github-actions-workflow.mdThe recommended short-term integration is the Node library mode from a server that can hold the CFX passkey securely. GitHub Actions becomes more convenient when organization-level secrets are available.
Project Structure
index.js package entrypoint
src/cli CLI entrypoints
src/core reusable upload flows
src/auth CFX auth, 2FA provider, and encrypted session cache
src/github GitHub release lookup and download
src/cfx CFX browser and HTTP upload adapters
src/zip ZIP workflow and metadata parsing
src/config runtime config resolution and local dev config
docs operational and integration documentationScripts
Development scripts available when working in this repository:
npm run register-passkey
npm run orchestrate-http
npm run orchestrate