diff --git a/.agents/skills/pixverse-growth-studio-api/SKILL.md b/.agents/skills/pixverse-growth-studio-api/SKILL.md index a6992bb..ac4e802 100644 --- a/.agents/skills/pixverse-growth-studio-api/SKILL.md +++ b/.agents/skills/pixverse-growth-studio-api/SKILL.md @@ -26,12 +26,7 @@ npm run cli -- growth-studio --help Growth Studio uses `PIXVERSE_GROWTH_API_KEY` and bearer authentication. It must not read `PIXVERSE_PLATFORM_API_KEY` or use Platform credits. Keep keys in `.env` or the server environment, never in a payload, command argument, artifact, or browser. -Optional folder configuration for the existing URL-based workflow: - -```bash -PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -PIXVERSE_GROWTH_FOLDER_API_KEY=... -``` +Folders use the documented `GET`/`POST /openapi/v1/folders` endpoints with the same `PIXVERSE_GROWTH_API_KEY`; no separate folder credential exists. A folder is targeted with the string `video.folder_id` inside the create payload, never a top-level `folder_id`. A non-loopback custom origin must be HTTPS, explicitly enabled with `PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL=true`, and controlled by the operator. @@ -84,8 +79,8 @@ For a local image, upload it first and place only the returned `https://media.pi Folder targeting applies only to the existing general workflow: - `--folder-id ` uses a verified folder. -- `--folder-name ` reuses a case-insensitive match or creates the folder. -- `--auto-folder` infers a folder from supported general-payload fields. +- `--folder-name ` reuses an exact (trimmed) name match or creates the folder with that name unchanged. +- `--auto-folder` infers a normalized folder name from supported general-payload fields and reuses a case-insensitive match. Prefer `growth-studio run-job` over direct general-video creation when a durable artifact trail is needed. General-video creation has no dry-run mode. diff --git a/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md b/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md index f850462..dec8131 100644 --- a/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md +++ b/.agents/skills/pixverse-platform-api/references/models-pricing-and-limits.md @@ -49,4 +49,4 @@ Use `resource.templates`, `resource.tts-speakers`, and `resource.restyle-effects - [Rate limits](https://docs.platform.pixverse.ai/rate-limit-796040m0) - [Image upload](https://docs.platform.pixverse.ai/upload-image-13016631e0) - [Video and audio upload](https://docs.platform.pixverse.ai/upload-videoaudio-19094401e0) -- [One-Click Music MV interface](https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc) +- [One-click VibeMV](https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0) diff --git a/.agents/skills/pixverse-platform-api/references/operation-catalog.md b/.agents/skills/pixverse-platform-api/references/operation-catalog.md index f18b0ec..f1c40f8 100644 --- a/.agents/skills/pixverse-platform-api/references/operation-catalog.md +++ b/.agents/skills/pixverse-platform-api/references/operation-catalog.md @@ -14,7 +14,7 @@ Every documented Platform API endpoint currently covered by `pixverse-api platfo | operation:resource.restyle-effects | `platform resource restyle-effects` | GET | `/openapi/v2/video/restyle/list` | read-only | no | none | Live page filters before choosing `restyle_id` | https://docs.platform.pixverse.ai/restyle-effect-list-21992862e0 | | operation:voice.create | `platform voice create` | POST | `/openapi/v2/video/tts_speaker` | non-billable | no | none | Voice name plus uploaded audio `media_id` string | https://docs.platform.pixverse.ai/create-custom-voice-40528181e0 | | operation:voice.delete | `platform voice delete` | DELETE | `/openapi/v2/video/tts_speaker/{speaker_id}` | non-billable | no | none | Known custom `speaker_id` string | https://docs.platform.pixverse.ai/delete-custom-voice-40528243e0 | -| operation:image.template | `platform image template` | POST | `/openapi/v2/image/template/generate` | billable | yes | `Resp.image_id` | Uploaded `img_ids` strings plus live `template_id` | https://docs.platform.pixverse.ai/image-template-generation-27564921e0 | +| operation:image.template | `platform image template` | POST | `/openapi/v2/image/template/generate` | billable | yes | `Resp.image_id` | Uploaded `img_ids` plus live `template_id` | https://docs.platform.pixverse.ai/image-template-generation-27564921e0 | | operation:image.status | `platform image status` | GET | `/openapi/v2/image/result/{image_id}` | read-only | no | `Resp.image_id` | Known `image_id` string; never resubmit generation to poll | https://docs.platform.pixverse.ai/get-image-generation-27565028e0 | | operation:video.text | `platform video text` | POST | `/openapi/v2/video/text/generate` | billable | yes | `Resp.video_id` | Prompt plus compatible model/duration/quality/aspect ratio | https://docs.platform.pixverse.ai/text-to-video-generation-13016634e0 | | operation:video.image | `platform video image` | POST | `/openapi/v2/video/img/generate` | billable | yes | `Resp.video_id` | Uploaded `img_id` string plus prompt/model/duration/quality | https://docs.platform.pixverse.ai/image-to-video-generation-13016633e0 | @@ -26,7 +26,7 @@ Every documented Platform API endpoint currently covered by `pixverse-api platfo | operation:video.restyle | `platform video restyle` | POST | `/openapi/v2/video/restyle/generate` | billable | yes | `Resp.video_id` | Source video plus live `restyle_id` | https://docs.platform.pixverse.ai/restyle-video-generation-21992681e0 | | operation:video.swap-mask | `platform video swap-mask` | POST | `/openapi/v2/video/mask/selection` | billable | no | none | Source video plus safe-integer-string `keyframe_id` | https://docs.platform.pixverse.ai/swap-mask-generation-24001877e0 | | operation:video.swap | `platform video swap` | POST | `/openapi/v2/video/swap/generate` | billable | yes | `Resp.video_id` | Source video, keyframe/mask from mask selection, replacement `img_id`, and quality | https://docs.platform.pixverse.ai/swap-video-generation-24001839e0 | -| operation:video.sound-effect | `platform video sound-effect` | POST | `/openapi/v2/video/sound_effect/generate` | billable | yes | `Resp.video_id` | Source video plus sound description | https://docs.platform.pixverse.ai/sound-effect-generation-19884196e0 | +| operation:video.sound-effect | `platform video sound-effect` | POST | `/openapi/v2/video/sound_effect/generate` | billable | yes | `Resp.video_id` | Source video plus optional `sound_effect_content` description | https://docs.platform.pixverse.ai/sound-effect-generation-19884196e0 | | operation:video.extend | `platform video extend` | POST | `/openapi/v2/video/extend/generate` | billable | yes | `Resp.video_id` | Source video plus continuation prompt and compatible V6 fields | https://docs.platform.pixverse.ai/extend-generation-19094393e0 | | operation:video.motion-control | `platform video motion-control` | POST | `/openapi/v2/video/mimic/generate` | billable | yes | `Resp.video_id` | Motion source video plus subject `img_id` and quality | https://docs.platform.pixverse.ai/motion-control-mimic-generation-28748523e0 | | operation:video.modify | `platform video modify` | POST | `/openapi/v2/video/modify/generate` | billable | yes | `Resp.video_id` | Source video plus edit prompt; optional masks/references | https://docs.platform.pixverse.ai/modify-generation-33365578e0 | @@ -34,5 +34,5 @@ Every documented Platform API endpoint currently covered by `pixverse-api platfo | operation:video.avatar | `platform video avatar` | POST | `/openapi/v2/video/avatar/generate` | billable | yes | `Resp.video_id` | Portrait `img_id` plus quality and exactly one uploaded-audio or TTS source | https://docs.platform.pixverse.ai/avatar-generation-40528034e0 | | operation:agent.viral-recreation | `platform agent viral-recreation` | POST | `/openapi/v2/video/agent/generate` | billable | yes | `Resp.video_id` | Fixed agent ID, prompt, image references, and exactly one video reference | https://docs.platform.pixverse.ai/viral-recreation-agent-41205382e0 | | operation:agent.real-estate | `platform agent real-estate` | POST | `/openapi/v2/video/agent/generate` | billable | yes | `Resp.video_id` | Fixed agent ID/model plus listing URL or supported image set | https://docs.platform.pixverse.ai/one-click-real-estate-video-42843647e0 | -| operation:agent.music-mv | `platform agent music-mv` | POST | `/openapi/v2/video/music_mv_agent/generate` | billable | yes | `Resp.video_id` | Verified audio plus optional character/style image references | https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc | +| operation:agent.music-mv | `platform agent music-mv` | POST | `/openapi/v2/video/music_mv_agent/generate` | billable | yes | `Resp.video_id` | Verified audio plus optional character/style image references | https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0 | | operation:video.status | `platform video status` | GET | `/openapi/v2/video/result/{video_id}` | read-only | no | `Resp.id` | Known `video_id` string; never resubmit generation to poll | https://docs.platform.pixverse.ai/get-video-generation-status-13016632e0 | diff --git a/.agents/skills/pixverse-platform-api/references/payload-examples.json b/.agents/skills/pixverse-platform-api/references/payload-examples.json index fa97326..1d2ab53 100644 --- a/.agents/skills/pixverse-platform-api/references/payload-examples.json +++ b/.agents/skills/pixverse-platform-api/references/payload-examples.json @@ -200,7 +200,7 @@ "description": "Generate synchronized sound for an existing video.", "input": { "source_video_id": "629000000000000005", - "prompt": "Gentle rain and distant thunder." + "sound_effect_content": "Gentle rain and distant thunder." } }, "video.extend": { @@ -258,7 +258,7 @@ "img_id": "629000000000000001", "quality": "720p", "lip_sync_tts_speaker_id": "speaker-7", - "lip_sync_tts_content": "Here is the latest update." + "lip_sync_tts_content": "Here is the latest product update for this week." } } ] @@ -271,12 +271,12 @@ "quality": "720p", "img_references": [ { - "img_url": "https://cdn.example.test/reference.png" + "img_id": "629000000000000002" } ], "video_references": [ { - "video_url": "https://cdn.example.test/reference.mp4" + "video_media_id": "629000000000000005" } ] } diff --git a/.agents/skills/pixverse-platform-api/references/troubleshooting.md b/.agents/skills/pixverse-platform-api/references/troubleshooting.md index f2f53bb..5fb8de2 100644 --- a/.agents/skills/pixverse-platform-api/references/troubleshooting.md +++ b/.agents/skills/pixverse-platform-api/references/troubleshooting.md @@ -9,7 +9,9 @@ HTTP success is not Platform success. Require a valid response envelope and `Err | Provider parameter rejection | Nonzero parameter-related `ErrCode` | Compare the specialized example, local validator, and current endpoint page; change only the rejected input. | | Moderation | Status 7 or moderation `ErrCode` | Treat as terminal. Report the provider reason; revise content only if the user asks. Do not hide or bypass moderation. | | Rate/concurrency pressure | Rate/concurrency `ErrCode`, HTTP 429, or retry guidance | For read-only calls, wait as directed and retry safely. For a confirmed-not-accepted submission, follow provider guidance; if acceptance is ambiguous, reconcile instead of resubmitting. | -| Polling issue | Transient failure while a result ID is known | Preserve the ID and job directory; resume polling without recreating the generation. | +| Polling issue | Transient failure while a result ID is known | Status polling already waits through transport errors, HTTP 429/5xx, and `500069`/`500044` until its timeout (see `poll_error` records in `polling.jsonl`). After a timeout, preserve the ID and job directory and resume polling without recreating the generation. | +| High load (`500069`) | Provider reports the system is under high load | Retryable for read-only calls; status polling continues automatically. A submission rejected with it was not accepted; obtain fresh approval before submitting again, and never auto-resubmit. | +| Webhook duplicates | PixVerse redelivers until it receives `ok` | The handler acknowledges verified duplicates (same nonce, or same `id` and `status`) with `ok` and does not call the delivery callback twice. A `409` means the first attempt is still in progress. | | Ambiguous billable submission | Timeout/connection loss after dispatch and no saved result ID | Do not retry or resubmit. Keep `request.json` and trace ID, inspect usage/provider records, and report `reconciliation_required`. | | Terminal generation failure | Status 6, 7, or 8 | Preserve final status, trace, provider envelope, and failure details; do not automatically create a replacement job. | | Missing result ID | Successful-looking create envelope lacks the cataloged ID path | Preserve create response and trace; stop as a response-contract error and reconcile. | diff --git a/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md b/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md index a966d7f..7145f71 100644 --- a/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md +++ b/.agents/skills/pixverse-platform-api/references/workflows-and-recovery.md @@ -38,14 +38,14 @@ Platform billable commands wait for a terminal result by default. To return imme `image_references` is accepted as a documented input alias and normalized to `img_references`. Do not send both fields. Lip sync requires recognizable vocals; audio verification does not authorize an automatic paid submission. -Keep Music MV identifiers as decimal strings in payload files and artifacts. The request layer losslessly serializes the documented `uint64` fields (`audio_media_id` and reference `img_id` values) as JSON number tokens because the live verification and generation endpoints reject quoted identifiers. +Keep identifiers as decimal strings in payload files and artifacts. The request layer losslessly serializes every field an operation's API reference types as integer, plus fusion `video_references` IDs and `image.template` `img_ids`, which the schemas list as string but the live API only accepts as integers (for example `img_id`, `img_ids`, `template_id`, `source_video_id`, `video_media_id`, `audio_media_id`, `keyframe_id`, `restyle_id`, `agent_id`, and reference `img_id` values) as JSON number tokens, because the endpoints reject quoted identifiers with ErrCode 400013. Fields the reference types as string (`mask_id`, `mask_ids`, `lip_sync_tts_speaker_id`, `webhook_id`) stay quoted. ### Durable artifact meanings - `request.json` is written before submission and records the provider, operation, redacted headers, original submission trace, and normalized input. - `create-response.json` preserves the redacted create response. - `video-id.json` or `image-id.json` preserves the known result ID as a string. -- `polling.jsonl` appends each redacted status snapshot and status-request trace. +- `polling.jsonl` appends each redacted status snapshot and status-request trace, plus a `poll_error` record for each transient status-read failure. - `final.json` preserves the original submission `trace_id`; `status_trace_id` identifies the latest status request. - `error.json` preserves safe failure classification and trace context without exposing secrets. @@ -67,10 +67,14 @@ Keep Music MV identifiers as decimal strings in payload files and artifacts. The Poll a known ID at the documented cadence and honor provider retry guidance. Preserve the original submission trace separately from status-request traces. +The default cadence is 5 seconds with a 30 minute wait per session; a timeout is resumable. Transient status-read failures (transport errors, HTTP 429/5xx, `ErrCode` `500069` high load or `500044` concurrency limit) do not fail the job: polling backs off, honors `Retry-After`, and continues until the overall timeout. Other read errors fail immediately. Every read-only retry uses a fresh `Ai-trace-id`. The billable submission is never retried automatically. + ## Webhooks Use the [official webhook page](https://docs.platform.pixverse.ai/how-to-use-webhook-1905378m0) to confirm current supported operations and delivery behavior. Verify timestamp, nonce, Base64 HMAC-SHA256 signature, time window, and nonce replay state before parsing JSON or invoking delivery logic. Return plain `ok` only after verified successful handling; use HTTPS and idempotent delivery processing. +PixVerse resends until it receives `ok` (8 retries over about 5 hours). The built-in handler therefore answers a verified duplicate (same nonce, or same payload `id` and `status` with a fresh nonce) with `200 ok` without invoking the delivery callback again, and answers `409` only while the first attempt is still in progress. `null` payload values are accepted (signed as `null`). The timestamp window defaults to 6 hours (`timestampToleranceSeconds`) so PixVerse's own late retries are not rejected if they reuse the original timestamp; nonce and `(id, status)` deduplication still block replays. + ## Raw diagnostics Use `platform raw` only for a relative `/openapi/v2/` path when diagnosing a documented endpoint or inspecting a newly published endpoint not yet in the catalog. Start with `--dry-run`. Never pass API keys, cookies, host, content length, authorization, or trace headers; the CLI owns those headers and assigns a fresh trace. diff --git a/.env.example b/.env.example index 0e5c53c..39b8a8c 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,3 @@ PIXVERSE_GROWTH_API_KEY=mh_live_REPLACE_WITH_PRODUCTION_API_KEY PIXVERSE_GROWTH_BASE_URL=https://growth-api.pixverse.ai # Required only for a non-official, non-loopback HTTPS origin: # PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL=true -# Optional, only if Growth Studio folder list/create uses a different API prefix/token: -# PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -# PIXVERSE_GROWTH_FOLDER_API_KEY=REPLACE_WITH_FOLDER_MANAGEMENT_TOKEN diff --git a/README.md b/README.md index daee694..0f416ac 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,6 @@ PIXVERSE_PLATFORM_BASE_URL=https://app-api.pixverse.ai PIXVERSE_GROWTH_API_KEY=mh_live_... PIXVERSE_GROWTH_BASE_URL=https://growth-api.pixverse.ai -PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -PIXVERSE_GROWTH_FOLDER_API_KEY=... ``` Keys stay server-side. Platform and Growth Studio credentials are separate and never fall back to each other. diff --git a/docs/api/command-reference.md b/docs/api/command-reference.md index 46f34b5..4efc325 100644 --- a/docs/api/command-reference.md +++ b/docs/api/command-reference.md @@ -129,7 +129,11 @@ pixverse-api growth-studio folders ensure "Campaign Alpha" pixverse-api growth-studio upload image /absolute/path/product.webp ``` -`folders ensure` accepts either the positional name shown above or `--folder-name `. +`folders ensure` accepts either the positional name shown above or `--folder-name `. The name is trimmed and otherwise sent unchanged; an existing folder is reused only on an exact (trimmed) name match. + +Folders use the documented `GET /openapi/v1/folders` and `POST /openapi/v1/folders` endpoints with the same `PIXVERSE_GROWTH_API_KEY` bearer credential as every other Growth Studio call. A resolved folder is sent as the string `video.folder_id` in the create payload. + +`upload image` accepts `.jpg`, `.jpeg`, `.png`, or `.webp` files of at most 20 MiB and rejects other files before any network request. ### PDP videos @@ -184,11 +188,15 @@ pixverse-api growth-studio video list [--limit 20] [--status succeeded] [--curso pixverse-api growth-studio video edit ``` -Folder options for creation are mutually exclusive targeting choices: +Folders are optional. Without a folder option (and without `video.folder_id` in the payload) no `folder_id` is sent, nothing is listed or created, and the video is filed at the workspace root. Folder options for creation are mutually exclusive targeting choices: - `--folder-id ` uses a verified existing folder. -- `--folder-name ` reuses a case-insensitive match or creates the folder. -- `--auto-folder` infers a name from supported payload metadata or the merchant URL. +- `--folder-name ` reuses an exact (trimmed) name match or creates the folder with that name unchanged. +- `--auto-folder` infers a normalized name from supported payload metadata or the merchant URL and reuses a case-insensitive match. + +A payload that already contains `video.folder_id` keeps that folder; no folder is listed or created. `video list --limit` must be an integer from 1 through 100 and `--status` one of `queued`, `processing`, `succeeded`, `failed`, or `canceled`. `video edit` requires a `clip_index` from 1 through 40 and a non-empty instruction of at most 500 characters. + +Read-only requests (`avatars list`, `folders list`, `video get`/`status`/`list`, and polling) retry on HTTP 429 and retryable 5xx responses, honoring `Retry-After` (seconds or HTTP date) and otherwise using capped exponential backoff. Video create, video edit, image upload, and folder create are never retried automatically. The existing general `create-from-url`, `create-from-json`, and `video edit` commands have no dry-run mode. They can create billable asynchronous work and do not provide the full durable job workflow. Prefer `run-job` for new general video generation. PDP is a separate workflow and does provide `pdp create --dry-run`. @@ -211,21 +219,12 @@ pixverse-api growth-studio resume /absolute/path/jobs/ \ [--fallback-delay-seconds 5] ``` -Timing values must be finite: `--timeout-minutes` must be greater than zero, while both delay values may be zero or greater. By default, `run-job` polls to a terminal state. `--no-poll` returns after recording the create response and string `video_id`. +Timing values must be finite: `--timeout-minutes` must be greater than zero. Polling never waits less than five seconds before the first status read or less than one second between later reads; smaller delay values are raised to those minimums. By default, `run-job` polls to a terminal state. `--no-poll` returns after recording the create response and string `video_id`. `resume` reads `video-id.json` and continues polling only. It never calls the create endpoint. With no saved video ID it reports `reconciliation_required`, preserving the evidence needed to investigate an ambiguous submission. Growth Studio jobs use `jobs/` by default and can contain `request.json`, `folder.json`, `create-response.json`, `video-id.json`, `polling.jsonl`, `final.json`, and `error.json`. PDP jobs omit folder resolution and can additionally contain `ledger-source-id.json`. A resumed nonterminal `final.json` is preserved as `final-prior-N.json`. JSON artifacts are private, atomic, and recursively redacted. -### Growth Studio folder configuration - -If folder management uses a different API prefix or token, configure either or both values without changing the video-generation credential: - -```bash -PIXVERSE_GROWTH_FOLDER_API_PREFIX=/marketing_hub -PIXVERSE_GROWTH_FOLDER_API_KEY=... -``` - ### Legacy aliases Legacy Growth Studio aliases remain available during `0.x` and emit one deprecation warning: diff --git a/docs/api/safety-and-recovery.md b/docs/api/safety-and-recovery.md index e7ff790..3ae3dd7 100644 --- a/docs/api/safety-and-recovery.md +++ b/docs/api/safety-and-recovery.md @@ -33,6 +33,12 @@ pixverse-api growth-studio resume /absolute/path/jobs/ pixverse-api growth-studio pdp resume /absolute/path/jobs/ ``` +### Polling defaults and transient read errors + +Platform status polling defaults to a 5 second interval (the status page asks for no less than 5 seconds) and a 30 minute wait per session (`--interval-ms` / `--timeout-ms` override both). Generation can run longer than that; a timeout is not a failure and the job resumes from its directory. + +Status reads are read-only, so a transient read failure does not abandon the job. Transport errors, HTTP 429 and 5xx, and the retryable `ErrCode`s `500069` (high load) and `500044` (concurrency limit) keep the job polling with exponential backoff (starting at the interval, capped at 60 seconds), honoring `Retry-After` when present, until the overall timeout. Each such failure is appended to `polling.jsonl` as a `poll_error` record; a timeout reached during failures records the last error in the timeout details. Any other error (for example an invalid ID) still fails immediately. Each read-only HTTP retry sends a fresh `Ai-trace-id`, because PixVerse can return a cached earlier result for a reused trace. None of this applies to the billable submission: it is sent exactly once, never retried automatically, and keeps its saved trace for recovery. + `resume` is poll-only. It reads the saved result ID and continues status checks; it never calls a create endpoint. If the submission response was ambiguous and no result ID was saved, it returns `reconciliation_required` instead of risking a duplicate charged generation. Inspect the saved request, error, recent remote jobs, and any provider request ID before deciding whether to submit again. ## Growth Studio PDP @@ -53,10 +59,18 @@ See [Growth Studio PDP Videos](growth-studio-pdp.md) for its seller-neutral fash ## Webhooks -Platform webhook verification first parses the raw body as a JSON object whose values must be scalar. It converts those key/value pairs to a URL-encoded query string with `URLSearchParams`, and verifies either insertion order or key-sorted order for compatibility. The signed string is: +Platform webhook verification first parses the raw body as a JSON object whose values must be scalar or `null`. It converts those key/value pairs to a URL-encoded query string with `URLSearchParams`, and verifies either insertion order or key-sorted order (code-point/byte order, matching the official Go and Java examples) for compatibility. The signed string is: ```text ${timestamp}\n${nonce}\n${queryString} ``` -For example, `{"video_id":"123","status":1}` becomes `video_id=123&status=1`. Nested objects, arrays, and `null` are not supported by this signature format. The signature is Base64 HMAC-SHA256. The handler validates authentication and reserves the nonce before delivery, rejects replays, and returns exact body `ok` only after the delivery callback succeeds and the nonce reservation is committed. +For example, `{"video_id":"123","status":1}` becomes `video_id=123&status=1`. A `null` value (such as `"url": null` on a failed delivery) is accepted and signed as `null`, which is what `String(null)` in the official Node example produces; the Go (``) and Python (`None`) spellings are also accepted. Nested objects and arrays are not supported by this signature format and are rejected. The signature is Base64 HMAC-SHA256. + +The handler validates authentication, reserves the nonce, and returns exact body `ok` only after the delivery callback succeeds and the reservation is committed. PixVerse resends any delivery that does not receive `ok` (8 retries over about 5 hours), so duplicates are acknowledged rather than rejected: + +- A verified replay of an already-processed nonce returns `200 ok` without calling the delivery callback again. +- Deliveries are also deduplicated by payload `(id, status)`, as the official docs recommend, so a redelivery with a fresh nonce is acknowledged with `ok` once the first one succeeded. A later status for the same `id` is a new event. +- A duplicate that arrives while the first attempt is still being processed gets `409`, so PixVerse retries it later; if the first attempt fails, its reservation is released and the retry is delivered. + +`timestampToleranceSeconds` (an option of `createPlatformWebhookHandler` and `verifyPlatformWebhook`, 1 second to 7 days) bounds how old a `Webhook-Timestamp` may be. The default is 6 hours: the docs do not say whether retries are re-signed, and a short window could reject PixVerse's own late retries if they keep the original timestamp. Replays inside the window are still blocked by the nonce and `(id, status)` deduplication, which are held for the whole window. Timestamps more than 300 seconds in the future are always rejected. A custom store's `reserve(key, expiresAt, nowSeconds)` must return a falsy value only for a key that is already committed, and throw (for example an error with `statusCode: 409`) for one that is still reserved. For a multi-instance receiver, use a shared durable store. diff --git a/src/core/errors.js b/src/core/errors.js index 28b3964..b859084 100644 --- a/src/core/errors.js +++ b/src/core/errors.js @@ -40,3 +40,19 @@ export function serializeError(error) { return redact(serialized); } + +// Classifies a failed *read-only* request (for example a status poll) as +// transient: worth waiting and reading again. Never use this to decide whether +// to resend a billable submission; those are never retried automatically. +export function isTransientReadError(error) { + if (!error || typeof error !== "object") return false; + if (isAbortError(error) || isAbortError(error.cause)) return false; + if (error.category === "transport") return true; + const status = Number(error.status); + if (status === 429 || (Number.isInteger(status) && status >= 500 && status <= 599)) return true; + return error.category === "provider" && error.retryable === true; +} + +function isAbortError(error) { + return error?.name === "AbortError" || error?.name === "TimeoutError"; +} diff --git a/src/core/http.js b/src/core/http.js index 0ce2edc..2d2f0e4 100644 --- a/src/core/http.js +++ b/src/core/http.js @@ -21,17 +21,32 @@ export async function requestHttp(options) { now = Date.now, provider, operation, - traceId, + traceId: initialTraceId, + traceHeader = "Ai-trace-id", + nextTraceId, signal, } = options; const normalizedMethod = method.toUpperCase(); const maxAttempts = normalizeMaxAttempts(retry?.maxAttempts); const canRetry = RETRYABLE_METHODS.has(normalizedMethod) && maxAttempts > 1; + if (nextTraceId !== undefined && typeof nextTraceId !== "function") { + throw new TypeError("nextTraceId must be a function."); + } + let traceId = initialTraceId; + let attemptHeaders = headers; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + if (attempt > 1 && nextTraceId) { + // PixVerse treats a repeated Ai-trace-id as the same request and can + // return the cached previous result, so each read-only retry gets a + // fresh trace. Billable submissions never reach this path: they are + // single-attempt and deliberately keep their persisted recovery trace. + traceId = nextTraceId(); + attemptHeaders = withHeader(headers, traceHeader, traceId); + } let response; try { - response = await fetchImpl(url, { method: normalizedMethod, headers, body, signal }); + response = await fetchImpl(url, { method: normalizedMethod, headers: attemptHeaders, body, signal }); } catch (cause) { const aborted = signal?.aborted === true || cause?.name === "AbortError"; const error = new PixverseCliError("PixVerse API transport request failed.", { @@ -67,6 +82,7 @@ export async function requestHttp(options) { headers: response.headers, status: response.status, retryAfter, + traceId, }; } @@ -143,6 +159,21 @@ function httpError(response, body, context) { }); } +function withHeader(headers, name, value) { + if (headers instanceof Headers) { + const copy = new Headers(headers); + copy.set(name, value); + return copy; + } + const copy = {}; + const lowerName = name.toLowerCase(); + for (const [key, entry] of Object.entries(headers ?? {})) { + if (key.toLowerCase() !== lowerName) copy[key] = entry; + } + copy[name] = value; + return copy; +} + function normalizeMaxAttempts(value) { if (value === undefined) return 1; if (!Number.isSafeInteger(value) || value < 1) { diff --git a/src/core/polling.js b/src/core/polling.js index 0776f9d..eb81657 100644 --- a/src/core/polling.js +++ b/src/core/polling.js @@ -1,4 +1,4 @@ -import { PixverseCliError } from "./errors.js"; +import { PixverseCliError, serializeError } from "./errors.js"; export async function pollUntilTerminal(options) { const { @@ -12,14 +12,42 @@ export async function pollUntilTerminal(options) { provider, operation, traceId, + // Opt-in: when provided and it returns true for a poll() failure, the loop + // keeps polling (with backoff) until timeoutMs instead of failing. Errors + // it rejects are rethrown immediately. + isRetryableError, + onPollError = async () => {}, + maxBackoffMs = Math.max(intervalMs, 60_000), } = options; - validateOptions({ poll, isTerminal, intervalMs, timeoutMs }); + validateOptions({ poll, isTerminal, intervalMs, timeoutMs, isRetryableError }); const startedAt = now(); let lastSnapshot; + let lastError; + let consecutiveErrors = 0; while (true) { - lastSnapshot = await poll(); + try { + lastSnapshot = await poll(); + consecutiveErrors = 0; + lastError = undefined; + } catch (error) { + if (!isRetryableError || !(await isRetryableError(error))) throw error; + consecutiveErrors += 1; + lastError = error; + await onPollError(error, { consecutiveErrors }); + const backoffMs = Math.min(maxBackoffMs, intervalMs * (2 ** (consecutiveErrors - 1))); + const retryAfterMs = Number.isFinite(error?.retryAfter) ? error.retryAfter * 1_000 : 0; + const delayMs = Math.max(backoffMs, retryAfterMs); + const remaining = timeoutMs - (now() - startedAt); + // Never poll again earlier than the provider asked; if its Retry-After + // outlasts the deadline, stop now with a resumable timeout. + if (remaining <= 0 || retryAfterMs > remaining) { + throw timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot, lastError }); + } + await sleep(Math.min(delayMs, remaining)); + continue; + } await onSnapshot(lastSnapshot); if (await isTerminal(lastSnapshot)) return lastSnapshot; @@ -32,7 +60,7 @@ export async function pollUntilTerminal(options) { } } -function validateOptions({ poll, isTerminal, intervalMs, timeoutMs }) { +function validateOptions({ poll, isTerminal, intervalMs, timeoutMs, isRetryableError }) { if (typeof poll !== "function") throw new TypeError("poll must be a function."); if (typeof isTerminal !== "function") throw new TypeError("isTerminal must be a function."); if (!Number.isFinite(intervalMs) || intervalMs <= 0) { @@ -41,9 +69,12 @@ function validateOptions({ poll, isTerminal, intervalMs, timeoutMs }) { if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { throw new TypeError("timeoutMs must be a non-negative number."); } + if (isRetryableError !== undefined && typeof isRetryableError !== "function") { + throw new TypeError("isRetryableError must be a function."); + } } -function timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot }) { +function timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot, lastError }) { return new PixverseCliError(`PixVerse polling timed out after ${timeoutMs}ms.`, { category: "timeout", provider, @@ -53,10 +84,17 @@ function timeoutError({ provider, operation, traceId, timeoutMs, lastSnapshot }) details: { timeout_ms: timeoutMs, last_snapshot: lastSnapshot, + ...(lastError === undefined ? {} : { last_error: summarizeError(lastError) }), }, }); } +function summarizeError(error) { + const summary = serializeError(error); + delete summary.details; + return summary; +} + function defaultSleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } diff --git a/src/credentials.js b/src/credentials.js index e5c1176..96144b0 100644 --- a/src/credentials.js +++ b/src/credentials.js @@ -8,8 +8,6 @@ export const USER_CREDENTIAL_KEYS = Object.freeze([ "PIXVERSE_PLATFORM_ALLOW_CUSTOM_BASE_URL", "PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_BASE_URL", - "PIXVERSE_GROWTH_FOLDER_API_KEY", - "PIXVERSE_GROWTH_FOLDER_API_PREFIX", "PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL", ]); diff --git a/src/growth-studio/cli.js b/src/growth-studio/cli.js index 6f14c82..31a342b 100644 --- a/src/growth-studio/cli.js +++ b/src/growth-studio/cli.js @@ -291,7 +291,9 @@ function parseListOptions(args) { const options = {}; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; - if (arg === "--limit") options.limit = readOptionValue(args, ++index, arg); + if (arg === "--limit") { + options.limit = readSafeIntegerOption(args, ++index, arg, { minimum: 1, maximum: 100 }); + } else if (arg === "--status") options.status = readOptionValue(args, ++index, arg); else if (arg === "--cursor") options.cursor = readOptionValue(args, ++index, arg); else throw new Error(`Unknown video list option: ${arg}`); @@ -546,8 +548,8 @@ function readFiniteOption(args, index, optionName, constraints = {}) { } function withFolderId(payload, folderId) { - if (!folderId) return payload; - return { ...payload, folder_id: folderId }; + if (!folderId || payload?.video?.folder_id === folderId) return payload; + return { ...payload, video: { ...payload.video, folder_id: folderId } }; } function resolveContextPath(context, value) { diff --git a/src/growth-studio/client.js b/src/growth-studio/client.js index ac2d37c..c554cd7 100644 --- a/src/growth-studio/client.js +++ b/src/growth-studio/client.js @@ -1,10 +1,12 @@ import fs from "node:fs"; import path from "node:path"; +import JSONBigFactory from "json-bigint"; import { assertVideoId, validateCreatePayload, validateEditPayload, validateFolderPayload, + validateListVideosOptions, } from "./validation.js"; import { PDP_CREATE_ROUTE, normalizePdpPayload } from "./pdp.js"; import { normalizeWalletLedgerOptions } from "./wallet.js"; @@ -13,6 +15,7 @@ export { validateCreatePayload, validateEditPayload, validateFolderPayload, + validateListVideosOptions, } from "./validation.js"; export { PDP_CREATE_PATH, @@ -24,8 +27,24 @@ export { export { normalizeWalletLedgerOptions } from "./wallet.js"; const API_PREFIX = "/openapi/v1"; -const DEFAULT_FOLDER_API_PREFIX = "/marketing_hub"; const TERMINAL_STATUSES = new Set(["succeeded", "failed", "canceled"]); +const DEFAULT_RETRYABLE_STATUSES = new Set([429, 502, 503, 504]); +const JSON_BIG = JSONBigFactory({ + storeAsString: true, + protoAction: "error", + constructorAction: "error", +}); +const DEFAULT_READ_RETRY = Object.freeze({ maxAttempts: 4, baseDelayMs: 1_000, maxDelayMs: 30_000 }); + +export const MIN_INITIAL_POLL_DELAY_SECONDS = 5; +export const MIN_FALLBACK_POLL_DELAY_SECONDS = 1; +export const MAX_UPLOAD_IMAGE_BYTES = 20 * 1024 * 1024; +const UPLOAD_IMAGE_TYPES = Object.freeze({ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +}); export class GrowthStudioApiError extends Error { constructor(message, options = {}) { @@ -72,7 +91,7 @@ export async function parseResponse(response) { let body = {}; if (text) { try { - body = JSON.parse(text); + body = toPlainJson(JSON_BIG.parse(text)); } catch { if (response.ok) { throw new GrowthStudioApiError("PixVerse API returned invalid JSON in a successful response.", { @@ -87,12 +106,14 @@ export async function parseResponse(response) { } if (!response.ok) { - const error = body.error || {}; + const error = isRecord(body?.error) ? body.error : {}; throw new GrowthStudioApiError(error.message || `PixVerse API request failed with HTTP ${response.status}.`, { status: response.status, code: error.code, - retryable: error.retryable, - requestId: body.request_id || requestId, + retryable: typeof error.retryable === "boolean" + ? error.retryable + : DEFAULT_RETRYABLE_STATUSES.has(response.status), + requestId: body?.request_id || requestId, retryAfter, details: error.details, }); @@ -101,7 +122,7 @@ export async function parseResponse(response) { return { body, headers: response.headers, - requestId: body.request_id || requestId, + requestId: body?.request_id || requestId, retryAfter, }; } @@ -109,19 +130,19 @@ export async function parseResponse(response) { export class GrowthStudioClient { constructor({ apiKey, - folderApiKey, - folderApiPrefix = DEFAULT_FOLDER_API_PREFIX, baseUrl, fetchImpl = globalThis.fetch, sleep = defaultSleep, + now = Date.now, + readRetry = DEFAULT_READ_RETRY, }) { if (!fetchImpl) throw new Error("A fetch implementation is required."); this.apiKey = apiKey; - this.folderApiKey = folderApiKey || apiKey; - this.folderApiPrefix = normalizeApiPrefix(folderApiPrefix); this.baseUrl = baseUrl; this.fetch = fetchImpl; this.sleep = sleep; + this.now = now; + this.readRetry = { ...DEFAULT_READ_RETRY, ...readRetry }; } async listAvatars(options = {}) { @@ -129,7 +150,14 @@ export class GrowthStudioClient { } async uploadImage(filePath, options = {}) { - const file = await fs.openAsBlob(filePath); + const type = UPLOAD_IMAGE_TYPES[path.extname(String(filePath)).toLowerCase()]; + if (!type) throw new Error("upload image accepts only .jpg, .jpeg, .png, or .webp files."); + const stats = await fs.promises.stat(filePath); + if (!stats.isFile()) throw new Error("upload image requires a regular file."); + if (stats.size > MAX_UPLOAD_IMAGE_BYTES) { + throw new Error("upload image files must be 20 MiB or smaller."); + } + const file = await fs.openAsBlob(filePath, { type }); const form = new FormData(); form.set("image", file, path.basename(filePath)); return this.request("POST", "/image/upload", { @@ -175,6 +203,7 @@ export class GrowthStudioClient { } async listVideos(options = {}) { + validateListVideosOptions(options); return this.request("GET", "/videos", { query: { limit: options.limit, @@ -186,14 +215,14 @@ export class GrowthStudioClient { } async listFolders(options = {}) { - return this.requestFolderApi("GET", "/folder/list", { + return this.request("GET", "/folders", { traceId: options.traceId, }); } async createFolder(payload, options = {}) { validateFolderPayload(payload); - return this.requestFolderApi("POST", "/folder/create", { + return this.request("POST", "/folders", { json: payload, traceId: options.traceId, }); @@ -210,26 +239,37 @@ export class GrowthStudioClient { async pollVideo(videoId, options = {}) { const timeoutMs = options.timeoutMs ?? 30 * 60 * 1000; - const startedAt = Date.now(); - let waitSeconds = options.initialDelaySeconds ?? 5; + const startedAt = this.now(); + const fallbackDelaySeconds = Math.max( + MIN_FALLBACK_POLL_DELAY_SECONDS, + options.fallbackDelaySeconds ?? 5, + ); + let waitSeconds = Math.max(MIN_INITIAL_POLL_DELAY_SECONDS, options.initialDelaySeconds ?? 5); while (true) { - if (waitSeconds > 0) await this.sleep(waitSeconds * 1000); - - const result = await this.getVideo(videoId, { traceId: options.traceId }); + await this.sleep(waitSeconds * 1000); + + let result; + try { + result = await this.getVideo(videoId, { traceId: options.traceId }); + } catch (error) { + if (!isRetryableReadError(error) || this.now() - startedAt > timeoutMs) throw error; + waitSeconds = Math.max(MIN_FALLBACK_POLL_DELAY_SECONDS, error.retryAfter ?? fallbackDelaySeconds); + continue; + } const body = result.body; - const editStatus = body.edit?.latest?.status || body.edit?.status; + const editStatus = body.edit?.status || body.edit?.latest?.status; await options.onSnapshot?.(body); if (TERMINAL_STATUSES.has(body.status) && !isEditRunning(editStatus)) { return body; } - if (Date.now() - startedAt > timeoutMs) { + if (this.now() - startedAt > timeoutMs) { throw new Error(`Polling timed out for video_id ${videoId}.`); } - waitSeconds = result.retryAfter ?? options.fallbackDelaySeconds ?? 5; + waitSeconds = Math.max(MIN_FALLBACK_POLL_DELAY_SECONDS, result.retryAfter ?? fallbackDelaySeconds); } } @@ -246,33 +286,22 @@ export class GrowthStudioClient { body = JSON.stringify(options.json); } - return parseResponse(await this.fetch(url, { method, headers, body })); - } - - async requestFolderApi(method, path, options = {}) { - const url = buildUrl(this.baseUrl, `${this.folderApiPrefix}${path}`, options.query); - const headers = new Headers(options.headers); - headers.set("Authorization", `Bearer ${this.folderApiKey}`); - headers.set("Accept", "application/json"); - if (options.traceId) headers.set("Ai-Trace-Id", options.traceId); - - let body = options.body; - if (options.json !== undefined) { - headers.set("Content-Type", "application/json"); - body = JSON.stringify(options.json); + // Only read-only GET requests are retried. Create, edit, upload, and folder + // creation have no idempotency keys, so a retry could duplicate billing or data. + const maxAttempts = method === "GET" ? Math.max(1, this.readRetry.maxAttempts) : 1; + for (let attempt = 1; ; attempt += 1) { + try { + return await parseResponse(await this.fetch(url, { method, headers, body })); + } catch (error) { + if (attempt >= maxAttempts || !isRetryableReadError(error)) throw error; + await this.sleep(readRetryDelayMs(error.retryAfter, attempt, this.readRetry)); + } } - - const response = await parseResponse(await this.fetch(url, { method, headers, body })); - return { - ...response, - body: unwrapFolderApiEnvelope(response.body), - }; } } export function createProductUrlPayload(sourceUrl, video = {}) { return { - ...(video.folderId ? { folder_id: video.folderId } : {}), product: { source_url: sourceUrl, }, @@ -285,29 +314,33 @@ export function createProductUrlPayload(sourceUrl, video = {}) { captions: video.captions ?? true, background_music: video.backgroundMusic ?? true, avatar: video.avatar || { mode: "auto" }, + ...(video.folderId ? { folder_id: video.folderId } : {}), }, ...(video.metadata ? { metadata: video.metadata } : {}), }; } -function unwrapFolderApiEnvelope(body) { - if (body && typeof body === "object" && "ErrCode" in body && body.ErrCode !== 0) { - const error = new Error(body.ErrMsg || `Growth Studio folder request failed with code ${body.ErrCode}.`); - error.code = body.ErrCode; - error.details = body.Resp; - throw error; - } +function isRetryableReadError(error) { + return error instanceof GrowthStudioApiError + && error.retryable === true + && (error.status === 429 || error.status >= 500); +} + +function readRetryDelayMs(retryAfter, attempt, policy) { + if (retryAfter !== undefined) return retryAfter * 1000; + return Math.min(policy.maxDelayMs, policy.baseDelayMs * (2 ** (attempt - 1))); +} - if (body && typeof body === "object" && "Resp" in body) return body.Resp || {}; - return body; +function toPlainJson(value) { + if (Array.isArray(value)) return value.map(toPlainJson); + if (value === null || typeof value !== "object") return value; + const output = {}; + for (const [key, item] of Object.entries(value)) output[key] = toPlainJson(item); + return output; } -function normalizeApiPrefix(prefix) { - if (!prefix || typeof prefix !== "string") return DEFAULT_FOLDER_API_PREFIX; - const trimmed = prefix.trim(); - if (!trimmed) return DEFAULT_FOLDER_API_PREFIX; - const withoutTrailingSlash = trimmed.replace(/\/+$/g, ""); - return withoutTrailingSlash.startsWith("/") ? withoutTrailingSlash : `/${withoutTrailingSlash}`; +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); } function isEditRunning(status) { diff --git a/src/growth-studio/config.js b/src/growth-studio/config.js index 32c7f1b..11d23ba 100644 --- a/src/growth-studio/config.js +++ b/src/growth-studio/config.js @@ -5,8 +5,6 @@ const OFFICIAL_GROWTH_ORIGIN = new URL(DEFAULT_BASE_URL).origin; const DOTENV_KEYS = new Set([ "PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_BASE_URL", - "PIXVERSE_GROWTH_FOLDER_API_KEY", - "PIXVERSE_GROWTH_FOLDER_API_PREFIX", "PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL", ]); @@ -31,8 +29,6 @@ export function loadDotEnv(path = ".env") { export function getGrowthStudioConfig(env = process.env) { const apiKey = env.PIXVERSE_GROWTH_API_KEY; - const folderApiKey = env.PIXVERSE_GROWTH_FOLDER_API_KEY || apiKey; - const folderApiPrefix = env.PIXVERSE_GROWTH_FOLDER_API_PREFIX; const baseUrl = normalizeBaseUrl( env.PIXVERSE_GROWTH_BASE_URL || DEFAULT_BASE_URL, env.PIXVERSE_GROWTH_ALLOW_CUSTOM_BASE_URL, @@ -46,7 +42,7 @@ export function getGrowthStudioConfig(env = process.env) { throw new Error("PIXVERSE_GROWTH_API_KEY must be a production key starting with mh_live_."); } - return { apiKey, folderApiKey, folderApiPrefix, baseUrl }; + return { apiKey, baseUrl }; } export const getConfig = getGrowthStudioConfig; diff --git a/src/growth-studio/folders.js b/src/growth-studio/folders.js index e6aab6f..97b1fda 100644 --- a/src/growth-studio/folders.js +++ b/src/growth-studio/folders.js @@ -1,5 +1,5 @@ export async function resolveFolderForPayload(client, payload, options = {}) { - const explicitFolderId = options.folderId || payload.folder_id; + const explicitFolderId = options.folderId || payload?.video?.folder_id; if (explicitFolderId) { assertFolderId(explicitFolderId); return { @@ -12,15 +12,22 @@ export async function resolveFolderForPayload(client, payload, options = {}) { if (!options.folderName && !options.autoFolder) return null; - const folderName = normalizeFolderName(options.folderName || inferFolderName(payload)); + // An explicit --folder-name is sent exactly as the user typed it (trimmed) and + // matched exactly; normalization only applies to names inferred by --auto-folder. + const explicitName = Boolean(options.folderName); + const folderName = explicitName + ? String(options.folderName).trim() + : normalizeFolderName(inferFolderName(payload)); if (!folderName) { - if (options.folderName) throw new Error("Folder name cannot be empty."); + if (explicitName) throw new Error("Folder name cannot be empty."); return null; } const listResult = await client.listFolders({ traceId: options.traceId && `${options.traceId}-folder-list` }); const folders = Array.isArray(listResult.body?.folders) ? listResult.body.folders : []; - const existing = folders.find((folder) => folderNamesMatch(folder.name, folderName)); + const existing = folders.find((folder) => (explicitName + ? typeof folder?.name === "string" && folder.name.trim() === folderName + : folderNamesMatch(folder?.name, folderName))); if (existing) { const folderId = readFolderId(existing); return { @@ -45,8 +52,8 @@ export async function resolveFolderForPayload(client, payload, options = {}) { } export function inferFolderName(payload) { - const metadata = asRecord(payload.metadata); - const product = asRecord(payload.product); + const metadata = asRecord(payload?.metadata); + const product = asRecord(payload?.product); return firstNonEmpty([ metadata.folder_name, diff --git a/src/growth-studio/jobs.js b/src/growth-studio/jobs.js index c7eeaae..5109ada 100644 --- a/src/growth-studio/jobs.js +++ b/src/growth-studio/jobs.js @@ -227,6 +227,8 @@ async function persistFailure(jobDir, error, traceId, options = {}) { ...(publicError.code === undefined ? {} : { code: publicError.code }), ...(publicError.retryable === undefined ? {} : { retryable: publicError.retryable }), ...(publicError.retry_after === undefined ? {} : { retry_after: publicError.retry_after }), + ...(publicError.request_id === undefined ? {} : { request_id: publicError.request_id }), + ...(publicError.details === undefined ? {} : { details: publicError.details }), trace_id: publicError.trace_id ?? traceId, })); } @@ -277,9 +279,9 @@ function requireStringIdentifier(value, message) { } function withFolderId(payload, folderId) { - if (!folderId) return payload; + if (!folderId || payload?.video?.folder_id === folderId) return payload; return { ...payload, - folder_id: folderId, + video: { ...payload.video, folder_id: folderId }, }; } diff --git a/src/growth-studio/validation.js b/src/growth-studio/validation.js index cb6de7e..21807f1 100644 --- a/src/growth-studio/validation.js +++ b/src/growth-studio/validation.js @@ -1,28 +1,47 @@ -export function validateCreatePayload(payload) { - if (!payload || typeof payload !== "object") throw new Error("Create payload must be an object."); - if (payload.folder_id !== undefined) assertFolderId(payload.folder_id); - if (!payload.product || typeof payload.product !== "object") throw new Error("Create payload requires product."); - if (!payload.video || typeof payload.video !== "object") throw new Error("Create payload requires video."); +export const VIDEO_DURATIONS = Object.freeze([15, 30, 45, 60]); +export const VIDEO_RESOLUTIONS = Object.freeze(["480p", "720p", "1080p"]); +export const AVATAR_MODES = Object.freeze(["auto", "custom", "disabled"]); +export const VIDEO_STATUSES = Object.freeze(["queued", "processing", "succeeded", "failed", "canceled"]); +export const VIDEO_LANGUAGES = Object.freeze([ + "en-AU", "pt-BR", "es-BR", "en-CA", "fr-CA", "zh-CN", "es-CO", "fr-FR", "de-DE", "hi-IN", + "en-IN", "id-ID", "it-IT", "ja-JP", "ms-MY", "es-MX", "nl-NL", "en-NL", "fil-PH", "en-PH", + "pl-PL", "ru-RU", "en-SG", "ko-KR", "es-ES", "de-CH", "fr-CH", "it-CH", "zh-TW", "th-TH", + "en-TH", "tr-TR", "ar-AE", "en-AE", "en-GB", "en-US", "es-US", "vi-VN", +]); +export const MAX_PRODUCT_IMAGES = 6; +export const MAX_EDIT_CLIP_INDEX = 40; +export const MAX_EDIT_INSTRUCTION_LENGTH = 500; +export const MAX_LIST_LIMIT = 100; - if (!payload.product.source_url && !Array.isArray(payload.product.images)) { - throw new Error("Create payload requires product.source_url or product.images from the upload endpoint."); - } +const LANGUAGE_SET = new Set(VIDEO_LANGUAGES); +const STATUS_SET = new Set(VIDEO_STATUSES); +const UPLOADED_IMAGE_PREFIX = "https://media.pixverse.ai/"; - for (const image of payload.product.images || []) { - if (!image.url || !image.url.startsWith("https://media.pixverse.ai/")) { - throw new Error("Product images must use URLs returned by the image upload endpoint."); - } +export function validateCreatePayload(payload) { + if (!isRecord(payload)) throw new Error("Create payload must be an object."); + if (payload.folder_id !== undefined) { + throw new Error("folder_id belongs at video.folder_id, not at the top level of the create payload."); } + validateProduct(payload.product); + validateVideo(payload.video); + if (payload.metadata !== undefined) validateMetadata(payload.metadata); } export function validateEditPayload(payload) { - if (!payload || typeof payload !== "object") throw new Error("Edit payload must be an object."); - if (!Number.isInteger(payload.clip_index) || payload.clip_index < 1) { - throw new Error("clip_index must be a one-based positive integer."); + if (!isRecord(payload)) throw new Error("Edit payload must be an object."); + if ( + !Number.isInteger(payload.clip_index) + || payload.clip_index < 1 + || payload.clip_index > MAX_EDIT_CLIP_INDEX + ) { + throw new Error(`clip_index must be a one-based integer from 1 through ${MAX_EDIT_CLIP_INDEX}.`); } - if (!payload.instruction || typeof payload.instruction !== "string") { + if (typeof payload.instruction !== "string" || !payload.instruction.trim()) { throw new Error("instruction is required."); } + if (payload.instruction.length > MAX_EDIT_INSTRUCTION_LENGTH) { + throw new Error(`instruction must be at most ${MAX_EDIT_INSTRUCTION_LENGTH} characters.`); + } } export function validateFolderPayload(payload) { @@ -32,6 +51,20 @@ export function validateFolderPayload(payload) { } } +export function validateListVideosOptions(options = {}) { + if (options.limit !== undefined) { + if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > MAX_LIST_LIMIT) { + throw new Error(`limit must be an integer from 1 through ${MAX_LIST_LIMIT}.`); + } + } + if (options.status !== undefined && !STATUS_SET.has(options.status)) { + throw new Error(`status must be one of: ${VIDEO_STATUSES.join(", ")}.`); + } + if (options.cursor !== undefined && typeof options.cursor !== "string") { + throw new Error("cursor must be a string."); + } +} + export function assertVideoId(videoId) { if (typeof videoId !== "string" || !/^\d+$/.test(videoId)) { throw new Error("video_id must be a numeric string."); @@ -43,3 +76,122 @@ export function assertFolderId(folderId) { throw new Error("folder_id must be a numeric string."); } } + +function validateProduct(product) { + if (!isRecord(product)) throw new Error("Create payload requires product."); + + const hasSourceUrl = product.source_url !== undefined; + if (hasSourceUrl) assertHttpUrl(product.source_url, "product.source_url", 2048); + + if (product.title !== undefined || !hasSourceUrl) { + assertNonEmptyString(product.title, "product.title", 255, hasSourceUrl ? "" : " when product.source_url is omitted"); + } + assertOptionalString(product.description, "product.description", 5120); + assertOptionalString(product.brand, "product.brand", 255); + + if (product.price !== undefined) { + if (!isRecord(product.price)) throw new Error("product.price must be an object."); + if (typeof product.price.amount !== "string" || !product.price.amount.trim()) { + throw new Error("product.price.amount must be a non-empty string such as \"89.99\"."); + } + if (typeof product.price.currency !== "string" || !/^[A-Za-z]{3}$/.test(product.price.currency)) { + throw new Error("product.price.currency must be a three-letter currency code."); + } + } + + if (product.images === undefined) { + if (!hasSourceUrl) { + throw new Error("Create payload requires product.source_url or product.images from the upload endpoint."); + } + return; + } + if (!Array.isArray(product.images)) throw new Error("product.images must be an array."); + if (!hasSourceUrl && product.images.length === 0) { + throw new Error("product.images requires at least one uploaded image when product.source_url is omitted."); + } + if (product.images.length > MAX_PRODUCT_IMAGES) { + throw new Error(`product.images accepts at most ${MAX_PRODUCT_IMAGES} images.`); + } + for (const image of product.images) { + if (!isRecord(image) || typeof image.url !== "string" || !image.url.startsWith(UPLOADED_IMAGE_PREFIX)) { + throw new Error("Product images must use URLs returned by the image upload endpoint."); + } + } +} + +function validateVideo(video) { + if (!isRecord(video)) throw new Error("Create payload requires video."); + + assertNonEmptyString(video.aspect_ratio, "video.aspect_ratio", Number.MAX_SAFE_INTEGER); + if (!VIDEO_DURATIONS.includes(video.duration_seconds)) { + throw new Error(`video.duration_seconds must be one of: ${VIDEO_DURATIONS.join(", ")}.`); + } + if (typeof video.resolution !== "string" || !VIDEO_RESOLUTIONS.includes(video.resolution.toLowerCase())) { + throw new Error(`video.resolution must be one of: ${VIDEO_RESOLUTIONS.join(", ")}.`); + } + if (!LANGUAGE_SET.has(video.language)) { + throw new Error("video.language must be one of the documented language codes, such as en-US."); + } + for (const field of ["voiceover", "captions", "background_music"]) { + if (typeof video[field] !== "boolean") throw new Error(`video.${field} must be a boolean.`); + } + + if (!isRecord(video.avatar)) throw new Error("video.avatar must be an object."); + if (!AVATAR_MODES.includes(video.avatar.mode)) { + throw new Error(`video.avatar.mode must be one of: ${AVATAR_MODES.join(", ")}.`); + } + if (video.avatar.mode === "custom") { + if (typeof video.avatar.url !== "string" || !video.avatar.url.trim()) { + throw new Error("video.avatar.url is required when video.avatar.mode is custom."); + } + } else if (video.avatar.url !== undefined) { + throw new Error(`video.avatar.url must be omitted when video.avatar.mode is ${video.avatar.mode}.`); + } + + assertOptionalString(video.creative_ad_clone, "video.creative_ad_clone", Number.MAX_SAFE_INTEGER); + assertOptionalString(video.subtitles_size_mode, "video.subtitles_size_mode", Number.MAX_SAFE_INTEGER); + if (video.folder_id !== undefined) assertFolderId(video.folder_id); +} + +function validateMetadata(metadata) { + if (!isRecord(metadata)) throw new Error("metadata must be an object of string values."); + for (const [key, value] of Object.entries(metadata)) { + if (typeof value !== "string") throw new Error(`metadata.${key} must be a string.`); + } +} + +function assertHttpUrl(value, label, maximumLength) { + if (typeof value !== "string" || !value || value.length > maximumLength) { + throw new Error(`${label} must be a non-empty string of at most ${maximumLength} characters.`); + } + let url; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be an HTTP or HTTPS URL.`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error(`${label} must be an HTTP or HTTPS URL.`); + } +} + +function assertNonEmptyString(value, label, maximumLength, condition = "") { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${label} is required${condition}.`); + } + if (value.length > maximumLength) { + throw new Error(`${label} must be at most ${maximumLength} characters.`); + } +} + +function assertOptionalString(value, label, maximumLength) { + if (value === undefined) return; + if (typeof value !== "string") throw new Error(`${label} must be a string.`); + if (value.length > maximumLength) { + throw new Error(`${label} must be at most ${maximumLength} characters.`); + } +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/platform/client.js b/src/platform/client.js index cdbbe7d..37b2a4c 100644 --- a/src/platform/client.js +++ b/src/platform/client.js @@ -46,9 +46,8 @@ export class PlatformClient { headers.set("Accept", "application/json"); headers.set("API-KEY", this.apiKey); headers.set("Ai-trace-id", traceId); - const retry = operation.billing === "read-only" && RETRYABLE_METHODS.has(operation.method) - ? this.retry - : { maxAttempts: 1 }; + const retrySafe = operation.billing === "read-only" && RETRYABLE_METHODS.has(operation.method); + const retry = retrySafe ? this.retry : { maxAttempts: 1 }; let response; try { @@ -63,6 +62,9 @@ export class PlatformClient { provider: "platform", operation: operation.id, traceId, + // Read-only retries need a unique Ai-trace-id per attempt. A recovery + // trace is never rotated: it only exists for single-attempt submissions. + nextTraceId: retrySafe && options.recovery !== true ? this.traceIdFactory : undefined, signal: options.signal, }); } catch (error) { @@ -70,20 +72,23 @@ export class PlatformClient { parsePlatformEnvelope(normalizePlatformResponseIdentifiers(error.details), { operation: operation.id, httpStatus: error.status, - traceId, + traceId: error.traceId ?? traceId, + retryAfter: error.retryAfter, }); } throw error; } + const attemptTraceId = response.traceId ?? traceId; const parsed = parsePlatformEnvelope(normalizePlatformResponseIdentifiers(response.body), { operation: operation.id, httpStatus: response.status, - traceId, + traceId: attemptTraceId, + retryAfter: response.retryAfter, }); return { operation: operation.id, - traceId, + traceId: attemptTraceId, envelope: parsed.envelope, data: parsed.data, retryAfter: response.retryAfter, diff --git a/src/platform/envelope.js b/src/platform/envelope.js index fee8e75..9226bfd 100644 --- a/src/platform/envelope.js +++ b/src/platform/envelope.js @@ -1,5 +1,16 @@ import { PixverseCliError } from "../core/errors.js"; +// Documented transient ErrCodes. 500069 is "system under high load, try again +// later"; 500044 is the concurrent-generation limit, which clears as running +// jobs finish. Marking them retryable lets read-only callers (status polling) +// keep waiting. It never authorizes an automatic billable resubmission: POST +// submissions are single-attempt regardless of this flag. +const RETRYABLE_PLATFORM_ERROR_CODES = new Set([500044, 500069]); + +export function isRetryablePlatformErrorCode(code) { + return RETRYABLE_PLATFORM_ERROR_CODES.has(Number(code)); +} + export function parsePlatformEnvelope(body, context = {}) { if (!isEnvelope(body)) { throw new PixverseCliError("Invalid Platform API response envelope.", { @@ -21,7 +32,8 @@ export function parsePlatformEnvelope(body, context = {}) { status: context.httpStatus, code: body.ErrCode, traceId: context.traceId, - retryable: false, + retryable: isRetryablePlatformErrorCode(errorCode), + ...(Number.isFinite(context.retryAfter) ? { retryAfter: context.retryAfter } : {}), details: body, }); } diff --git a/src/platform/jobs.js b/src/platform/jobs.js index 7978dd7..667d1a3 100644 --- a/src/platform/jobs.js +++ b/src/platform/jobs.js @@ -7,7 +7,7 @@ import { readJsonArtifact, writeJsonArtifact, } from "../core/artifacts.js"; -import { PixverseCliError, serializeError } from "../core/errors.js"; +import { isTransientReadError, PixverseCliError, serializeError } from "../core/errors.js"; import { pollUntilTerminal } from "../core/polling.js"; import { redact } from "../core/redaction.js"; import { createTraceId } from "../core/trace.js"; @@ -15,8 +15,12 @@ import { getPlatformOperation } from "./operations.js"; import { normalizePlatformStatus } from "./status.js"; import { normalizeAndValidatePlatformInput } from "./validation.js"; -const DEFAULT_INTERVAL_MS = 3_000; -const DEFAULT_TIMEOUT_MS = 10 * 60 * 1_000; +// Official status guidance: poll no more often than every 5 seconds. +const DEFAULT_INTERVAL_MS = 5_000; +// Generation can legitimately run well past 10 minutes (the FAQ covers jobs +// exceeding 10 minutes and 2 hours). A timeout is resumable from the job +// directory, so this only bounds a single waiting session. +const DEFAULT_TIMEOUT_MS = 30 * 60 * 1_000; export async function submitPlatformJob(client, operationId, input, options = {}) { const operation = requireBillableOperation(operationId); @@ -152,6 +156,16 @@ async function pollKnownJob(client, { jobDir, operation, id, traceId, options }) onSnapshot: (snapshot) => appendJsonLineArtifact( path.join(jobDir, "polling.jsonl"), snapshot, { rootDir: jobDir }, ), + // A status read is read-only: transport failures, HTTP 429/5xx, and + // retryable ErrCodes (for example 500069 high load) must not abandon a + // job that is still generating. Keep polling with backoff until the + // overall timeout. The billable submission above is never retried. + isRetryableError: (error) => options.signal?.aborted !== true && isTransientReadError(error), + onPollError: (error, { consecutiveErrors }) => appendJsonLineArtifact( + path.join(jobDir, "polling.jsonl"), + transientPollRecord(error, consecutiveErrors), + { rootDir: jobDir }, + ), intervalMs: options.intervalMs ?? DEFAULT_INTERVAL_MS, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, sleep: async (milliseconds) => { @@ -182,6 +196,23 @@ async function pollKnownJob(client, { jobDir, operation, id, traceId, options }) } } +function transientPollRecord(error, consecutiveErrors) { + const publicError = serializeError(error); + return redact({ + status: "poll_error", + terminal: false, + transient: true, + consecutive_errors: consecutiveErrors, + error: { + category: publicError.category ?? inferErrorCategory(error), + ...(publicError.status === undefined ? {} : { status: publicError.status }), + ...(publicError.code === undefined ? {} : { code: publicError.code }), + ...(publicError.retry_after === undefined ? {} : { retry_after: publicError.retry_after }), + ...(publicError.trace_id === undefined ? {} : { trace_id: publicError.trace_id }), + }, + }); +} + function timeoutRecoveryError(error, { jobDir, id }) { return new PixverseCliError(error.message, { category: error.category, diff --git a/src/platform/operations.js b/src/platform/operations.js index 0264b7a..61508c5 100644 --- a/src/platform/operations.js +++ b/src/platform/operations.js @@ -37,7 +37,7 @@ const DOCUMENTATION_URLS = Object.freeze({ "video.avatar": "https://docs.platform.pixverse.ai/avatar-generation-40528034e0", "agent.viral-recreation": "https://docs.platform.pixverse.ai/viral-recreation-agent-41205382e0", "agent.real-estate": "https://docs.platform.pixverse.ai/one-click-real-estate-video-42843647e0", - "agent.music-mv": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", "video.status": "https://docs.platform.pixverse.ai/get-video-generation-status-13016632e0", }); diff --git a/src/platform/request.js b/src/platform/request.js index b53fef4..9db00c9 100644 --- a/src/platform/request.js +++ b/src/platform/request.js @@ -5,6 +5,23 @@ import JSONBigFactory from "json-bigint"; import { PixverseCliError } from "../core/errors.js"; const PATH_PARAMETER = /\{([^}]+)\}/g; +// The template list documents template_ids as one comma-separated string. +const COMMA_SEPARATED_QUERY = new Set(["template_ids"]); +// Upload endpoints accept only these MIME types; an untyped Blob is sent as +// application/octet-stream, which the image upload reference rejects. +const UPLOAD_MIME_TYPES = new Map(Object.entries({ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".m4a": "audio/x-m4a", + ".aac": "audio/aac", +})); const JSON_BIG = JSONBigFactory({ useNativeBigInt: true, protoAction: "error", @@ -38,35 +55,94 @@ export async function buildPlatformRequest(operation, normalizedInput) { }; } +// Fields each operation's API reference types as integer. The validators keep +// identifiers as decimal strings so 64-bit values stay exact; they are turned +// back into JSON integers here. Fields typed as string (mask_id, mask_ids, +// lip_sync_tts_speaker_id, webhook_id) are deliberately absent. +const VIDEO_SOURCE_IDS = ["source_video_id", "video_media_id"]; +const INTEGER_ID_FIELDS = new Map(Object.entries({ + "audio.verify": ["audio_media_id"], + "voice.create": ["audio_media_id"], + // The schema types img_ids items as string, but the live API rejects quoted + // values (400013), matching the image template guide's integer examples. + "image.template": ["template_id", "img_ids[]"], + "video.text": ["template_id"], + "video.image": ["img_id", "template_id"], + "video.template": ["img_id", "img_ids[]", "template_id"], + "video.transition": ["first_frame_img", "last_frame_img"], + "video.multi-transition": ["multi_transition[].img_id"], + "video.lip-sync": [...VIDEO_SOURCE_IDS, "audio_media_id"], + // The fusion schema types video_references IDs as string, but the live API + // rejects quoted values (400013), matching the v6 video reference guide. + "video.fusion": [ + "image_references[].img_id", + "video_references[].source_video_id", + "video_references[].video_media_id", + ], + "video.avatar": ["img_id", "audio_media_id"], + "video.restyle": [...VIDEO_SOURCE_IDS, "restyle_id"], + "video.swap-mask": [...VIDEO_SOURCE_IDS, "keyframe_id"], + "video.swap": [...VIDEO_SOURCE_IDS, "keyframe_id", "img_id"], + "video.sound-effect": VIDEO_SOURCE_IDS, + "video.extend": [...VIDEO_SOURCE_IDS, "template_id"], + "video.motion-control": [...VIDEO_SOURCE_IDS, "img_id"], + "video.modify": [...VIDEO_SOURCE_IDS, "img_ids[]", "keyframe_id"], + "video.upscale": VIDEO_SOURCE_IDS, + "agent.viral-recreation": [ + "agent_id", + "img_references[].img_id", + "video_references[].video_media_id", + "video_references[].source_video_id", + ], + "agent.real-estate": ["agent_id", "img_references[].img_id"], + "agent.music-mv": ["audio_media_id", "image_references[].img_id", "style_img_references[].img_id"], +})); + +export function integerIdFields(operationId) { + return INTEGER_ID_FIELDS.get(operationId) ?? []; +} + function serializeJsonBody(operation, payload) { - if (!new Set(["audio.verify", "agent.music-mv", "video.fusion", "video.upscale"]).has(operation.id)) { - return JSON.stringify(payload); - } + const fields = INTEGER_ID_FIELDS.get(operation.id); + if (!fields) return JSON.stringify(payload); const wirePayload = { ...payload }; if (operation.id === "agent.music-mv" && wirePayload.img_references !== undefined) { wirePayload.image_references = wirePayload.img_references; delete wirePayload.img_references; } - if (wirePayload.audio_media_id !== undefined) { - wirePayload.audio_media_id = decimalStringToBigInt(wirePayload.audio_media_id); - } - for (const field of ["img_references", "image_references", "style_img_references"]) { - if (!Array.isArray(wirePayload[field])) continue; - wirePayload[field] = wirePayload[field].map((reference) => ({ - ...reference, - img_id: decimalStringToBigInt(reference.img_id), - })); + for (const field of fields) convertIntegerPath(operation, wirePayload, field.split("."), field); + return JSON_BIG.stringify(wirePayload); +} + +function convertIntegerPath(operation, container, [segment, ...rest], label) { + const isArray = segment.endsWith("[]"); + const key = isArray ? segment.slice(0, -2) : segment; + if (container === null || typeof container !== "object" || container[key] === undefined) return; + if (isArray) { + if (!Array.isArray(container[key])) return; + container[key] = container[key].map((item) => { + if (rest.length === 0) return toWireInteger(operation, label, item); + if (item === null || typeof item !== "object" || Array.isArray(item)) return item; + const copy = { ...item }; + convertIntegerPath(operation, copy, rest, label); + return copy; + }); + return; } - for (const field of ["source_video_id", "video_media_id"]) { - if (wirePayload[field] !== undefined) { - wirePayload[field] = decimalStringToBigInt(wirePayload[field]); - } + if (rest.length === 0) { + container[key] = toWireInteger(operation, label, container[key]); + return; } - return JSON_BIG.stringify(wirePayload); + if (container[key] === null || typeof container[key] !== "object") return; + container[key] = Array.isArray(container[key]) ? [...container[key]] : { ...container[key] }; + convertIntegerPath(operation, container[key], rest, label); } -function decimalStringToBigInt(value) { - return typeof value === "string" && /^[1-9]\d*$/.test(value) ? BigInt(value) : value; +function toWireInteger(operation, field, value) { + if (value === null || value === "") return value; + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value)) return BigInt(value); + throw requestError(operation, `${field} must be a non-negative integer ID.`); } function interpolatePath(operation, pathParams) { @@ -83,7 +159,9 @@ function appendQuery(requestPath, query) { const search = new URLSearchParams(); for (const [name, value] of Object.entries(query)) { if (value === undefined || value === null || value === "") continue; - if (Array.isArray(value)) { + if (Array.isArray(value) && COMMA_SEPARATED_QUERY.has(name)) { + search.append(name, value.map(serializeField).join(",")); + } else if (Array.isArray(value)) { for (const item of value) search.append(name, serializeField(item)); } else { search.append(name, serializeField(value)); @@ -99,7 +177,8 @@ async function buildMultipartBody(normalizedInput) { if (value !== undefined && value !== null) form.append(name, serializeField(value)); } for (const [name, filePath] of Object.entries(normalizedInput.files)) { - const blob = await fs.openAsBlob(filePath); + const type = UPLOAD_MIME_TYPES.get(path.extname(filePath).toLowerCase()); + const blob = await fs.openAsBlob(filePath, type ? { type } : undefined); form.append(name, blob, path.basename(filePath)); } return form; diff --git a/src/platform/validators/music-mv.js b/src/platform/validators/music-mv.js index 560e4b8..ca2f700 100644 --- a/src/platform/validators/music-mv.js +++ b/src/platform/validators/music-mv.js @@ -1,14 +1,15 @@ import { requireFields, validationError } from "./common.js"; -const AGENT_TYPES = new Set(["vibe_mv", "vibe_mv_v3_custom"]); +// vibe_mv_v3 is the documented legacy workflow; vibe_mv_v3_custom is recommended. +const AGENT_TYPES = new Set(["vibe_mv_v3_custom", "vibe_mv_v3"]); const ASPECT_RATIOS = new Set(["16:9", "9:16", "1:1", "4:3", "3:4"]); const QUALITIES = new Set(["720p", "1080p"]); const MUSIC_STYLES = new Set([ - "pop", "rock", "hiphop", "r&b", "jazz", "reggae", "country", "folk", + "pop", "rock", "hip hop", "hiphop", "r&b", "jazz", "reggae", "country", "folk", "electronic", "classical", "soul", "funk", "metal", "ambient", "others", ]); const MV_STYLES = new Set([ - "custom", "cinematic", "lo-fi", "dreamscape", "woolen felt", "candy", "vintage", + "custom", "cinematic", "lo-fi", "dreamscape", "woolen felt", "candy", "golden age", "voxel", "retro game", "claymation", "woodland tale", "impressionism", "decadence", "futuristic", "chromatic clash", "holiday", ]); @@ -18,13 +19,14 @@ const UINT64_MAX = 18_446_744_073_709_551_615n; export function normalizeAndValidateMusicMv(operation, payload) { const normalized = normalizeImageReferenceAlias(operation, payload); - requireFields(operation, normalized, [ - "mv_agent_type", "audio_media_id", "aspect_ratio", "quality", - ]); + requireFields(operation, normalized, ["mv_agent_type", "audio_media_id"]); validateExactEnum(operation, "mv_agent_type", normalized.mv_agent_type, AGENT_TYPES); validatePositiveDecimalId(operation, "audio_media_id", normalized.audio_media_id); - validateExactEnum(operation, "aspect_ratio", normalized.aspect_ratio, ASPECT_RATIOS); - validateExactEnum(operation, "quality", normalized.quality, QUALITIES); + // Both are optional; the API defaults to 16:9 and 720p. + if (normalized.aspect_ratio !== undefined) { + validateExactEnum(operation, "aspect_ratio", normalized.aspect_ratio, ASPECT_RATIOS); + } + if (normalized.quality !== undefined) validateExactEnum(operation, "quality", normalized.quality, QUALITIES); validateOptionalStyles(operation, normalized); validateReferences(operation, normalized); validateOptionalScalars(operation, normalized); @@ -68,7 +70,11 @@ function validateReferences(operation, payload) { } for (const reference of references) validateReference(operation, reference); } - if (String(payload.mv_style).toLowerCase() === "custom") { + const customStyle = String(payload.mv_style).toLowerCase() === "custom"; + if (!customStyle && (payload.style_img_references?.length ?? 0) > 0) { + throw validationError(operation, "style_img_references is supported only when mv_style is Custom."); + } + if (customStyle) { const count = (payload.img_references?.length ?? 0) + (payload.style_img_references?.length ?? 0); if (count === 0) throw validationError(operation, "Custom mv_style requires an image or style reference."); } diff --git a/src/platform/validators/specialized.js b/src/platform/validators/specialized.js index c65692d..4930f6f 100644 --- a/src/platform/validators/specialized.js +++ b/src/platform/validators/specialized.js @@ -10,6 +10,8 @@ import { inspectLocalMedia as inspectMedia } from "../../core/media.js"; import { normalizeAndValidateMusicMv, validatePositiveDecimalId } from "./music-mv.js"; const VIDEO_SOURCES = ["source_video_id", "video_media_id"]; +const EXTEND_MODELS = ["v3.5", "v4", "v4.5", "v5", "v5.5", "v6"]; +const SEED_MAXIMUM = 2_147_483_647; export async function validateSpecializedOperation(operation, payload, query = payload, context = {}) { switch (operation.id) { @@ -50,11 +52,23 @@ export async function validateSpecializedOperation(operation, payload, query = p return; case "video.sound-effect": requireExactlyOneField(operation, payload, VIDEO_SOURCES); - requireFields(operation, payload, ["prompt"]); + // The API has no prompt field; the sound description is sound_effect_content. + if (payload.prompt !== undefined) { + throw validationError(operation, "sound-effect has no prompt field; describe the sound with sound_effect_content."); + } + if (payload.sound_effect_content !== undefined && typeof payload.sound_effect_content !== "string") { + throw validationError(operation, "sound_effect_content must be a string."); + } + if (payload.original_sound_switch !== undefined && typeof payload.original_sound_switch !== "boolean") { + throw validationError(operation, "original_sound_switch must be a boolean."); + } return; case "video.extend": requireExactlyOneField(operation, payload, VIDEO_SOURCES); requireFields(operation, payload, ["prompt", "seed", "quality", "duration", "model"]); + if (!EXTEND_MODELS.includes(payload.model)) { + throw validationError(operation, `extend model must be one of ${EXTEND_MODELS.join(", ")}.`); + } validateModelScalars(operation, payload); return; case "video.motion-control": @@ -102,8 +116,9 @@ function validateUsage(operation, payload) { function validateTemplateQuery(operation, query) { if (query.type !== undefined && ![1, 2, "1", "2"].includes(query.type)) throw validationError(operation, "type must be 1 or 2."); - if (query.page !== undefined) validatePositiveInteger(operation, query.page, "page"); - if (query.pageSize !== undefined && ![10, 20, 50, 100].includes(query.pageSize)) throw validationError(operation, "pageSize must be 10, 20, 50, or 100."); + // The reference types these query values as strings, e.g. page "1" and pageSize "50". + if (query.page !== undefined) validatePositiveInteger(operation, toIntegerOrSelf(query.page), "page"); + if (query.pageSize !== undefined && ![10, 20, 50, 100].includes(toIntegerOrSelf(query.pageSize))) throw validationError(operation, "pageSize must be 10, 20, 50, or 100."); } function validateTtsQuery(operation, query) { @@ -118,6 +133,16 @@ async function validateViralAgent(operation, payload, context) { validateModelScalars(operation, payload, { qualities: ["720p", "1080p"], aspectRatios: ["9:16", "16:9", "1:1", "4:3", "3:4", "21:9"] }); if (!Array.isArray(payload.img_references) || payload.img_references.length < 1 || payload.img_references.length > 5) throw validationError(operation, "img_references must contain between 1 and 5 images."); if (!Array.isArray(payload.video_references) || payload.video_references.length !== 1) throw validationError(operation, "video_references must contain exactly one video."); + // Each reference must carry an uploaded asset ID; the API has no URL or file fields here. + payload.img_references.forEach((reference, index) => { + if (!isReferenceObject(reference) || !isPresent(reference.img_id)) { + throw validationError(operation, `img_references[${index}] requires an img_id from upload image.`); + } + }); + payload.video_references.forEach((reference, index) => { + if (!isReferenceObject(reference)) throw validationError(operation, `video_references[${index}] must be an object.`); + requireExactlyOneField(operation, reference, VIDEO_SOURCES, `video_references[${index}] source_video_id or video_media_id`); + }); const inspector = context.inspectLocalMedia ?? inspectMedia; for (const reference of payload.img_references) { const file = localReferencePath(reference); @@ -132,13 +157,25 @@ async function validateViralAgent(operation, payload, context) { if (metadata.duration_seconds < 2 || metadata.duration_seconds > 30) throw validationError(operation, "Viral-agent video duration must be between 2 and 30 seconds."); if (metadata.width < 640 || metadata.width > 1920 || metadata.height < 640 || metadata.height > 1920) throw validationError(operation, "Viral-agent video width and height must each be between 640 and 1920 pixels."); } + // Local paths only feed the media checks above and must not reach the API. + return { + ...payload, + img_references: payload.img_references.map(withoutLocalPath), + video_references: payload.video_references.map(withoutLocalPath), + }; +} + +function withoutLocalPath({ file, local_path, ...reference }) { + return reference; } function validateRealEstateAgent(operation, payload) { requireFields(operation, payload, ["agent_id", "model"]); if (payload.agent_id !== "419629433597950") throw validationError(operation, "real estate agent_id must be 419629433597950."); if (!["pro", "fast"].includes(payload.model)) throw validationError(operation, "real estate model must be pro or fast."); - if (payload.mode !== undefined && payload.model !== "pro") throw validationError(operation, "mode is supported only by the pro real estate model."); + for (const field of ["mode", "duration", "video_style", "lip_sync_tts_speaker_id"]) { + if (payload[field] !== undefined && payload.model !== "pro") throw validationError(operation, `${field} is supported only by the pro real estate model.`); + } if (payload.mode !== undefined && !["property_tour", "ad_house_tour"].includes(payload.mode)) throw validationError(operation, "mode must be property_tour or ad_house_tour."); if (isPresent(payload.product_url)) { validateSafeRemoteUrl(operation, "product_url", payload.product_url); @@ -162,11 +199,14 @@ function validateRealEstateAgent(operation, payload) { if (payload.quality !== undefined) validateQuality(operation, payload.quality, qualities); } +function toIntegerOrSelf(value) { + return typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value; +} function validatePositiveInteger(operation, value, name) { if (!Number.isInteger(value) || value < 1) throw validationError(operation, `${name} must be a positive integer.`); } function validateOptionalSeed(operation, value) { - if (value !== undefined && (!Number.isInteger(value) || value < 0)) throw validationError(operation, "seed must be a non-negative integer."); + if (value !== undefined && (!Number.isInteger(value) || value < 0 || value > SEED_MAXIMUM)) throw validationError(operation, `seed must be an integer from 0 to ${SEED_MAXIMUM}.`); } function validateKeyframe(operation, value, required) { if (!isPresent(value)) { @@ -174,13 +214,12 @@ function validateKeyframe(operation, value, required) { return; } const normalized = typeof value === "number" && Number.isSafeInteger(value) ? String(value) : value; - const minimum = operation.id === "video.swap-mask" ? 0n : 1n; + // Documented range: 1 to the position of the last video frame. if (typeof normalized !== "string" || !/^\d+$/.test(normalized) - || BigInt(normalized) < minimum + || BigInt(normalized) < 1n || BigInt(normalized) > BigInt(Number.MAX_SAFE_INTEGER)) { - const range = minimum === 0n ? "non-negative" : "positive"; - throw validationError(operation, `keyframe_id must be a ${range} safe-integer string.`); + throw validationError(operation, "keyframe_id must be a positive safe-integer string."); } } function validateQuality(operation, value, allowed) { @@ -189,6 +228,9 @@ function validateQuality(operation, value, allowed) { function validateMaximumItems(operation, value, field, maximum) { if (value !== undefined && (!Array.isArray(value) || value.length > maximum)) throw validationError(operation, `${field} must contain no more than ${maximum} items.`); } +function isReferenceObject(reference) { + return reference !== null && typeof reference === "object" && !Array.isArray(reference); +} function localReferencePath(reference) { if (!reference || typeof reference !== "object" || Array.isArray(reference)) return undefined; return reference.file ?? reference.local_path; diff --git a/src/platform/validators/video.js b/src/platform/validators/video.js index 825c322..4696ac7 100644 --- a/src/platform/validators/video.js +++ b/src/platform/validators/video.js @@ -21,13 +21,20 @@ export function validateVideoOperation(operation, payload) { validateModelScalars(operation, payload); requireFields(operation, payload, ["duration", "img_id", "model", "prompt", "quality"]); break; - case "video.template": - validateModelScalars(operation, payload, { promptMaxLength: 2048 }); - requireFields(operation, payload, ["template_id", "duration", "model", "prompt", "quality"]); + case "video.template": { + // The template supplies the prompt: it may be empty and is ignored when template_id is set. + if (payload.prompt === undefined) payload.prompt = ""; + if (typeof payload.prompt !== "string" || payload.prompt.length > 2048) { + throw validationError(operation, "prompt must be a string no longer than 2048 characters."); + } + const { prompt, ...scalars } = payload; + validateModelScalars(operation, scalars); + requireFields(operation, payload, ["template_id", "duration", "model", "quality"]); if (!isPresent(payload.img_id) && (!Array.isArray(payload.img_ids) || payload.img_ids.length === 0)) { throw validationError(operation, "img_id or img_ids image source is required."); } break; + } case "video.transition": validateModelScalars(operation, payload); requireFields(operation, payload, ["prompt", "model", "duration", "quality"]); @@ -46,6 +53,10 @@ export function validateVideoOperation(operation, payload) { validateModelScalars(operation, payload); requireFields(operation, payload, ["img_id", "quality"]); validateAudioOrTts(operation, payload, true); + if (isPresent(payload.lip_sync_tts_content) + && (payload.lip_sync_tts_content.length < 30 || payload.lip_sync_tts_content.length > 200)) { + throw validationError(operation, "avatar lip_sync_tts_content must contain 30 to 200 characters."); + } break; default: throw validationError(operation, `No video validator exists for ${operation.id}.`); @@ -53,8 +64,8 @@ export function validateVideoOperation(operation, payload) { } function validateTransition(operation, payload) { - requireField(operation, payload, ["first_frame_img", "first_frame_img_id", "first_frame_image_id", "first_frame_url"], "first frame"); - requireField(operation, payload, ["last_frame_img", "last_frame_img_id", "last_frame_image_id", "last_frame_url"], "last frame"); + // The API only reads first_frame_img/last_frame_img; aliases would be sent but ignored. + requireFields(operation, payload, ["first_frame_img", "last_frame_img"]); } function validateMultiTransition(operation, payload) { @@ -72,13 +83,19 @@ function validateMultiTransition(operation, payload) { throw validationError(operation, `multi-transition item ${index + 1} must be an object.`); } requireField(operation, item, ["img_id"], `multi-transition item ${index + 1} img_id`); - if (index < items.length - 1 && !isPresent(item.duration)) { + const last = index === items.length - 1; + if (last) { + if (isPresent(item.duration) && item.duration !== 0) { + throw validationError(operation, "the last multi-transition item duration must be 0 or omitted."); + } + return; + } + if (!isPresent(item.duration)) { throw validationError(operation, "duration is required for every multi-transition item except the last item."); } - const minimumDuration = index === items.length - 1 ? 0 : 1; - if (isPresent(item.duration) && (!Number.isInteger(item.duration) || item.duration < minimumDuration)) { - const range = minimumDuration === 0 ? "a non-negative integer" : "a positive integer"; - throw validationError(operation, `multi-transition item duration must be ${range}.`); + const maximumDuration = items.length >= 3 ? 5 : 8; + if (!Number.isInteger(item.duration) || item.duration < 1 || item.duration > maximumDuration) { + throw validationError(operation, `multi-transition item duration must be an integer from 1 to ${maximumDuration} when there are ${items.length} items.`); } }); } @@ -110,15 +127,26 @@ function validateFusion(operation, payload) { ? { aspectRatios: ["16:9", "4:3", "1:1", "3:4", "9:16", "2:3", "3:2", "21:9", "auto"] } : {}; validateModelScalars(operation, scalarPayload, scalarOptions); - requireFields(operation, payload, ["image_references", "prompt", "model", "duration", "quality", "aspect_ratio"]); + // v6 omni requests may reference only videos (fusion v6 video reference guide). + requireFields(operation, payload, omniVideo + ? ["prompt", "model", "duration", "quality", "aspect_ratio"] + : ["image_references", "prompt", "model", "duration", "quality", "aspect_ratio"]); if (!["v4.5", "v5", "v5.5", "v5.6", "v6", "c1"].includes(payload.model)) { throw validationError(operation, "fusion model must be v4.5, v5, v5.5, v5.6, v6, or c1."); } validateFusionModelRules(operation, payload, omniVideo); - const references = payload.image_references; - if (!Array.isArray(references) || references.length === 0) { + if (omniVideo && payload.video_references.length > 2) { + throw validationError(operation, "fusion accepts at most two reference videos."); + } + const references = payload.image_references ?? (omniVideo ? [] : undefined); + if (!Array.isArray(references) || (references.length === 0 && !omniVideo)) { throw validationError(operation, "fusion requires one or more image references."); } + for (const reference of references) { + if (reference?.type !== undefined && !["subject", "background"].includes(reference.type)) { + throw validationError(operation, "fusion image reference type must be subject or background."); + } + } const maximum = payload.model === "v6" && payload.reference_mode === "omni" ? 10 : ["v5.5", "v5.6", "v6", "c1"].includes(payload.model) ? 7 : 3; if (references.length > maximum) throw validationError(operation, `fusion accepts at most ${maximum} image references for this model.`); diff --git a/src/platform/webhooks.js b/src/platform/webhooks.js index 7e424b4..9efd159 100644 --- a/src/platform/webhooks.js +++ b/src/platform/webhooks.js @@ -13,8 +13,20 @@ const MAX_BODY_BYTES = 1_048_576; const MAX_SECRET_BYTES = 4_096; const MAX_NONCE_BYTES = 256; const MAX_TRACE_ID_BYTES = 256; -const MAX_TIMESTAMP_SKEW_SECONDS = 300; -const MAX_NONCES = 10_000; +// PixVerse retries a failed delivery after 2s, 10s, 30s, 1m, 5m, 15m, 1h and +// 4h (about 5h22m in total), and the docs do not say whether a retry is +// re-signed with a fresh Webhook-Timestamp. A 300s window could therefore +// reject PixVerse's own late retries and lose the result. The default accepts +// the whole documented schedule plus margin. Widening the window is safe +// because replays are still stopped by the nonce store (each nonce is held +// for the full window) and by (payload id, status) event deduplication. +export const DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 6 * 60 * 60; +const MAX_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 7 * 24 * 60 * 60; +// Clock skew allowed for timestamps in the future; deliveries are never +// legitimately signed far ahead of the receiver's clock. +const MAX_FUTURE_SKEW_SECONDS = 300; +const MAX_NONCES = 100_000; +const EVENT_KEY_PREFIX = "pixverse-webhook-event:"; const DEFAULT_COMMIT_TIMEOUT_MS = 5_000; const MAX_COMMIT_TIMEOUT_MS = 60_000; const REQUIRED_HEADERS = new Map([ @@ -30,7 +42,12 @@ export function createMemoryNonceStore(options = {}) { for (const [storedNonce, entry] of entries) { if (entry.expiresAt < nowSeconds) entries.delete(storedNonce); } - if (entries.has(nonce)) return undefined; + const existing = entries.get(nonce); + // A committed key is an already-processed delivery: report a duplicate. + // An in-flight reservation must not be acknowledged, because the first + // attempt may still fail and release it; ask the sender to retry later. + if (existing?.state === "committed") return undefined; + if (existing) throw inProgressError(); if (entries.size >= maxEntries) throw webhookError( "Webhook replay protection is temporarily unavailable.", "WEBHOOK_NONCE_STORE_FULL", @@ -80,20 +97,21 @@ export async function verifyPlatformWebhook(options = {}) { if (!/^[0-9]{1,13}$/.test(timestamp)) { throw webhookError("Webhook authentication failed.", "WEBHOOK_TIMESTAMP_INVALID", 401); } + const toleranceSeconds = normalizeTimestampTolerance(options.timestampToleranceSeconds); const timestampSeconds = unixSeconds(Number(timestamp)); const nowSeconds = unixSeconds(resolveNow(options.now)); - if (Math.abs(nowSeconds - timestampSeconds) > MAX_TIMESTAMP_SKEW_SECONDS) { + const ageSeconds = nowSeconds - timestampSeconds; + if (ageSeconds > toleranceSeconds || -ageSeconds > Math.min(MAX_FUTURE_SKEW_SECONDS, toleranceSeconds)) { throw webhookError("Webhook authentication failed.", "WEBHOOK_TIMESTAMP_OUT_OF_RANGE", 401); } const payload = parsePlatformWebhook(rawBody); - const [insertionOrderQuery, sortedQuery] = canonicalWebhookQueries(payload); - const insertionExpected = signWebhookQuery(secret, timestamp, nonce, insertionOrderQuery); - const sortedExpected = signWebhookQuery(secret, timestamp, nonce, sortedQuery); const supplied = decodeSha256Signature(signature); - const matchesInsertionOrder = timingSafeEqual(insertionExpected, supplied); - const matchesSortedOrder = timingSafeEqual(sortedExpected, supplied); - if ((Number(matchesInsertionOrder) | Number(matchesSortedOrder)) === 0) { + let matched = 0; + for (const query of canonicalWebhookQueries(payload)) { + matched |= Number(timingSafeEqual(signWebhookQuery(secret, timestamp, nonce, query), supplied)); + } + if (matched === 0) { throw webhookError("Webhook authentication failed.", "WEBHOOK_SIGNATURE_INVALID", 401); } @@ -108,29 +126,81 @@ export async function verifyPlatformWebhook(options = {}) { 500, ); } - const reservation = typeof nonceStore.reserve === "function" - ? await nonceStore.reserve(nonce, timestampSeconds + MAX_TIMESTAMP_SKEW_SECONDS, nowSeconds) - : reserveSetLikeNonceStore(nonceStore, nonce); - if (!reservation) throw webhookError("Webhook delivery was already received.", "WEBHOOK_REPLAY", 409); - if (typeof reservation.commit !== "function" || typeof reservation.release !== "function") { - throw webhookError( - "Webhook replay protection returned an invalid reservation.", - "WEBHOOK_NONCE_STORE_INVALID", - 500, - ); + // The nonce must stay reserved for as long as its timestamp is acceptable. + const nonceReservation = await reserveKey(nonceStore, nonce, timestampSeconds + toleranceSeconds, nowSeconds); + if (!nonceReservation) { + throw duplicateError("Webhook delivery was already received.", "WEBHOOK_REPLAY"); } + // PixVerse recommends idempotency by payload `id`. A redelivery can carry a + // fresh nonce, so also deduplicate on (id, status); a later status for the + // same id is a distinct event and still delivered. + const eventKey = webhookEventKey(payload); + let eventReservation; + if (eventKey !== undefined) { + try { + eventReservation = await reserveKey(nonceStore, eventKey, nowSeconds + toleranceSeconds, nowSeconds); + } catch (error) { + await safeRelease(nonceReservation); + throw error; + } + if (!eventReservation) { + await safeRelease(nonceReservation); + throw duplicateError("Webhook event was already processed.", "WEBHOOK_DUPLICATE_EVENT"); + } + } + const reservations = eventReservation ? [nonceReservation, eventReservation] : [nonceReservation]; + return { timestamp, nonce, signature, traceId, payload, - commit: () => reservation.commit(), - release: () => reservation.release(), + commit: async () => { + const results = await Promise.all(reservations.map((reservation) => reservation.commit())); + return results.every((result) => result === true); + }, + release: async () => { + const results = await Promise.all(reservations.map((reservation) => reservation.release())); + return results.every((result) => result === true); + }, }; } +async function reserveKey(store, key, expiresAt, nowSeconds) { + const reservation = typeof store.reserve === "function" + ? await store.reserve(key, expiresAt, nowSeconds) + : reserveSetLikeNonceStore(store, key); + if (!reservation) return undefined; + if (typeof reservation.commit !== "function" || typeof reservation.release !== "function") { + throw webhookError( + "Webhook replay protection returned an invalid reservation.", + "WEBHOOK_NONCE_STORE_INVALID", + 500, + ); + } + return reservation; +} + +async function safeRelease(reservation) { + try { + await reservation.release(); + } catch { + // Never disclose store internals; the original outcome is reported. + } +} + +function webhookEventKey(payload) { + const { id } = payload; + if (!((typeof id === "string" && id !== "") || (typeof id === "number" && Number.isFinite(id)))) { + return undefined; + } + const status = payload.status; + const statusPart = status === undefined || status === null || typeof status === "object" ? "" : String(status); + return `${EVENT_KEY_PREFIX}${JSON.stringify([String(id), statusPart])}`; +} + export function parsePlatformWebhook(rawBody) { const normalized = normalizeRawBody(rawBody); let payload; @@ -154,6 +224,7 @@ export function createPlatformWebhookHandler(options = {}) { } const nonceStore = options.nonceStore ?? createMemoryNonceStore(); const commitTimeoutMs = normalizeCommitTimeout(options.commitTimeoutMs); + const timestampToleranceSeconds = normalizeTimestampTolerance(options.timestampToleranceSeconds); return async function platformWebhookHandler(request, response) { let verification; @@ -170,6 +241,7 @@ export function createPlatformWebhookHandler(options = {}) { secret: options.secret, now: options.now, nonceStore, + timestampToleranceSeconds, }); const context = { timestamp: verification.timestamp, @@ -184,6 +256,14 @@ export function createPlatformWebhookHandler(options = {}) { response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); response.end("ok"); } catch (error) { + if (error?.duplicate === true && !response.headersSent) { + // A verified redelivery of an already-processed event. PixVerse keeps + // resending until it receives exact `ok`, so acknowledge it without + // invoking onDelivery again. + response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); + response.end("ok"); + return; + } if (verification && !commitStarted) { try { await verification.release(); @@ -308,20 +388,42 @@ function decodeSha256Signature(value) { return decoded; } +// The official Node example serializes each value with String(value), so a +// null (for example "url": null on a failure delivery) signs as "null". The Go +// (`fmt %v` -> "") and Python (`str` -> "None") examples differ, so those +// spellings are also accepted for null values; every candidate is still a full +// HMAC comparison. Rejecting null would return non-ok and lose the delivery. +const NULL_SPELLINGS = ["null", "", "None"]; + function canonicalWebhookQueries(payload) { - const entries = Object.entries(payload).map(([key, value]) => { - if (value === null || typeof value === "object") { + let hasNull = false; + for (const value of Object.values(payload)) { + if (value === null) hasNull = true; + else if (typeof value === "object") { throw webhookError( "Webhook payload contains a value unsupported by the documented signature format.", "WEBHOOK_PAYLOAD_UNSUPPORTED", 400, ); } - return [key, String(value)]; - }); - const insertionOrder = new URLSearchParams(entries).toString(); - const sortedEntries = [...entries].sort(([left], [right]) => left.localeCompare(right)); - return [insertionOrder, new URLSearchParams(sortedEntries).toString()]; + } + const queries = []; + for (const nullSpelling of hasNull ? NULL_SPELLINGS : [NULL_SPELLINGS[0]]) { + const entries = Object.entries(payload).map(([key, value]) => [ + key, value === null ? nullSpelling : String(value), + ]); + // Code-point (byte) order, matching Go url.Values.Encode and Java TreeMap + // for the documented keys; localeCompare is locale- and case-folding-aware. + const sortedEntries = [...entries].sort(([left], [right]) => compareCodePoints(left, right)); + queries.push(new URLSearchParams(entries).toString(), new URLSearchParams(sortedEntries).toString()); + } + return queries; +} + +function compareCodePoints(left, right) { + if (left < right) return -1; + if (left > right) return 1; + return 0; } function signWebhookQuery(secret, timestamp, nonce, query) { @@ -330,19 +432,32 @@ function signWebhookQuery(secret, timestamp, nonce, query) { .digest(); } +const SET_LIKE_IN_FLIGHT = new WeakMap(); + function reserveSetLikeNonceStore(store, nonce) { - if (store.has(nonce)) return undefined; + let inFlight = SET_LIKE_IN_FLIGHT.get(store); + if (!inFlight) { + inFlight = new Set(); + SET_LIKE_IN_FLIGHT.set(store, inFlight); + } + if (store.has(nonce)) { + if (inFlight.has(nonce)) throw inProgressError(); + return undefined; + } store.add(nonce); + inFlight.add(nonce); let state = "reserved"; return Object.freeze({ commit() { if (state !== "reserved") return false; state = "committed"; + inFlight.delete(nonce); return true; }, release() { if (state !== "reserved") return false; state = "released"; + inFlight.delete(nonce); return store.delete(nonce); }, }); @@ -390,6 +505,18 @@ function positiveInteger(value, fallback) { return Number.isSafeInteger(value) && value > 0 ? value : fallback; } +function normalizeTimestampTolerance(value) { + if (value === undefined) return DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS; + if (!Number.isSafeInteger(value) || value < 1 || value > MAX_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) { + throw webhookError( + "Invalid webhook timestamp tolerance.", + "WEBHOOK_CONFIGURATION_INVALID", + 500, + ); + } + return value; +} + function normalizeCommitTimeout(value) { if (value === undefined) return DEFAULT_COMMIT_TIMEOUT_MS; if (!Number.isSafeInteger(value) || value < 1 || value > MAX_COMMIT_TIMEOUT_MS) { @@ -410,6 +537,21 @@ function toPlainJsonValue(value) { return value; } +function duplicateError(message, code) { + // statusCode 200: the correct reply to a verified duplicate is exact `ok`. + const error = webhookError(message, code, 200); + error.duplicate = true; + return error; +} + +function inProgressError() { + return webhookError( + "Webhook delivery is already being processed.", + "WEBHOOK_DELIVERY_IN_PROGRESS", + 409, + ); +} + function webhookError(message, code, statusCode, cause) { const error = new PixverseCliError(message, { category: "webhook", diff --git a/test/catalog-completeness.test.js b/test/catalog-completeness.test.js index ba7d767..02d7c83 100644 --- a/test/catalog-completeness.test.js +++ b/test/catalog-completeness.test.js @@ -11,7 +11,11 @@ const ROOT = process.cwd(); const PLATFORM_SKILL = path.join(ROOT, ".agents/skills/pixverse-platform-api/SKILL.md"); const PLATFORM_OPERATION_REFERENCE = path.join(ROOT, ".agents/skills/pixverse-platform-api/references/operation-catalog.md"); const ROUTER_SKILL = path.join(ROOT, ".agents/skills/pixverse-api/SKILL.md"); -const MUSIC_MV_DOCUMENTATION_URL = "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc"; +// audio.verify has no public page yet; Music MV is documented as One-click VibeMV. +const MUSIC_MV_DOCUMENTATION_URLS = { + "audio.verify": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", +}; const MUSIC_MV_OPERATION_IDS = new Set(["audio.verify", "agent.music-mv"]); test("Platform catalog, validators, fixtures, and help cover the independent inventory", async () => { @@ -33,7 +37,7 @@ test("Platform catalog, validators, fixtures, and help cover the independent inv const helpLine = help.split("\n").find((line) => line.includes(`pixverse-api platform ${operation.command.join(" ")}`)); assert.equal(helpLine.includes("[--no-wait]"), operation.billing === "billable"); if (MUSIC_MV_OPERATION_IDS.has(operation.id)) { - assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URL); + assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URLS[operation.id]); } else { assert.equal(new URL(operation.documentationUrl).hostname, "docs.platform.pixverse.ai"); } diff --git a/test/client.test.js b/test/client.test.js index fbef4ed..c93338c 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; import { GrowthStudioClient, @@ -6,6 +9,7 @@ import { validateCreatePayload, validateEditPayload, validateFolderPayload, + validateListVideosOptions, } from "../src/client.js"; import { GrowthStudioApiError, buildUrl, parseResponse } from "../src/http.js"; @@ -44,29 +48,54 @@ test("createProductUrlPayload can target a Growth Studio folder", () => { folderId: "630251570268735431", }); - assert.equal(payload.folder_id, "630251570268735431"); + assert.equal(payload.video.folder_id, "630251570268735431"); + assert.equal("folder_id" in payload, false); + assert.doesNotThrow(() => validateCreatePayload(payload)); }); -test("validateCreatePayload requires folder_id to stay a numeric string", () => { +function validVideo(overrides = {}) { + return { + aspect_ratio: "9:16", + duration_seconds: 30, + resolution: "1080p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: { mode: "auto" }, + ...overrides, + }; +} + +const UPLOADED_IMAGE = "https://media.pixverse.ai/marketing_hub_website/openapi_video_inputs/uploads/22/11/product.webp"; + +test("validateCreatePayload keeps folder_id at video.folder_id as a numeric string", () => { assert.doesNotThrow(() => validateCreatePayload({ - folder_id: "630251570268735431", product: { source_url: "https://shop.example.com/products/running-shoes" }, - video: { avatar: { mode: "auto" } }, + video: validVideo({ folder_id: "630251570268735431" }), })); assert.throws(() => validateCreatePayload({ - folder_id: 630251570268735431, product: { source_url: "https://shop.example.com/products/running-shoes" }, - video: { avatar: { mode: "auto" } }, + video: validVideo({ folder_id: 630251570268735431 }), }), /folder_id must be a numeric string/); + assert.throws(() => validateCreatePayload({ + folder_id: "630251570268735431", + product: { source_url: "https://shop.example.com/products/running-shoes" }, + video: validVideo(), + }), /video\.folder_id/); }); test("validateCreatePayload accepts uploaded PixVerse image URLs", () => { assert.doesNotThrow(() => validateCreatePayload({ product: { title: "Shoes", - images: [{ url: "https://media.pixverse.ai/marketing_hub_website/openapi_video_inputs/uploads/22/11/product.webp" }], + description: "Lightweight running shoes.", + brand: "ACME", + price: { amount: "89.99", currency: "USD" }, + images: [{ url: UPLOADED_IMAGE }], }, - video: { avatar: { mode: "auto" } }, + video: validVideo({ resolution: "720P", avatar: { mode: "custom", url: "https://media.pixverse.ai/asset/avatar/urban.webp" } }), + metadata: { order_id: "order_123456" }, })); }); @@ -76,15 +105,89 @@ test("validateCreatePayload rejects arbitrary image URLs", () => { title: "Shoes", images: [{ url: "https://shop.example.com/product.webp" }], }, - video: { avatar: { mode: "auto" } }, + video: validVideo(), }), /Product images must use URLs returned by the image upload endpoint/); }); +test("validateCreatePayload enforces the documented product contract", () => { + const cases = [ + [{ source_url: "ftp://shop.example.com/item" }, /source_url must be an HTTP or HTTPS URL/], + [{ source_url: `https://shop.example.com/${"a".repeat(2048)}` }, /source_url must be .*2048/], + [{ images: [{ url: UPLOADED_IMAGE }] }, /product\.title is required/], + [{ title: "x".repeat(256), images: [{ url: UPLOADED_IMAGE }] }, /product\.title must be at most 255/], + [{ title: "Shoes" }, /product\.source_url or product\.images/], + [{ title: "Shoes", images: [] }, /at least one uploaded image/], + [{ title: "Shoes", images: Array.from({ length: 7 }, () => ({ url: UPLOADED_IMAGE })) }, /at most 6 images/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], description: "d".repeat(5121) }, /description must be at most 5120/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], brand: "b".repeat(256) }, /brand must be at most 255/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], price: { amount: 89.99, currency: "USD" } }, /price\.amount/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], price: { amount: "89.99" } }, /price\.currency/], + [{ title: "Shoes", images: [{ url: UPLOADED_IMAGE }], price: { amount: "89.99", currency: "US" } }, /price\.currency/], + ]; + for (const [product, message] of cases) { + assert.throws(() => validateCreatePayload({ product, video: validVideo() }), message); + } +}); + +test("validateCreatePayload enforces the documented video and metadata contract", () => { + const product = { source_url: "https://shop.example.com/products/running-shoes" }; + const cases = [ + [undefined, /requires video/], + [validVideo({ aspect_ratio: undefined }), /aspect_ratio is required/], + [validVideo({ duration_seconds: 20 }), /duration_seconds must be one of: 15, 30, 45, 60/], + [validVideo({ duration_seconds: "30" }), /duration_seconds/], + [validVideo({ resolution: "4k" }), /resolution must be one of/], + [validVideo({ language: "en" }), /language must be one of/], + [validVideo({ voiceover: "yes" }), /voiceover must be a boolean/], + [validVideo({ captions: undefined }), /captions must be a boolean/], + [validVideo({ background_music: 1 }), /background_music must be a boolean/], + [validVideo({ avatar: undefined }), /avatar must be an object/], + [validVideo({ avatar: { mode: "random" } }), /avatar\.mode must be one of/], + [validVideo({ avatar: { mode: "custom" } }), /avatar\.url is required/], + [validVideo({ avatar: { mode: "auto", url: "https://media.pixverse.ai/a.webp" } }), /avatar\.url must be omitted/], + [validVideo({ avatar: { mode: "disabled", url: "https://media.pixverse.ai/a.webp" } }), /avatar\.url must be omitted/], + [validVideo({ creative_ad_clone: 1 }), /creative_ad_clone must be a string/], + [validVideo({ subtitles_size_mode: true }), /subtitles_size_mode must be a string/], + ]; + for (const [video, message] of cases) { + assert.throws(() => validateCreatePayload({ product, video }), message); + } + assert.doesNotThrow(() => validateCreatePayload({ + product, + video: validVideo({ duration_seconds: 15, avatar: { mode: "disabled" }, creative_ad_clone: "Hook first.", subtitles_size_mode: "large" }), + })); + assert.throws( + () => validateCreatePayload({ product, video: validVideo(), metadata: { order: 1 } }), + /metadata\.order must be a string/, + ); + assert.throws( + () => validateCreatePayload({ product, video: validVideo(), metadata: ["x"] }), + /metadata must be an object/, + ); +}); + +test("validateListVideosOptions enforces documented limit and status values", () => { + assert.doesNotThrow(() => validateListVideosOptions({ limit: 1, status: "canceled" })); + assert.doesNotThrow(() => validateListVideosOptions({ limit: 100 })); + assert.throws(() => validateListVideosOptions({ limit: 0 }), /limit must be an integer from 1 through 100/); + assert.throws(() => validateListVideosOptions({ limit: 101 }), /limit/); + assert.throws(() => validateListVideosOptions({ limit: "20" }), /limit/); + assert.throws(() => validateListVideosOptions({ status: "done" }), /status must be one of/); +}); + test("validateEditPayload requires one-based clip index", () => { assert.throws(() => validateEditPayload({ clip_index: 0, instruction: "Try again." }), /one-based/); assert.doesNotThrow(() => validateEditPayload({ clip_index: 1, instruction: "Try again." })); }); +test("validateEditPayload enforces clip_index 1-40 and a 500-character instruction", () => { + assert.doesNotThrow(() => validateEditPayload({ clip_index: 40, instruction: "x".repeat(500) })); + assert.throws(() => validateEditPayload({ clip_index: 41, instruction: "Try again." }), /1 through 40/); + assert.throws(() => validateEditPayload({ clip_index: 1.5, instruction: "Try again." }), /1 through 40/); + assert.throws(() => validateEditPayload({ clip_index: 2, instruction: " " }), /instruction is required/); + assert.throws(() => validateEditPayload({ clip_index: 2, instruction: "x".repeat(501) }), /at most 500/); +}); + test("parseResponse throws structured API errors", async () => { const response = new Response(JSON.stringify({ error: { @@ -147,12 +250,12 @@ test("client keeps video IDs as strings and sends bearer auth", async () => { assert.equal(calls[0].init.headers.get("Authorization"), "Bearer test-api-key"); }); -test("client lists and creates folders through Growth Studio folder endpoints", async () => { +test("client lists and creates folders through the documented /openapi/v1/folders endpoints", async () => { const calls = []; const credentials = { ["api" + "Key"]: "test-api-key" }; const responses = [ - new Response(JSON.stringify({ ErrCode: 0, Resp: { folders: [{ folder_id: "636771263750078906", name: "REVOLVE" }] } }), { status: 200 }), - new Response(JSON.stringify({ ErrCode: 0, Resp: { folder_id: "636771263750078906" } }), { status: 200 }), + new Response('{"folders":[{"folder_id":636771263750078906,"name":"REVOLVE"}],"request_id":"req-list"}', { status: 200 }), + new Response(JSON.stringify({ folder_id: "636771263750078907", request_id: "req-create" }), { status: 201 }), ]; const client = new GrowthStudioClient({ ...credentials, @@ -164,59 +267,206 @@ test("client lists and creates folders through Growth Studio folder endpoints", }); const listResult = await client.listFolders(); - const createResult = await client.createFolder({ name: "REVOLVE" }); + const createResult = await client.createFolder({ name: "September campaign" }); assert.deepEqual(listResult.body.folders, [{ folder_id: "636771263750078906", name: "REVOLVE" }]); - assert.deepEqual(createResult.body, { folder_id: "636771263750078906" }); - assert.equal(calls[0].url, "https://growth-api.pixverse.ai/marketing_hub/folder/list"); - assert.equal(calls[1].url, "https://growth-api.pixverse.ai/marketing_hub/folder/create"); + assert.deepEqual(createResult.body, { folder_id: "636771263750078907", request_id: "req-create" }); + assert.equal(createResult.requestId, "req-create"); + assert.equal(calls[0].url, "https://growth-api.pixverse.ai/openapi/v1/folders"); + assert.equal(calls[0].init.method, "GET"); + assert.equal(calls[1].url, "https://growth-api.pixverse.ai/openapi/v1/folders"); + assert.equal(calls[1].init.method, "POST"); + assert.equal(calls[0].init.headers.get("Authorization"), "Bearer test-api-key"); assert.equal(calls[1].init.headers.get("Authorization"), "Bearer test-api-key"); - assert.equal(calls[1].init.body, JSON.stringify({ name: "REVOLVE" })); + assert.equal(calls[1].init.body, JSON.stringify({ name: "September campaign" })); }); -test("client can point folder commands at a configured Growth Studio API prefix", async () => { +test("folder errors use the standard Growth Studio error body", async () => { + const client = new GrowthStudioClient({ + ["api" + "Key"]: "test-api-key", + baseUrl: "https://growth-api.pixverse.ai", + fetchImpl: async () => new Response(JSON.stringify({ + error: { code: "FOLDER_LIMIT_EXCEEDED", message: "Folder limit reached.", retryable: false }, + request_id: "req-limit", + }), { status: 409 }), + }); + + await assert.rejects(client.createFolder({ name: "One more" }), (error) => { + assert.ok(error instanceof GrowthStudioApiError); + assert.equal(error.status, 409); + assert.equal(error.code, "FOLDER_LIMIT_EXCEEDED"); + assert.equal(error.requestId, "req-limit"); + return true; + }); +}); + +test("parseResponse keeps 64-bit integer identifiers lossless as strings", async () => { + const result = await parseResponse(new Response( + '{"video_id":627410861853514292,"price":1299,"data":[{"avatar_id":123456789012345678}]}', + { status: 200 }, + )); + + assert.equal(result.body.video_id, "627410861853514292"); + assert.equal(result.body.price, 1299); + assert.equal(result.body.data[0].avatar_id, "123456789012345678"); + assert.equal(Object.getPrototypeOf(result.body), Object.prototype); +}); + +test("parseResponse defaults retryable by status when the error body is missing", async () => { + const expectations = [[429, true], [503, true], [500, false], [400, false]]; + for (const [status, retryable] of expectations) { + await assert.rejects(parseResponse(new Response("", { + status, + headers: { "retry-after": "12", "x-request-id": `req-${status}` }, + })), (error) => { + assert.equal(error.status, status); + assert.equal(error.retryable, retryable); + assert.equal(error.retryAfter, 12); + assert.equal(error.requestId, `req-${status}`); + return true; + }); + } + await assert.rejects(parseResponse(new Response(JSON.stringify({ + error: { code: "INTERNAL_ERROR", message: "boom", retryable: true }, + }), { status: 500 })), (error) => error.retryable === true && error.code === "INTERNAL_ERROR"); +}); + +test("GET requests retry 429 and retryable 5xx honoring Retry-After seconds and HTTP dates", async () => { + const waits = []; const calls = []; + const nowMs = Date.parse("2026-10-10T00:00:00Z"); + const responses = [ + new Response("", { status: 429, headers: { "retry-after": "3" } }), + new Response("", { status: 503, headers: { "retry-after": new Date(nowMs + 9_000).toUTCString() } }), + new Response(JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "retry", retryable: true } }), { status: 500 }), + new Response(JSON.stringify({ data: [], pagination: { has_more: false, next_cursor: "" } }), { status: 200 }), + ]; + const realNow = Date.now; + Date.now = () => nowMs; + try { + const client = new GrowthStudioClient({ + ["api" + "Key"]: "test-api-key", + baseUrl: "https://growth-api.pixverse.ai", + fetchImpl: async (url, init) => { + calls.push({ url: url.toString(), method: init.method }); + return responses.shift(); + }, + sleep: async (ms) => waits.push(ms), + }); + + const result = await client.listVideos({ limit: 20, status: "succeeded" }); + + assert.deepEqual(result.body.data, []); + } finally { + Date.now = realNow; + } + assert.equal(calls.length, 4); + assert.equal(calls.every(({ method }) => method === "GET"), true); + assert.deepEqual(waits, [3000, 9000, 4000]); +}); + +test("GET retries use capped exponential backoff and stop after the attempt budget", async () => { + const waits = []; + let calls = 0; const client = new GrowthStudioClient({ ["api" + "Key"]: "test-api-key", - folderApiPrefix: "/openapi/v1/growth-studio", baseUrl: "https://growth-api.pixverse.ai", - fetchImpl: async (url, init) => { - calls.push({ url: url.toString(), init }); - return new Response(JSON.stringify({ folders: [] }), { status: 200 }); + readRetry: { maxAttempts: 5, baseDelayMs: 1_000, maxDelayMs: 3_000 }, + fetchImpl: async () => { + calls += 1; + return new Response("", { status: 503 }); }, + sleep: async (ms) => waits.push(ms), }); - await client.listFolders(); + await assert.rejects(client.listAvatars(), (error) => error.status === 503); + assert.equal(calls, 5); + assert.deepEqual(waits, [1000, 2000, 3000, 3000]); +}); - assert.equal(calls[0].url, "https://growth-api.pixverse.ai/openapi/v1/growth-studio/folder/list"); +test("GET requests do not retry non-retryable errors", async () => { + let calls = 0; + const client = new GrowthStudioClient({ + ["api" + "Key"]: "test-api-key", + baseUrl: "https://growth-api.pixverse.ai", + fetchImpl: async () => { + calls += 1; + return new Response(JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "no", retryable: false } }), { status: 500 }); + }, + sleep: async () => {}, + }); + + await assert.rejects(client.getVideo("627410861853514292"), /no/); + assert.equal(calls, 1); }); -test("client can use a separate folder API token for folder endpoints", async () => { +test("POST create, edit, upload, and folder create are never retried", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-upload-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const imagePath = path.join(root, "product.png"); + await fs.writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); const calls = []; const client = new GrowthStudioClient({ - ["api" + "Key"]: "video-api-key", - folderApiKey: "folder-api-key", + ["api" + "Key"]: "test-api-key", baseUrl: "https://growth-api.pixverse.ai", fetchImpl: async (url, init) => { - calls.push({ url: url.toString(), init }); - return new Response(JSON.stringify({ ErrCode: 0, Resp: { folders: [] } }), { status: 200 }); + calls.push(`${init.method} ${new URL(url).pathname}`); + return new Response("", { status: calls.length % 2 ? 429 : 503, headers: { "retry-after": "1" } }); + }, + sleep: async () => { + throw new Error("POST requests must not sleep for a retry"); }, }); - await client.listFolders(); - - assert.equal(calls[0].init.headers.get("Authorization"), "Bearer folder-api-key"); + await assert.rejects(client.createVideo({ + product: { source_url: "https://shop.example.com/item" }, + video: validVideo(), + }), (error) => error.status === 429); + await assert.rejects(client.editVideo("627410861853514292", { clip_index: 2, instruction: "Smile." }), (error) => error.status === 503); + await assert.rejects(client.uploadImage(imagePath), (error) => error.status === 429); + await assert.rejects(client.createFolder({ name: "ACME" }), (error) => error.status === 503); + + assert.deepEqual(calls, [ + "POST /openapi/v1/videos", + "POST /openapi/v1/videos/627410861853514292/edit", + "POST /openapi/v1/image/upload", + "POST /openapi/v1/folders", + ]); }); -test("client rejects Marketing Hub folder envelope errors", async () => { - const credentials = { ["api" + "Key"]: "test-api-key" }; +test("uploadImage validates extension and size locally and sets the blob type", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-upload-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const calls = []; const client = new GrowthStudioClient({ - ...credentials, + ["api" + "Key"]: "test-api-key", baseUrl: "https://growth-api.pixverse.ai", - fetchImpl: async () => new Response(JSON.stringify({ ErrCode: 10001, ErrMsg: "Token is invalid", Resp: {} }), { status: 200 }), + fetchImpl: async (url, init) => { + calls.push({ url: url.toString(), init }); + return new Response(JSON.stringify({ url: "https://media.pixverse.ai/uploads/x.webp", request_id: "req-up" }), { status: 200 }); + }, }); - await assert.rejects(client.listFolders(), /Token is invalid/); + const gifPath = path.join(root, "product.gif"); + await fs.writeFile(gifPath, "GIF89a"); + await assert.rejects(client.uploadImage(gifPath), /\.jpg, \.jpeg, \.png, or \.webp/); + + const largePath = path.join(root, "large.jpg"); + const handle = await fs.open(largePath, "w"); + await handle.truncate(20 * 1024 * 1024 + 1); + await handle.close(); + await assert.rejects(client.uploadImage(largePath), /20 MiB/); + assert.equal(calls.length, 0); + + for (const [name, type] of [["a.JPG", "image/jpeg"], ["b.jpeg", "image/jpeg"], ["c.png", "image/png"], ["d.webp", "image/webp"]]) { + const filePath = path.join(root, name); + await fs.writeFile(filePath, "fixture"); + await client.uploadImage(filePath); + const image = calls.at(-1).init.body.get("image"); + assert.equal(image.type, type); + assert.equal(image.name, name); + } + assert.equal(new URL(calls[0].url).pathname, "/openapi/v1/image/upload"); }); test("validateFolderPayload requires a folder name", () => { @@ -224,25 +474,33 @@ test("validateFolderPayload requires a folder name", () => { assert.throws(() => validateFolderPayload({ name: "" }), /non-empty name/); }); -test("pollVideo honors Retry-After and stops on succeeded", async () => { - const waits = []; - const snapshots = []; - const credentials = { ["api" + "Key"]: "test-api-key" }; - const responses = [ - new Response(JSON.stringify({ video_id: "627410861853514292", status: "processing" }), { - status: 200, - headers: { "retry-after": "7" }, - }), - new Response(JSON.stringify({ video_id: "627410861853514292", status: "succeeded", output: { video_url: "https://media.pixverse.ai/videos/result.mp4" } }), { - status: 200, - }), - ]; +function pollingClient(responses, { waits, now } = {}) { + const calls = []; const client = new GrowthStudioClient({ - ...credentials, + ["api" + "Key"]: "test-api-key", baseUrl: "https://growth-api.pixverse.ai", - fetchImpl: async () => responses.shift(), - sleep: async (ms) => waits.push(ms), + fetchImpl: async (url, init) => { + calls.push(`${init.method} ${new URL(url).pathname}`); + const next = responses.shift(); + return typeof next === "function" ? next() : next; + }, + sleep: async (ms) => waits?.push(ms), + ...(now ? { now } : {}), }); + return { client, calls }; +} + +function videoResponse(body, headers = {}) { + return new Response(JSON.stringify({ video_id: "627410861853514292", ...body }), { status: 200, headers }); +} + +test("pollVideo honors Retry-After, enforces the five-second first wait, and stops on succeeded", async () => { + const waits = []; + const snapshots = []; + const { client } = pollingClient([ + videoResponse({ status: "processing" }, { "retry-after": "7" }), + videoResponse({ status: "succeeded", output: { video_url: "https://media.pixverse.ai/videos/result.mp4" } }), + ], { waits }); const result = await client.pollVideo("627410861853514292", { initialDelaySeconds: 0, @@ -250,6 +508,69 @@ test("pollVideo honors Retry-After and stops on succeeded", async () => { }); assert.equal(result.status, "succeeded"); - assert.deepEqual(waits, [7000]); + assert.deepEqual(waits, [5000, 7000]); assert.deepEqual(snapshots, ["processing", "succeeded"]); }); + +test("pollVideo clamps the fallback delay to at least one second", async () => { + const waits = []; + const { client } = pollingClient([ + videoResponse({ status: "queued" }), + videoResponse({ status: "succeeded" }), + ], { waits }); + + await client.pollVideo("627410861853514292", { initialDelaySeconds: 2, fallbackDelaySeconds: 0 }); + + assert.deepEqual(waits, [5000, 1000]); +}); + +test("pollVideo keeps polling through 429 and retryable 5xx responses within its deadline", async () => { + const waits = []; + const { client, calls } = pollingClient([ + new Response("", { status: 429, headers: { "retry-after": "30" } }), + new Response("", { status: 503, headers: { "retry-after": "2" } }), + new Response("", { status: 429, headers: { "retry-after": "4" } }), + new Response("", { status: 503 }), + new Response("", { status: 503, headers: { "retry-after": "6" } }), + videoResponse({ status: "succeeded" }), + ], { waits }); + client.readRetry = { ...client.readRetry, maxAttempts: 2 }; + + const result = await client.pollVideo("627410861853514292", { fallbackDelaySeconds: 3 }); + + assert.equal(result.status, "succeeded"); + assert.equal(calls.length, 6); + // First wait, in-request retry (30s), poll-level Retry-After after exhausted retries (2s), + // in-request retry (4s), poll-level fallback with no Retry-After (3s), in-request retry (6s). + assert.deepEqual(waits, [5000, 30000, 2000, 4000, 3000, 6000]); +}); + +test("pollVideo stops on retryable errors once the deadline passes", async () => { + let nowMs = 0; + const { client, calls } = pollingClient([ + () => { + nowMs = 11_000; + return new Response("", { status: 503 }); + }, + ], { now: () => nowMs }); + client.readRetry = { ...client.readRetry, maxAttempts: 1 }; + + await assert.rejects( + client.pollVideo("627410861853514292", { timeoutMs: 10_000 }), + (error) => error.status === 503, + ); + assert.equal(calls.length, 1); +}); + +test("pollVideo prefers edit.status over edit.latest.status", async () => { + const waits = []; + const { client, calls } = pollingClient([ + videoResponse({ status: "succeeded", edit: { status: "processing", latest: { status: "succeeded" } } }, { "retry-after": "5" }), + videoResponse({ status: "succeeded", edit: { status: "succeeded", latest: { status: "processing" } } }), + ], { waits }); + + const result = await client.pollVideo("627410861853514292"); + + assert.equal(result.edit.status, "succeeded"); + assert.equal(calls.length, 2); +}); diff --git a/test/contract/growth-studio-client.contract.test.js b/test/contract/growth-studio-client.contract.test.js index 0bb5661..acd0e3e 100644 --- a/test/contract/growth-studio-client.contract.test.js +++ b/test/contract/growth-studio-client.contract.test.js @@ -9,42 +9,60 @@ import { } from "../../src/growth-studio/client.js"; import { createRecordingFetch, describeRecordedBody, jsonResponse } from "../helpers/recording-fetch.js"; +const VIDEO = Object.freeze({ + aspect_ratio: "9:16", + duration_seconds: 30, + resolution: "1080p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: Object.freeze({ mode: "auto" }), +}); + +// describeRecordedBody parses losslessly into null-prototype records; compare as plain JSON. +async function recordedJson(body) { + return JSON.parse(JSON.stringify(await describeRecordedBody(body), (_key, value) => ( + typeof value === "bigint" ? value.toString() : value + ))); +} + test("Growth Studio client uses bearer auth only and never sends Platform headers", async () => { const { calls, fetchImpl } = createRecordingFetch([ jsonResponse({ video_id: "627410861853514292", status: "processing" }), - jsonResponse({ ErrCode: 0, Resp: { folders: [] } }), - jsonResponse({ ErrCode: 0, Resp: { folder_id: "630251570268735431" } }), + jsonResponse({ folders: [], request_id: "folder-list-request" }), + jsonResponse({ folder_id: "630251570268735431", request_id: "folder-create-request" }, { status: 201 }), ]); const client = new GrowthStudioClient({ ["api" + "Key"]: "growth-fixture-key", - ["folderApi" + "Key"]: "folder-fixture-key", baseUrl: "https://growth.example.test", fetchImpl, }); await client.createVideo({ product: { source_url: "https://shop.example.test/item" }, - video: { avatar: { mode: "auto" } }, + video: { ...VIDEO, folder_id: "630251570268735431" }, }, { traceId: "create-trace" }); await client.listFolders({ traceId: "folder-trace" }); await client.createFolder({ name: "ACME" }, { traceId: "create-folder-trace" }); assert.equal(calls[0].headers.get("Authorization"), "Bearer growth-fixture-key"); - assert.equal(calls[1].headers.get("Authorization"), "Bearer folder-fixture-key"); - assert.equal(calls[2].headers.get("Authorization"), "Bearer folder-fixture-key"); + assert.equal(calls[1].headers.get("Authorization"), "Bearer growth-fixture-key"); + assert.equal(calls[2].headers.get("Authorization"), "Bearer growth-fixture-key"); assert.equal(calls.every(({ headers }) => !headers.has("API-KEY")), true); assert.equal(calls.every(({ headers }) => headers.has("Ai-Trace-Id")), true); assert.deepEqual(calls.map(({ method }) => method), ["POST", "GET", "POST"]); assert.deepEqual(calls.map(({ url }) => new URL(url).pathname), [ "/openapi/v1/videos", - "/marketing_hub/folder/list", - "/marketing_hub/folder/create", + "/openapi/v1/folders", + "/openapi/v1/folders", ]); - assert.deepEqual(await describeRecordedBody(calls[0].body), { + assert.deepEqual(await recordedJson(calls[0].body), { product: { source_url: "https://shop.example.test/item" }, - video: { avatar: { mode: "auto" } }, + video: { ...VIDEO, avatar: { mode: "auto" }, folder_id: "630251570268735431" }, }); - assert.deepEqual(await describeRecordedBody(calls[2].body), { name: "ACME" }); + assert.equal(calls[1].body, undefined); + assert.deepEqual(await recordedJson(calls[2].body), { name: "ACME" }); }); test("Growth Studio client preserves string identifiers through create and poll", async () => { @@ -61,7 +79,7 @@ test("Growth Studio client preserves string identifiers through create and poll" const created = await client.createVideo({ product: { source_url: "https://shop.example.test/item" }, - video: { avatar: { mode: "auto" } }, + video: VIDEO, }); const final = await client.pollVideo(created.body.video_id, { initialDelaySeconds: 0 }); @@ -106,7 +124,7 @@ test("Growth Studio PDP uses bearer auth and the exact PDP wire contract", async assert.equal(calls[0].headers.get("Authorization"), "Bearer growth-fixture-key"); assert.equal(calls[0].headers.get("Ai-Trace-Id"), "pdp-create-fixture"); assert.equal(calls[0].headers.has("API-KEY"), false); - assert.deepEqual(await describeRecordedBody(calls[0].body), { + assert.deepEqual(await recordedJson(calls[0].body), { product: { title: "Linen Summer Shirt", images: [{ url: "https://media.pixverse.ai/example/front.webp" }], @@ -305,3 +323,59 @@ test("Growth Studio successful responses reject malformed JSON as a protocol err }, ); }); + +test("Growth Studio create sends folder_id inside video and the edit body unchanged", async () => { + const { calls, fetchImpl } = createRecordingFetch([ + jsonResponse({ video_id: "627410861853514292", status: "processing" }, { status: 202 }), + jsonResponse({ video_id: "627410861853514292", edit: { status: "processing" } }, { status: 202 }), + ]); + const client = new GrowthStudioClient({ + ["api" + "Key"]: "growth-fixture-key", + baseUrl: "https://growth.example.test", + fetchImpl, + }); + + await client.createVideo({ + product: { source_url: "https://shop.example.test/item" }, + video: { ...VIDEO, folder_id: "630251570268735431" }, + }); + await client.editVideo("627410861853514292", { clip_index: 2, instruction: "Make the expression more natural." }); + + const created = await recordedJson(calls[0].body); + assert.equal(created.folder_id, undefined); + assert.equal(created.video.folder_id, "630251570268735431"); + assert.deepEqual(calls.map(({ method, url }) => `${method} ${new URL(url).pathname}`), [ + "POST /openapi/v1/videos", + "POST /openapi/v1/videos/627410861853514292/edit", + ]); + assert.deepEqual(await recordedJson(calls[1].body), { + clip_index: 2, + instruction: "Make the expression more natural.", + }); +}); + +test("Growth Studio read requests retry 429 and 503 while writes stay single-attempt", async () => { + const waits = []; + const { calls, fetchImpl } = createRecordingFetch([ + jsonResponse({ error: { code: "RATE_LIMIT_EXCEEDED", message: "slow down", retryable: true } }, { + status: 429, + headers: { "retry-after": "30" }, + }), + jsonResponse({ error: { code: "SERVICE_UNAVAILABLE", message: "later", retryable: true } }, { status: 503 }), + jsonResponse({ folders: [{ folder_id: "630251570268735431", name: "ACME" }] }), + jsonResponse({ error: { code: "SERVICE_UNAVAILABLE", message: "later", retryable: true } }, { status: 503 }), + ]); + const client = new GrowthStudioClient({ + ["api" + "Key"]: "growth-fixture-key", + baseUrl: "https://growth.example.test", + fetchImpl, + sleep: async (ms) => waits.push(ms), + }); + + const folders = await client.listFolders(); + await assert.rejects(client.createFolder({ name: "ACME" }), (error) => error.status === 503); + + assert.equal(folders.body.folders[0].folder_id, "630251570268735431"); + assert.deepEqual(calls.map(({ method }) => method), ["GET", "GET", "GET", "POST"]); + assert.deepEqual(waits, [30_000, 2_000]); +}); diff --git a/test/contract/platform-client.contract.test.js b/test/contract/platform-client.contract.test.js index fe7bbbf..c03fc13 100644 --- a/test/contract/platform-client.contract.test.js +++ b/test/contract/platform-client.contract.test.js @@ -3,13 +3,14 @@ import fs from "node:fs/promises"; import test from "node:test"; import { fileURLToPath } from "node:url"; + import { PlatformClient, normalizePlatformResponseIdentifiers, } from "../../src/platform/client.js"; import { PLATFORM_OPERATIONS } from "../../src/platform/operations.js"; import { buildPlatformRequest } from "../../src/platform/request.js"; -import { createRecordingFetch, describeRecordedBody, jsonResponse } from "../helpers/recording-fetch.js"; +import { createRecordingFetch, describeRecordedBody, jsonResponse, parseLosslessJson } from "../helpers/recording-fetch.js"; const FIXTURES = new URL("../fixtures/platform/", import.meta.url); const TEST_CREDENTIAL = ["fixture", "credential"].join("-"); @@ -185,7 +186,7 @@ test("GET and HEAD read requests retry transient failures, while every non-read- assert.equal(writeRecorder.calls.length, 1); }); -test("request building preserves repeated query values and serializes local multipart files without a content type", async () => { +test("request building preserves repeated query values and types local multipart files by extension", async () => { const queryRequest = await buildPlatformRequest({ id: "fixture.query", method: "GET", path: "/items", bodyMode: "none", }, { @@ -208,8 +209,19 @@ test("request building preserves repeated query values and serializes local mult assert.equal(multipartRequest.headers.has("Content-Type"), false); assert.deepEqual(await describeRecordedBody(multipartRequest.body), { metadata: '{"role":"cover"}', - image: { name: "tiny.png", size: 90, type: "" }, + image: { name: "tiny.png", size: 90, type: "image/png" }, }); + + // The template list documents template_ids as a single comma-separated string. + const templatesRequest = await buildPlatformRequest({ + id: "resource.templates", method: "GET", path: "/templates", bodyMode: "none", + }, { + payload: { template_ids: ["402331965264704", "402331965264705"] }, + query: {}, + pathParams: {}, + files: {}, + }); + assert.equal(templatesRequest.path, "/templates?template_ids=402331965264704%2C402331965264705"); }); test("Music MV uint64 identifiers stay strings internally but serialize as lossless JSON numbers", async () => { @@ -263,15 +275,37 @@ test("Music MV uint64 identifiers stay strings internally but serialize as lossl assert.equal(fusionRequest.body, '{"model":"v6","image_references":[{"img_id":892421142,"ref_name":"front"},{"img_id":892421144,"ref_name":"side"}],"prompt":"@front @side walk.","duration":15,"quality":"1080p","aspect_ratio":"9:16","generate_audio_switch":true}'); assert.equal(fusionInput.payload.image_references[0].img_id, "892421142"); - const unrelatedRequest = await buildPlatformRequest({ - id: "video.avatar", method: "POST", path: "/avatar", bodyMode: "json", + // Live API checks: fusion video_references IDs must be integers even though + // the schema says string (quoted values are rejected with 400013). + const fusionVideoRequest = await buildPlatformRequest({ + id: "video.fusion", method: "POST", path: "/fusion", bodyMode: "json", }, { - payload: { audio_media_id: "18446744073709551615" }, + payload: { video_references: [{ source_video_id: "429187502447488", ref_name: "video1" }] }, query: {}, pathParams: {}, files: {}, }); - assert.equal(unrelatedRequest.body, '{"audio_media_id":"18446744073709551615"}'); + assert.equal(fusionVideoRequest.body, '{"video_references":[{"source_video_id":429187502447488,"ref_name":"video1"}]}'); + + // Fields the reference types as string stay strings even next to integer IDs. + const modifyRequest = await buildPlatformRequest({ + id: "video.modify", method: "POST", path: "/modify", bodyMode: "json", + }, { + payload: { video_media_id: "18446744073709551615", mask_ids: ["7"], img_ids: ["8"], keyframe_id: "1" }, + query: {}, + pathParams: {}, + files: {}, + }); + assert.equal(modifyRequest.body, '{"video_media_id":18446744073709551615,"mask_ids":["7"],"img_ids":[8],"keyframe_id":1}'); + + await assert.rejects(buildPlatformRequest({ + id: "video.image", method: "POST", path: "/img", bodyMode: "json", + }, { + payload: { img_id: "not-a-number" }, + query: {}, + pathParams: {}, + files: {}, + }), { code: "INVALID_PLATFORM_REQUEST", message: /img_id must be a non-negative integer ID/ }); }); test("upscale media IDs stay strings internally but serialize as lossless JSON numbers", async () => { @@ -316,7 +350,7 @@ test("client and request boundaries reject malformed construction inputs", async const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; async function fixture(operationId, name) { - return JSON.parse(await fs.readFile(new URL(`${operationId}/${name}.json`, FIXTURES), "utf8")); + return parseLosslessJson(await fs.readFile(new URL(`${operationId}/${name}.json`, FIXTURES), "utf8")); } function deepFreeze(value) { diff --git a/test/e2e/growth-studio-namespaced.e2e.test.js b/test/e2e/growth-studio-namespaced.e2e.test.js index 396e851..c5f4a85 100644 --- a/test/e2e/growth-studio-namespaced.e2e.test.js +++ b/test/e2e/growth-studio-namespaced.e2e.test.js @@ -13,23 +13,33 @@ test("executable Growth Studio namespace resolves folders and completes create t const jobsDir = path.join(tempRoot, "jobs"); await fs.writeFile(payloadPath, JSON.stringify({ product: { source_url: "https://product.example.invalid/item" }, - video: { avatar: { mode: "auto" } }, + video: { + aspect_ratio: "9:16", + duration_seconds: 30, + resolution: "1080p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: { mode: "auto" }, + }, })); const server = await createMockApiServer((request, response) => { assert.equal(request.headers.authorization, "Bearer mh_live_fixture"); assert.equal(request.headers["api-key"], undefined); - if (request.method === "GET" && request.url === "/marketing_hub/folder/list") { - return sendJson(response, { ErrCode: 0, Resp: { folders: [] } }); + if (request.method === "GET" && request.url === "/openapi/v1/folders") { + return sendJson(response, { folders: [{ folder_id: "630251570268735430", name: "acme" }], request_id: "req-list" }); } - if (request.method === "POST" && request.url === "/marketing_hub/folder/create") { + if (request.method === "POST" && request.url === "/openapi/v1/folders") { assert.deepEqual(JSON.parse(request.body.toString()), { name: "ACME" }); - return sendJson(response, { ErrCode: 0, Resp: { folder_id: "630251570268735431" } }); + return sendJson(response, { folder_id: "630251570268735431", request_id: "req-create" }, 201); } if (request.method === "POST" && request.url === "/openapi/v1/videos") { const payload = JSON.parse(request.body.toString()); - assert.equal(payload.folder_id, "630251570268735431"); + assert.equal(payload.folder_id, undefined); + assert.equal(payload.video.folder_id, "630251570268735431"); return sendJson(response, { video_id: "627410861853514292", status: "processing" }); } if (request.method === "GET" && request.url === "/openapi/v1/videos/627410861853514292") { @@ -74,8 +84,8 @@ test("executable Growth Studio namespace resolves folders and completes create t assert.equal(output.status, "succeeded"); assert.equal(output.folder_id, "630251570268735431"); assert.deepEqual(server.requests.map(({ method, url }) => `${method} ${url}`), [ - "GET /marketing_hub/folder/list", - "POST /marketing_hub/folder/create", + "GET /openapi/v1/folders", + "POST /openapi/v1/folders", "POST /openapi/v1/videos", "GET /openapi/v1/videos/627410861853514292", ]); diff --git a/test/e2e/platform-recovery.e2e.test.js b/test/e2e/platform-recovery.e2e.test.js index 23fe035..199e55d 100644 --- a/test/e2e/platform-recovery.e2e.test.js +++ b/test/e2e/platform-recovery.e2e.test.js @@ -5,7 +5,7 @@ import test from "node:test"; import { PlatformClient } from "../../src/platform/client.js"; import { resumePlatformJob, submitPlatformJob } from "../../src/platform/jobs.js"; -import { createMockApiServer } from "../helpers/mock-api-server.js"; +import { createMockApiServer, sendJson } from "../helpers/mock-api-server.js"; import { createTempJobRoot, readJobArtifacts } from "../helpers/temp-job-dir.js"; test("ambiguous agent acceptance is resumable without a second billable POST", async (t) => { @@ -23,8 +23,8 @@ test("ambiguous agent acceptance is resumable without a second billable POST", a agent_id: "414562414124109", prompt: "Recreate pacing.", quality: "720p", - img_references: [{ img_url: "https://cdn.example.test/a.png" }], - video_references: [{ video_url: "https://cdn.example.test/a.mp4" }], + img_references: [{ img_id: "1" }], + video_references: [{ video_media_id: "2" }], }, { jobRoot: root, traceIdFactory: () => "11111111-1111-4111-8111-111111111111" }), (error) => error.category === "transport"); const [jobName] = await fs.readdir(root); @@ -92,3 +92,50 @@ test("unknown status is preserved until timeout with final and error artifacts", assert.equal(recoveredArtifacts["final-timeout-1.json"].status, "unknown"); assert.equal(recoveredArtifacts["final.json"].status, "succeeded"); }); + +test("over real HTTP, transient status failures keep polling with fresh traces and one billable POST", async (t) => { + const root = await createTempJobRoot(t); + const savedTrace = "11111111-1111-4111-8111-111111111111"; + let statusReads = 0; + const server = await createMockApiServer((request, response) => { + if (request.method === "POST") { + sendJson(response, { ErrCode: 0, ErrMsg: "success", Resp: { video_id: 627410861853514292 } }); + return; + } + statusReads += 1; + // Each status call exhausts the client's 3 in-call GET retries first. + if (statusReads <= 3) { + sendJson(response, { error: { message: "busy" } }, 503); + return; + } + if (statusReads === 4) { + sendJson(response, { ErrCode: 500069, ErrMsg: "System is currently experiencing high load.", Resp: null }); + return; + } + sendJson(response, { ErrCode: 0, ErrMsg: "success", Resp: { id: 627410861853514292, status: 1, url: "https://cdn.example.test/v.mp4" } }); + }); + t.after(() => server.close()); + const client = new PlatformClient({ + apiKey: "test-key", baseUrl: server.baseUrl, fetchImpl: globalThis.fetch, sleep: async () => {}, + }); + const final = await submitPlatformJob(client, "video.text", { + prompt: "x", model: "v6", duration: 5, quality: "720p", aspect_ratio: "16:9", + }, { jobRoot: root, traceIdFactory: () => savedTrace, poll: true, sleep: async () => {} }); + + assert.equal(final.status, "succeeded"); + const posts = server.requests.filter(({ method }) => method === "POST"); + const reads = server.requests.filter(({ method }) => method === "GET"); + assert.equal(posts.length, 1); + assert.equal(posts[0].headers["ai-trace-id"], savedTrace); + assert.equal(reads.length, 5); + const readTraces = reads.map(({ headers }) => headers["ai-trace-id"]); + assert.equal(new Set(readTraces).size, readTraces.length); + assert.equal(readTraces.includes(savedTrace), false); + assert.equal(final.trace_id, savedTrace); + assert.equal(final.status_trace_id, readTraces.at(-1)); + const artifacts = await readJobArtifacts(final.job_dir); + assert.deepEqual( + artifacts["polling.jsonl"].filter(({ status }) => status === "poll_error").map(({ error }) => error.code ?? error.status), + [503, 500069], + ); +}); diff --git a/test/e2e/platform-webhook.e2e.test.js b/test/e2e/platform-webhook.e2e.test.js index d88dca4..5bf5014 100644 --- a/test/e2e/platform-webhook.e2e.test.js +++ b/test/e2e/platform-webhook.e2e.test.js @@ -75,13 +75,16 @@ test("rejects invalid signatures and malformed payloads before delivery", async assert.equal(mutations, 0); }); -test("rejects replay and non-POST or oversized requests without delivery", async (t) => { +test("acknowledges a verified replay with ok without redelivery; rejects non-POST or oversized requests", async (t) => { let deliveries = 0; const rawBody = JSON.stringify({ event: "ready" }); const url = await startHandler(t, { onDelivery: async () => { deliveries += 1; } }); const options = { method: "POST", headers: headersFor(rawBody), body: rawBody }; assert.equal((await fetch(url, options)).status, 200); - assert.equal((await fetch(url, options)).status, 409); + const replay = await fetch(url, options); + // PixVerse resends until it receives exact `ok`, so a duplicate is acknowledged. + assert.equal(replay.status, 200); + assert.equal(await replay.text(), "ok"); assert.equal((await fetch(url)).status, 405); const oversized = "x".repeat(1_048_577); @@ -92,6 +95,25 @@ test("rejects replay and non-POST or oversized requests without delivery", async assert.equal(deliveries, 1); }); +test("a redelivery of the same id and status with a fresh nonce is acknowledged once", async (t) => { + const events = []; + const url = await startHandler(t, { onDelivery: async (payload) => events.push(payload) }); + const success = JSON.stringify({ id: "123456789", status: 1, url: "https://example.test/v.mp4", has_audio: true }); + for (const nonce of ["first-delivery", "redelivery-1", "redelivery-2"]) { + const response = await fetch(url, { method: "POST", headers: headersFor(success, { nonce }), body: success }); + assert.equal(response.status, 200); + assert.equal(await response.text(), "ok"); + } + assert.equal(events.length, 1); + + const later = JSON.stringify({ id: "123456789", status: 8, url: null }); + const response = await fetch(url, { method: "POST", headers: headersFor(later, { nonce: "status-8" }), body: later }); + assert.equal(response.status, 200); + assert.equal(await response.text(), "ok"); + assert.equal(events.length, 2); + assert.equal(events[1].url, null); +}); + test("blocks a concurrent duplicate while the first delivery is in progress", async (t) => { let releaseDelivery; let deliveries = 0; diff --git a/test/fixtures/platform/agent.real-estate/request.json b/test/fixtures/platform/agent.real-estate/request.json index 4d30481..39e0e3a 100644 --- a/test/fixtures/platform/agent.real-estate/request.json +++ b/test/fixtures/platform/agent.real-estate/request.json @@ -7,7 +7,7 @@ "method": "POST", "path": "/openapi/v2/video/agent/generate", "body": { - "agent_id": "419629433597950", + "agent_id": 419629433597950, "model": "pro", "product_url": "https://listing.example.test/home" } diff --git a/test/fixtures/platform/agent.viral-recreation/request.json b/test/fixtures/platform/agent.viral-recreation/request.json index afcb2c6..7d78be8 100644 --- a/test/fixtures/platform/agent.viral-recreation/request.json +++ b/test/fixtures/platform/agent.viral-recreation/request.json @@ -5,29 +5,29 @@ "quality": "720p", "img_references": [ { - "img_url": "https://cdn.example.test/a.png" + "img_id": "883671823" } ], "video_references": [ { - "video_url": "https://cdn.example.test/a.mp4" + "video_media_id": "416097137329803" } ] }, "method": "POST", "path": "/openapi/v2/video/agent/generate", "body": { - "agent_id": "414562414124109", + "agent_id": 414562414124109, "prompt": "Recreate pacing.", "quality": "720p", "img_references": [ { - "img_url": "https://cdn.example.test/a.png" + "img_id": 883671823 } ], "video_references": [ { - "video_url": "https://cdn.example.test/a.mp4" + "video_media_id": 416097137329803 } ] } diff --git a/test/fixtures/platform/image.template/request.json b/test/fixtures/platform/image.template/request.json index 81ffe6e..adca85c 100644 --- a/test/fixtures/platform/image.template/request.json +++ b/test/fixtures/platform/image.template/request.json @@ -9,8 +9,8 @@ "path": "/openapi/v2/image/template/generate", "body": { "img_ids": [ - "629000000000000001" + 629000000000000001 ], - "template_id": "42" + "template_id": 42 } } diff --git a/test/fixtures/platform/official-operation-inventory.json b/test/fixtures/platform/official-operation-inventory.json index 84b66c1..c4c83b0 100644 --- a/test/fixtures/platform/official-operation-inventory.json +++ b/test/fixtures/platform/official-operation-inventory.json @@ -29,6 +29,6 @@ { "id": "video.avatar", "command": ["video", "avatar"], "method": "POST", "path": "/openapi/v2/video/avatar/generate", "bodyMode": "json", "validationPolicy": "video.avatar", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/avatar-generation-40528034e0" }, { "id": "agent.viral-recreation", "command": ["agent", "viral-recreation"], "method": "POST", "path": "/openapi/v2/video/agent/generate", "bodyMode": "json", "validationPolicy": "agent.viral-recreation", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/viral-recreation-agent-41205382e0" }, { "id": "agent.real-estate", "command": ["agent", "real-estate"], "method": "POST", "path": "/openapi/v2/video/agent/generate", "bodyMode": "json", "validationPolicy": "agent.real-estate", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/one-click-real-estate-video-42843647e0" }, - { "id": "agent.music-mv", "command": ["agent", "music-mv"], "method": "POST", "path": "/openapi/v2/video/music_mv_agent/generate", "bodyMode": "json", "validationPolicy": "agent.music-mv", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc" }, + { "id": "agent.music-mv", "command": ["agent", "music-mv"], "method": "POST", "path": "/openapi/v2/video/music_mv_agent/generate", "bodyMode": "json", "validationPolicy": "agent.music-mv", "billing": "billable", "asynchronous": true, "resultIdPath": "Resp.video_id", "documentationUrl": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0" }, { "id": "video.status", "command": ["video", "status"], "method": "GET", "path": "/openapi/v2/video/result/{video_id}", "bodyMode": "none", "validationPolicy": "video.status", "billing": "read-only", "asynchronous": false, "resultIdPath": "Resp.id", "documentationUrl": "https://docs.platform.pixverse.ai/get-video-generation-status-13016632e0" } ] diff --git a/test/fixtures/platform/video.avatar/request.json b/test/fixtures/platform/video.avatar/request.json index abf31f8..507f4ca 100644 --- a/test/fixtures/platform/video.avatar/request.json +++ b/test/fixtures/platform/video.avatar/request.json @@ -7,8 +7,8 @@ "method": "POST", "path": "/openapi/v2/video/avatar/generate", "body": { - "img_id": "1", + "img_id": 1, "quality": "720p", - "audio_media_id": "2" + "audio_media_id": 2 } } diff --git a/test/fixtures/platform/video.extend/request.json b/test/fixtures/platform/video.extend/request.json index bd660e7..b5b180c 100644 --- a/test/fixtures/platform/video.extend/request.json +++ b/test/fixtures/platform/video.extend/request.json @@ -10,7 +10,7 @@ "method": "POST", "path": "/openapi/v2/video/extend/generate", "body": { - "source_video_id": "1", + "source_video_id": 1, "prompt": "Continue.", "seed": 1, "quality": "720p", diff --git a/test/fixtures/platform/video.image/request.json b/test/fixtures/platform/video.image/request.json index ac9effd..27de309 100644 --- a/test/fixtures/platform/video.image/request.json +++ b/test/fixtures/platform/video.image/request.json @@ -10,7 +10,7 @@ "path": "/openapi/v2/video/img/generate", "body": { "duration": 5, - "img_id": "629000000000000001", + "img_id": 629000000000000001, "model": "v6", "prompt": "Drift forward.", "quality": "720p" diff --git a/test/fixtures/platform/video.lip-sync/request.json b/test/fixtures/platform/video.lip-sync/request.json index cfb3260..c353ea4 100644 --- a/test/fixtures/platform/video.lip-sync/request.json +++ b/test/fixtures/platform/video.lip-sync/request.json @@ -6,7 +6,7 @@ "method": "POST", "path": "/openapi/v2/video/lip_sync/generate", "body": { - "source_video_id": "1", - "audio_media_id": "2" + "source_video_id": 1, + "audio_media_id": 2 } } diff --git a/test/fixtures/platform/video.modify/request.json b/test/fixtures/platform/video.modify/request.json index 1d24d2d..a125184 100644 --- a/test/fixtures/platform/video.modify/request.json +++ b/test/fixtures/platform/video.modify/request.json @@ -7,7 +7,7 @@ "method": "POST", "path": "/openapi/v2/video/modify/generate", "body": { - "source_video_id": "1", + "source_video_id": 1, "prompt": "Change sky.", "quality": "720p" } diff --git a/test/fixtures/platform/video.motion-control/request.json b/test/fixtures/platform/video.motion-control/request.json index 7e706ac..5a964b3 100644 --- a/test/fixtures/platform/video.motion-control/request.json +++ b/test/fixtures/platform/video.motion-control/request.json @@ -7,8 +7,8 @@ "method": "POST", "path": "/openapi/v2/video/mimic/generate", "body": { - "source_video_id": "1", - "img_id": "2", + "source_video_id": 1, + "img_id": 2, "quality": "720p" } } diff --git a/test/fixtures/platform/video.multi-transition/request.json b/test/fixtures/platform/video.multi-transition/request.json index c4a56ec..f95b930 100644 --- a/test/fixtures/platform/video.multi-transition/request.json +++ b/test/fixtures/platform/video.multi-transition/request.json @@ -19,11 +19,11 @@ "quality": "720p", "multi_transition": [ { - "img_id": "1", + "img_id": 1, "duration": 3 }, { - "img_id": "2" + "img_id": 2 } ] } diff --git a/test/fixtures/platform/video.restyle/request.json b/test/fixtures/platform/video.restyle/request.json index c35e179..682cf19 100644 --- a/test/fixtures/platform/video.restyle/request.json +++ b/test/fixtures/platform/video.restyle/request.json @@ -6,7 +6,7 @@ "method": "POST", "path": "/openapi/v2/video/restyle/generate", "body": { - "source_video_id": "1", - "restyle_id": "2" + "source_video_id": 1, + "restyle_id": 2 } } diff --git a/test/fixtures/platform/video.sound-effect/request.json b/test/fixtures/platform/video.sound-effect/request.json index 051adf1..d392465 100644 --- a/test/fixtures/platform/video.sound-effect/request.json +++ b/test/fixtures/platform/video.sound-effect/request.json @@ -1,12 +1,12 @@ { "input": { "source_video_id": "1", - "prompt": "Rain." + "sound_effect_content": "Rain." }, "method": "POST", "path": "/openapi/v2/video/sound_effect/generate", "body": { - "source_video_id": "1", - "prompt": "Rain." + "source_video_id": 1, + "sound_effect_content": "Rain." } } diff --git a/test/fixtures/platform/video.swap-mask/request.json b/test/fixtures/platform/video.swap-mask/request.json index 8d66568..810b396 100644 --- a/test/fixtures/platform/video.swap-mask/request.json +++ b/test/fixtures/platform/video.swap-mask/request.json @@ -6,7 +6,7 @@ "method": "POST", "path": "/openapi/v2/video/mask/selection", "body": { - "source_video_id": "1", - "keyframe_id": "1" + "source_video_id": 1, + "keyframe_id": 1 } } diff --git a/test/fixtures/platform/video.swap/request.json b/test/fixtures/platform/video.swap/request.json index adb1ee2..690ba91 100644 --- a/test/fixtures/platform/video.swap/request.json +++ b/test/fixtures/platform/video.swap/request.json @@ -9,10 +9,10 @@ "method": "POST", "path": "/openapi/v2/video/swap/generate", "body": { - "source_video_id": "1", - "keyframe_id": "1", + "source_video_id": 1, + "keyframe_id": 1, "mask_id": "person-1", - "img_id": "2", + "img_id": 2, "quality": "720p" } } diff --git a/test/fixtures/platform/video.template/request.json b/test/fixtures/platform/video.template/request.json index 7a1dc10..c3466ea 100644 --- a/test/fixtures/platform/video.template/request.json +++ b/test/fixtures/platform/video.template/request.json @@ -11,10 +11,10 @@ "path": "/openapi/v2/video/img/generate", "body": { "duration": 5, - "img_id": "629000000000000001", + "img_id": 629000000000000001, "model": "v6", "prompt": "Animate.", "quality": "720p", - "template_id": "42" + "template_id": 42 } } diff --git a/test/fixtures/platform/video.transition/request.json b/test/fixtures/platform/video.transition/request.json index 6fc80a6..f25254e 100644 --- a/test/fixtures/platform/video.transition/request.json +++ b/test/fixtures/platform/video.transition/request.json @@ -10,8 +10,8 @@ "method": "POST", "path": "/openapi/v2/video/transition/generate", "body": { - "first_frame_img": "1", - "last_frame_img": "2", + "first_frame_img": 1, + "last_frame_img": 2, "prompt": "Transition.", "model": "v6", "duration": 5, diff --git a/test/fixtures/platform/voice.create/request.json b/test/fixtures/platform/voice.create/request.json index 2ed2960..7e7dbbb 100644 --- a/test/fixtures/platform/voice.create/request.json +++ b/test/fixtures/platform/voice.create/request.json @@ -7,6 +7,6 @@ "path": "/openapi/v2/video/tts_speaker", "body": { "name": "Sample voice", - "audio_media_id": "629000000000000003" + "audio_media_id": 629000000000000003 } } diff --git a/test/folders.test.js b/test/folders.test.js index 58263c3..7d03a00 100644 --- a/test/folders.test.js +++ b/test/folders.test.js @@ -82,12 +82,75 @@ test("resolveFolderForPayload creates a missing folder", async () => { assert.deepEqual(calls, ["list", ["create", { name: "REVOLVE" }]]); }); -test("resolveFolderForPayload keeps explicit folder ids as strings", async () => { +test("resolveFolderForPayload keeps an explicit video.folder_id without listing or creating", async () => { + const client = { + async listFolders() { + throw new Error("listFolders should not be called"); + }, + async createFolder() { + throw new Error("createFolder should not be called"); + }, + }; + const folder = await resolveFolderForPayload(client, { + product: { source_url: "https://www.revolve.com/item" }, + video: { folder_id: "630251570268735431" }, + }, { folderName: "Another Folder", autoFolder: true }); + + assert.equal(folder.folderId, "630251570268735431"); + assert.equal(folder.source, "explicit-folder-id"); + assert.equal(folder.folderCreated, false); +}); + +test("resolveFolderForPayload ignores a legacy top-level folder_id", async () => { const folder = await resolveFolderForPayload({}, { folder_id: "630251570268735431", product: { source_url: "https://www.revolve.com/item" }, }, {}); - assert.equal(folder.folderId, "630251570268735431"); - assert.equal(folder.source, "explicit-folder-id"); + assert.equal(folder, null); +}); + +test("resolveFolderForPayload sends an explicit folder name trimmed but otherwise unchanged", async () => { + const calls = []; + const client = { + async listFolders() { + calls.push("list"); + return { body: { folders: [{ folder_id: "1", name: "SUMMER DROP" }, { folder_id: "2", name: "summer_drop" }] } }; + }, + async createFolder(payload) { + calls.push(["create", payload]); + return { body: { folder_id: "636771263750078906" } }; + }, + }; + + const folder = await resolveFolderForPayload(client, {}, { folderName: " summer drop " }); + + assert.deepEqual(calls, ["list", ["create", { name: "summer drop" }]]); + assert.deepEqual(folder, { + folderId: "636771263750078906", + folderName: "summer drop", + folderCreated: true, + source: "created-folder", + }); +}); + +test("resolveFolderForPayload reuses an exact explicit folder name match", async () => { + const client = { + async listFolders() { + return { body: { folders: [{ folder_id: "1", name: "Summer DROP" }, { folder_id: "2", name: "summer_drop" }] } }; + }, + async createFolder() { + throw new Error("createFolder should not be called"); + }, + }; + + const folder = await resolveFolderForPayload(client, {}, { folderName: "summer_drop" }); + + assert.equal(folder.folderId, "2"); + assert.equal(folder.folderName, "summer_drop"); + assert.equal(folder.source, "existing-folder"); +}); + +test("resolveFolderForPayload rejects a blank explicit folder name", async () => { + await assert.rejects(resolveFolderForPayload({}, {}, { folderName: " " }), /Folder name cannot be empty/); }); diff --git a/test/helpers/cli-process.js b/test/helpers/cli-process.js index 7136481..571a232 100644 --- a/test/helpers/cli-process.js +++ b/test/helpers/cli-process.js @@ -58,7 +58,6 @@ function assertLoopbackProviderUrls(overrides) { const providers = [ ["PIXVERSE_PLATFORM_API_KEY", "PIXVERSE_PLATFORM_BASE_URL"], ["PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_BASE_URL"], - ["PIXVERSE_GROWTH_FOLDER_API_KEY", "PIXVERSE_GROWTH_BASE_URL"], ]; for (const [credentialName, baseUrlName] of providers) { if (overrides[credentialName] && !isLoopbackUrl(overrides[baseUrlName])) { @@ -83,7 +82,7 @@ function assertDotEnvProviderUrlIsLoopback(args, cwd, overrides) { const dotEnv = readDotEnv(path.join(cwd, ".env")); const credentialNames = provider === "platform" ? ["PIXVERSE_PLATFORM_API_KEY"] - : ["PIXVERSE_GROWTH_API_KEY", "PIXVERSE_GROWTH_FOLDER_API_KEY"]; + : ["PIXVERSE_GROWTH_API_KEY"]; const baseUrlName = provider === "platform" ? "PIXVERSE_PLATFORM_BASE_URL" : "PIXVERSE_GROWTH_BASE_URL"; diff --git a/test/helpers/recording-fetch.js b/test/helpers/recording-fetch.js index 8fa0182..cbbe547 100644 --- a/test/helpers/recording-fetch.js +++ b/test/helpers/recording-fetch.js @@ -1,3 +1,21 @@ +import JSONBigFactory from "json-bigint"; + +// Parse recorded JSON bodies losslessly so an integer ID above 2^53 can be +// told apart from a rounded neighbour (and from a JSON string). +const JSON_BIG = JSONBigFactory({ useNativeBigInt: true }); + +export function parseLosslessJson(text) { + return withPlainPrototypes(JSON_BIG.parse(text)); +} + +function withPlainPrototypes(value) { + if (Array.isArray(value)) return value.map(withPlainPrototypes); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withPlainPrototypes(item)])); + } + return value; +} + export function createRecordingFetch(responses = []) { const calls = []; let responseIndex = 0; @@ -30,7 +48,7 @@ export function jsonResponse(body, init = {}) { export async function describeRecordedBody(body) { if (body === undefined || body === null) return null; - if (typeof body === "string") return JSON.parse(body); + if (typeof body === "string") return parseLosslessJson(body); if (body instanceof FormData) { const fields = {}; for (const [name, value] of body.entries()) { diff --git a/test/integration/cli-routing.test.js b/test/integration/cli-routing.test.js index 57797ed..3ec6893 100644 --- a/test/integration/cli-routing.test.js +++ b/test/integration/cli-routing.test.js @@ -302,7 +302,7 @@ test("Growth Studio executable still loads provider configuration from cwd doten const home = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-growth-home-")); t.after(() => fs.rm(home, { recursive: true, force: true })); const server = http.createServer((request, response) => { - assert.equal(request.url, "/marketing_hub/folder/list"); + assert.equal(request.url, "/openapi/v1/folders"); response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ folders: [] })); }); @@ -411,7 +411,7 @@ test("executable routes load dedicated API credentials from the user auth store" test("legacy executable preserves JSON stdout and emits one deprecation line", async () => { const server = http.createServer((request, response) => { - assert.equal(request.url, "/marketing_hub/folder/list"); + assert.equal(request.url, "/openapi/v1/folders"); response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ folders: [{ folder_id: "630251570268735431" }] })); }); diff --git a/test/integration/growth-studio-cli.test.js b/test/integration/growth-studio-cli.test.js index 7691dd3..10acc8a 100644 --- a/test/integration/growth-studio-cli.test.js +++ b/test/integration/growth-studio-cli.test.js @@ -11,7 +11,7 @@ import { createRecordingFetch, jsonResponse } from "../helpers/recording-fetch.j test("canonical Growth Studio namespace is independent from Platform configuration", async () => { const output = captureOutput(); const { calls, fetchImpl } = createRecordingFetch([ - jsonResponse({ ErrCode: 0, Resp: { folders: [{ folder_id: "630251570268735431" }] } }), + jsonResponse('{"folders":[{"folder_id":630251570268735431,"name":"ACME"}],"request_id":"req-folders"}'), ]); const exitCode = await main(["growth-studio", "folders", "list"], { env: { @@ -23,9 +23,14 @@ test("canonical Growth Studio namespace is independent from Platform configurati }); assert.equal(exitCode, 0); - assert.deepEqual(JSON.parse(output.stdout()), { folders: [{ folder_id: "630251570268735431" }] }); + assert.deepEqual(JSON.parse(output.stdout()), { + folders: [{ folder_id: "630251570268735431", name: "ACME" }], + request_id: "req-folders", + }); assert.equal(output.stderr(), ""); assert.equal(calls.length, 1); + assert.equal(calls[0].method, "GET"); + assert.equal(new URL(calls[0].url).pathname, "/openapi/v1/folders"); assert.equal(calls[0].headers.get("Authorization"), "Bearer mh_live_fixture"); assert.equal(calls[0].headers.has("API-KEY"), false); }); diff --git a/test/jobs.test.js b/test/jobs.test.js index 722eb18..fb143aa 100644 --- a/test/jobs.test.js +++ b/test/jobs.test.js @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { GrowthStudioApiError } from "../src/http.js"; import { resumeGrowthStudioJob, runVideoJob } from "../src/jobs.js"; test("runVideoJob writes durable job artifacts and returns final output", async () => { @@ -102,7 +103,7 @@ test("runVideoJob can inject a folder_id override into the submitted payload", a async createVideo(receivedPayload) { assert.deepEqual(receivedPayload, { ...originalPayload, - folder_id: "630251570268735431", + video: { ...originalPayload.video, folder_id: "630251570268735431" }, }); return { body: { video_id: "627410861853514292", status: "processing" } }; }, @@ -118,8 +119,9 @@ test("runVideoJob can inject a folder_id override into the submitted payload", a }); const request = JSON.parse(await fs.readFile(path.join(result.job_dir, "request.json"), "utf8")); - assert.equal(request.payload.folder_id, "630251570268735431"); - assert.equal(originalPayload.folder_id, undefined); + assert.equal(request.payload.video.folder_id, "630251570268735431"); + assert.equal(request.payload.folder_id, undefined); + assert.equal(originalPayload.video.folder_id, undefined); }); test("runVideoJob resolves an auto folder before creating the video", async () => { @@ -137,7 +139,8 @@ test("runVideoJob resolves an auto folder before creating the video", async () = throw new Error("createFolder should not be called"); }, async createVideo(receivedPayload) { - assert.equal(receivedPayload.folder_id, "636771263750078906"); + assert.equal(receivedPayload.video.folder_id, "636771263750078906"); + assert.equal(receivedPayload.folder_id, undefined); return { body: { video_id: "627410861853514292", status: "processing" } }; }, async pollVideo() { @@ -153,11 +156,36 @@ test("runVideoJob resolves an auto folder before creating the video", async () = const request = JSON.parse(await fs.readFile(path.join(result.job_dir, "request.json"), "utf8")); const folder = JSON.parse(await fs.readFile(path.join(result.job_dir, "folder.json"), "utf8")); - assert.equal(request.payload.folder_id, "636771263750078906"); + assert.equal(request.payload.video.folder_id, "636771263750078906"); assert.equal(result.folder_id, "636771263750078906"); assert.equal(result.folder_name, "REVOLVE"); assert.equal(folder.source, "existing-folder"); - assert.equal(originalPayload.folder_id, undefined); + assert.equal(originalPayload.video.folder_id, undefined); +}); + +test("runVideoJob keeps a payload video.folder_id without resolving another folder", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-api-job-")); + const payload = { + metadata: { customer: "REVOLVE" }, + product: { source_url: "https://www.revolve.com/item" }, + video: { aspect_ratio: "9:16", folder_id: "630251570268735431" }, + }; + const client = { + async listFolders() { + throw new Error("listFolders should not be called"); + }, + async createFolder() { + throw new Error("createFolder should not be called"); + }, + async createVideo(receivedPayload) { + assert.deepEqual(receivedPayload, payload); + return { body: { video_id: "627410861853514292", status: "processing" } }; + }, + }; + + const result = await runVideoJob(client, payload, { jobsDir: root, autoFolder: true, poll: false }); + + assert.equal(result.folder_id, "630251570268735431"); }); test("runVideoJob writes private atomic artifacts with recursive credential redaction", async () => { @@ -279,6 +307,35 @@ test("runVideoJob persists a redacted recovery error without exposing upstream d assert.equal(JSON.stringify(failure).includes("super-secret"), false); }); +test("runVideoJob persists request_id and redacted details in error.json", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "pixverse-api-error-")); + await assert.rejects( + runVideoJob({ + async createVideo() { + throw new GrowthStudioApiError("Insufficient balance.", { + status: 402, + code: "INSUFFICIENT_BALANCE", + retryable: false, + requestId: "req_402_fixture", + details: { required: "1.00", api_key: "leaked-fixture-value" }, + }); + }, + }, { + product: { source_url: "https://shop.example.com/item" }, + video: { aspect_ratio: "9:16" }, + }, { jobsDir: root, traceId: "trace-402" }), + /Insufficient balance/, + ); + const [jobDir] = (await fs.readdir(root)).map((entry) => path.join(root, entry)); + const failure = JSON.parse(await fs.readFile(path.join(jobDir, "error.json"), "utf8")); + + assert.equal(failure.status, 402); + assert.equal(failure.code, "INSUFFICIENT_BALANCE"); + assert.equal(failure.request_id, "req_402_fixture"); + assert.deepEqual(failure.details, { required: "1.00", api_key: "[REDACTED]" }); + assert.equal(JSON.stringify(failure).includes("leaked-fixture-value"), false); +}); + async function fileExists(filePath) { try { await fs.access(filePath); diff --git a/test/platform-skill.test.js b/test/platform-skill.test.js index 1a63b4b..2d53392 100644 --- a/test/platform-skill.test.js +++ b/test/platform-skill.test.js @@ -14,7 +14,11 @@ const REFERENCES = [ "operation-catalog.md", "payload-examples.json", ]; -const MUSIC_MV_DOCUMENTATION_URL = "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc"; +// audio.verify has no public page yet; Music MV is documented as One-click VibeMV. +const MUSIC_MV_DOCUMENTATION_URLS = { + "audio.verify": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", +}; const MUSIC_MV_OPERATION_IDS = new Set(["audio.verify", "agent.music-mv"]); async function readSkill(relativePath) { @@ -100,7 +104,7 @@ test("operation reference is traceable to every executable catalog row", async ( assert.notEqual(row.prerequisite, ""); assert.equal(row.documentationUrl, operation.documentationUrl); if (MUSIC_MV_OPERATION_IDS.has(operation.id)) { - assert.equal(row.documentationUrl, MUSIC_MV_DOCUMENTATION_URL); + assert.equal(row.documentationUrl, MUSIC_MV_DOCUMENTATION_URLS[operation.id]); } else { assert.equal(new URL(row.documentationUrl).hostname, "docs.platform.pixverse.ai"); } @@ -156,7 +160,7 @@ test("model, pricing, and limit guidance is dated and linked to primary sources" assert.match(reference, /Verified against official docs: 2026-09-20/); assert.match(reference, /refresh|re-check|verify live/i); for (const page of officialPages) assert.ok(reference.includes(page), page); - assert.ok(reference.includes(MUSIC_MV_DOCUMENTATION_URL)); + assert.ok(reference.includes(MUSIC_MV_DOCUMENTATION_URLS["agent.music-mv"])); assert.match(reference, /15 credits.*second/i); assert.match(reference, /22\.5 credits.*second/i); assert.match(reference, /10.*360 seconds/i); diff --git a/test/unit/core-errors.test.js b/test/unit/core-errors.test.js index c1c98c0..a3c6031 100644 --- a/test/unit/core-errors.test.js +++ b/test/unit/core-errors.test.js @@ -62,3 +62,20 @@ test("PixverseCliError also accepts a single options object", () => { code: "BAD_INPUT", }); }); + +test("isTransientReadError classifies transport, 429, 5xx, and retryable provider codes", async () => { + const { isTransientReadError, PixverseCliError } = await import("../../src/core/errors.js"); + const make = (fields) => new PixverseCliError("x", fields); + assert.equal(isTransientReadError(make({ category: "transport" })), true); + assert.equal(isTransientReadError(make({ category: "http", status: 429 })), true); + assert.equal(isTransientReadError(make({ category: "http", status: 503 })), true); + assert.equal(isTransientReadError(make({ category: "provider", status: 500, retryable: false })), true); + assert.equal(isTransientReadError(make({ category: "response", status: 502 })), true); + assert.equal(isTransientReadError(make({ category: "provider", status: 200, code: 500069, retryable: true })), true); + assert.equal(isTransientReadError(make({ category: "provider", status: 200, code: 400017, retryable: false })), false); + assert.equal(isTransientReadError(make({ category: "http", status: 400 })), false); + assert.equal(isTransientReadError(make({ category: "response", status: 200 })), false); + const abort = new DOMException("aborted", "AbortError"); + assert.equal(isTransientReadError(make({ category: "transport", cause: abort })), false); + assert.equal(isTransientReadError(undefined), false); +}); diff --git a/test/unit/core-http.test.js b/test/unit/core-http.test.js index 5194516..1ae083d 100644 --- a/test/unit/core-http.test.js +++ b/test/unit/core-http.test.js @@ -179,3 +179,70 @@ test("requestHttp does not retry an AbortError raised during fetch", async () => assert.equal(attempts, 1); assert.deepEqual(delays, []); }); + +test("requestHttp gives each read-only retry attempt a fresh trace header", async () => { + const seen = []; + const traces = ["trace-2", "trace-3"]; + let attempts = 0; + const result = await requestHttp({ + url: "https://example.test/status", + headers: new Headers({ "Ai-trace-id": "trace-1", Accept: "application/json" }), + traceId: "trace-1", + nextTraceId: () => traces.shift(), + retry: { maxAttempts: 3, delayMs: 0 }, + sleep: async () => {}, + fetchImpl: async (_url, { headers }) => { + seen.push(headers.get("Ai-trace-id")); + assert.equal(headers.get("Accept"), "application/json"); + attempts += 1; + return attempts < 3 ? new Response("busy", { status: 503 }) : new Response('{"ok":true}'); + }, + }); + assert.deepEqual(seen, ["trace-1", "trace-2", "trace-3"]); + assert.equal(result.traceId, "trace-3"); + + // Plain-object headers are rewritten case-insensitively; failures report the attempt trace. + const objectSeen = []; + await assert.rejects(requestHttp({ + url: "https://example.test/status", + headers: { "ai-trace-id": "first" }, + traceId: "first", + nextTraceId: () => "second", + retry: { maxAttempts: 2, delayMs: 0 }, + sleep: async () => {}, + fetchImpl: async (_url, { headers }) => { + objectSeen.push(headers); + return new Response("busy", { status: 503 }); + }, + }), (error) => error.traceId === "second"); + assert.deepEqual(objectSeen, [{ "ai-trace-id": "first" }, { "Ai-trace-id": "second" }]); +}); + +test("requestHttp keeps the supplied trace when no per-attempt factory is given and never retries POST", async () => { + const seen = []; + await requestHttp({ + url: "https://example.test/status", + headers: new Headers({ "Ai-trace-id": "fixed" }), + traceId: "fixed", + retry: { maxAttempts: 2, delayMs: 0 }, + sleep: async () => {}, + fetchImpl: async (_url, { headers }) => { + seen.push(headers.get("Ai-trace-id")); + return seen.length === 1 ? new Response("busy", { status: 503 }) : new Response("{}"); + }, + }); + assert.deepEqual(seen, ["fixed", "fixed"]); + + let factoryCalls = 0; + let posts = 0; + await assert.rejects(requestHttp({ + url: "https://example.test/generate", + method: "POST", + traceId: "billable", + nextTraceId: () => { factoryCalls += 1; return "other"; }, + retry: { maxAttempts: 3 }, + fetchImpl: async () => { posts += 1; return new Response("busy", { status: 503 }); }, + }), (error) => error.traceId === "billable"); + assert.equal(posts, 1); + assert.equal(factoryCalls, 0); +}); diff --git a/test/unit/core-polling.test.js b/test/unit/core-polling.test.js index a92bffd..5a0c2e3 100644 --- a/test/unit/core-polling.test.js +++ b/test/unit/core-polling.test.js @@ -54,3 +54,95 @@ test("pollUntilTerminal validates callbacks and time bounds", async () => { /timeoutMs must be a non-negative number/i, ); }); + +function transient(fields = {}) { + return Object.assign(new Error("transient"), { category: "transport", ...fields }); +} + +test("pollUntilTerminal keeps polling through retryable errors with backoff and Retry-After", async () => { + let nowMs = 0; + const delays = []; + const outcomes = [ + transient(), + transient({ category: "http", status: 503 }), + transient({ category: "http", status: 429, retryAfter: 30 }), + { status: 5 }, + transient(), + { status: 1 }, + ]; + const errors = []; + const result = await pollUntilTerminal({ + poll: async () => { + const next = outcomes.shift(); + if (next instanceof Error) throw next; + return next; + }, + isTerminal: (snapshot) => snapshot.status === 1, + isRetryableError: () => true, + onPollError: async (error, { consecutiveErrors }) => errors.push(consecutiveErrors), + intervalMs: 1_000, + maxBackoffMs: 3_000, + timeoutMs: 600_000, + now: () => nowMs, + sleep: async (milliseconds) => { delays.push(milliseconds); nowMs += milliseconds; }, + }); + assert.deepEqual(result, { status: 1 }); + // 1s, 2s backoff, then Retry-After 30s beats the 3s cap, success resets the streak. + assert.deepEqual(delays, [1_000, 2_000, 30_000, 1_000, 1_000]); + assert.deepEqual(errors, [1, 2, 3, 1]); +}); + +test("pollUntilTerminal rethrows non-retryable errors immediately", async () => { + let polls = 0; + const fatal = Object.assign(new Error("bad id"), { category: "provider", retryable: false }); + await assert.rejects(pollUntilTerminal({ + poll: async () => { polls += 1; throw fatal; }, + isTerminal: () => false, + isRetryableError: (error) => error.retryable === true, + sleep: async () => { throw new Error("must not sleep"); }, + }), (error) => error === fatal); + assert.equal(polls, 1); + + // Without the opt-in classifier, any error still fails fast (backward compatible). + await assert.rejects(pollUntilTerminal({ + poll: async () => { throw transient(); }, + isTerminal: () => false, + }), /transient/); +}); + +test("pollUntilTerminal times out with the last error when transient failures persist", async () => { + let nowMs = 0; + await assert.rejects(pollUntilTerminal({ + poll: async () => { throw transient({ status: 502, traceId: "t-1" }); }, + isTerminal: () => false, + isRetryableError: () => true, + intervalMs: 100, + timeoutMs: 1_000, + now: () => nowMs, + sleep: async (milliseconds) => { nowMs += milliseconds; }, + provider: "platform", + }), (error) => error.category === "timeout" + && error.details.last_error.category === "transport" + && error.details.last_error.status === 502 + && error.details.last_error.trace_id === "t-1"); + assert.ok(nowMs <= 1_000); + + // A Retry-After longer than the remaining budget stops instead of polling early. + nowMs = 0; + let polls = 0; + await assert.rejects(pollUntilTerminal({ + poll: async () => { polls += 1; throw transient({ retryAfter: 5 }); }, + isTerminal: () => false, + isRetryableError: () => true, + intervalMs: 100, + timeoutMs: 1_000, + now: () => nowMs, + sleep: async (milliseconds) => { nowMs += milliseconds; }, + }), (error) => error.category === "timeout"); + assert.equal(polls, 1); + + await assert.rejects( + pollUntilTerminal({ poll: async () => ({}), isTerminal: () => true, isRetryableError: true }), + /isRetryableError must be a function/i, + ); +}); diff --git a/test/unit/growth-studio-compatibility.test.js b/test/unit/growth-studio-compatibility.test.js index 964c5f8..54f6c16 100644 --- a/test/unit/growth-studio-compatibility.test.js +++ b/test/unit/growth-studio-compatibility.test.js @@ -51,9 +51,8 @@ test("flat modules preserve Growth Studio named exports", () => { assert.equal(flatHttp.parseResponse, growthClient.parseResponse); }); -test("Growth Studio config has a provider-specific name and preserves folder key override", () => { +test("Growth Studio config has a provider-specific name and ignores removed folder key settings", () => { const apiKeyField = "api" + "Key"; - const folderApiKeyField = "folderApi" + "Key"; const config = growthConfig.getGrowthStudioConfig({ PIXVERSE_GROWTH_API_KEY: "mh_" + "live_fixture_video", PIXVERSE_GROWTH_FOLDER_API_KEY: "mh_" + "live_fixture_folder", @@ -64,8 +63,6 @@ test("Growth Studio config has a provider-specific name and preserves folder key assert.deepEqual(config, { [apiKeyField]: "mh_" + "live_fixture_video", - [folderApiKeyField]: "mh_" + "live_fixture_folder", - folderApiPrefix: "/openapi/v1/growth-studio", baseUrl: "https://growth.example.test", }); assert.equal(growthConfig.getConfig, growthConfig.getGrowthStudioConfig); @@ -179,7 +176,8 @@ test("namespaced jobs preserve artifacts and do not mutate payloads during folde const client = { async createVideo(received) { assert.notEqual(received, payload); - assert.equal(received.folder_id, "630251570268735431"); + assert.equal(received.video.folder_id, "630251570268735431"); + assert.equal(received.folder_id, undefined); return { body: { video_id: "627410861853514292", status: "processing" } }; }, }; @@ -190,7 +188,7 @@ test("namespaced jobs preserve artifacts and do not mutate payloads during folde poll: false, }); - assert.equal(payload.folder_id, undefined); + assert.equal(payload.video.folder_id, undefined); assert.equal(result.video_id, "627410861853514292"); assert.equal(result.folder_id, "630251570268735431"); assert.deepEqual( @@ -272,7 +270,7 @@ test("Growth Studio command adapter preserves read-only result shapes", async () assert.equal(calls[0][0], "avatars"); assert.equal(calls[1][0], "folders"); assert.deepEqual(calls[2][1], { - limit: "20", + limit: 20, status: "succeeded", cursor: "next", traceId: calls[2][1].traceId, @@ -377,9 +375,10 @@ test("Growth Studio command adapter preserves create, edit, upload, and job beha assert.equal(job.video_id, "627410861853514292"); assert.ok(job.job_dir.startsWith(jobsDir)); assert.deepEqual(calls.map(([name]) => name), ["upload", "create", "create", "edit", "create"]); - assert.equal(calls[1][1].folder_id, "630251570268735431"); - assert.equal(calls[2][1].folder_id, undefined); - assert.equal(calls[4][1].folder_id, undefined); + assert.equal(calls[1][1].video.folder_id, "630251570268735431"); + assert.equal(calls[1][1].folder_id, undefined); + assert.equal(calls[2][1].video.folder_id, undefined); + assert.equal(calls[4][1].video.folder_id, undefined); }); test("Growth Studio folder ensure accepts explicit option form", async () => { @@ -396,13 +395,47 @@ test("Growth Studio folder ensure accepts explicit option form", async () => { "folders", "ensure", "--folder-name", - "fixture-folder", + "Fixture Folder", ], { client }); assert.equal(result.folderId, "630251570268735431"); assert.equal(result.source, "existing-folder"); }); +test("Growth Studio folder ensure sends the user-provided name without normalization", async () => { + const created = []; + const client = { + async listFolders() { + return { body: { folders: [{ folder_id: "630251570268735431", name: "Fixture Folder" }] } }; + }, + async createFolder(payload) { + created.push(payload); + return { body: { folder_id: "630251570268735432" } }; + }, + }; + + const result = await runGrowthStudioCommand(["folders", "ensure", " fixture-folder "], { client }); + + assert.deepEqual(created, [{ name: "fixture-folder" }]); + assert.equal(result.folderId, "630251570268735432"); + assert.equal(result.folderName, "fixture-folder"); + assert.equal(result.source, "created-folder"); +}); + +test("Growth Studio video list rejects undocumented limits and statuses before client access", async () => { + let clientAccesses = 0; + const context = { + get client() { + clientAccesses += 1; + throw new Error("client must not be loaded for invalid list options"); + }, + }; + await assert.rejects(runGrowthStudioCommand(["video", "list", "--limit", "0"], context), /--limit must be a safe integer from 1 through 100/); + await assert.rejects(runGrowthStudioCommand(["video", "list", "--limit", "101"], context), /--limit/); + await assert.rejects(runGrowthStudioCommand(["video", "list", "--limit", "2.5"], context), /--limit/); + assert.equal(clientAccesses, 0); +}); + test("Growth Studio command adapter rejects incomplete commands without configuration", async () => { await assert.rejects( runGrowthStudioCommand(["folders", "ensure"], { client: {} }), diff --git a/test/unit/growth-studio-payload-boundary.test.js b/test/unit/growth-studio-payload-boundary.test.js index 340a441..b884137 100644 --- a/test/unit/growth-studio-payload-boundary.test.js +++ b/test/unit/growth-studio-payload-boundary.test.js @@ -11,39 +11,57 @@ const freeze = (value) => { return value; }; +const video = (overrides = {}) => ({ + aspect_ratio: "9:16", + duration_seconds: 15, + resolution: "720p", + language: "en-US", + voiceover: true, + captions: true, + background_music: true, + avatar: { mode: "auto" }, + ...overrides, +}); + test("Growth Studio accepts a neutral product URL payload without mutation", () => { const payload = freeze({ - folder_id: "630251570268735431", product: { source_url: "https://shop.example.com/products/example-item" }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video({ folder_id: "630251570268735431" }), }); assert.doesNotThrow(() => validateCreatePayload(payload)); assert.deepEqual(payload, { - folder_id: "630251570268735431", product: { source_url: "https://shop.example.com/products/example-item" }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video({ folder_id: "630251570268735431" }), }); }); test("Growth Studio accepts only PixVerse upload URLs for image payloads", () => { assert.doesNotThrow(() => validateCreatePayload(freeze({ product: { + title: "Example item", images: [{ url: "https://media.pixverse.ai/marketing_hub_website/openapi_video_inputs/uploads/example/product.webp" }], }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video(), }))); assert.throws(() => validateCreatePayload({ - product: { images: [{ url: "https://shop.example.com/product.webp" }] }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + product: { title: "Example item", images: [{ url: "https://shop.example.com/product.webp" }] }, + video: video(), }), /Product images must use URLs returned by the image upload endpoint/); }); test("Growth Studio rejects unsafe numeric folder IDs before network access", () => { assert.throws(() => validateCreatePayload({ - folder_id: 630251570268735431, product: { source_url: "https://shop.example.com/products/example-item" }, - video: { aspect_ratio: "9:16", duration_seconds: 15, resolution: "720p" }, + video: video({ folder_id: 630251570268735431 }), }), /folder_id must be a numeric string/); }); + +test("Growth Studio rejects a top-level folder_id because it belongs at video.folder_id", () => { + assert.throws(() => validateCreatePayload({ + folder_id: "630251570268735431", + product: { source_url: "https://shop.example.com/products/example-item" }, + video: video(), + }), /video\.folder_id/); +}); diff --git a/test/unit/platform-client-retry-trace.test.js b/test/unit/platform-client-retry-trace.test.js new file mode 100644 index 0000000..8b77ea0 --- /dev/null +++ b/test/unit/platform-client-retry-trace.test.js @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PlatformClient } from "../../src/platform/client.js"; + +const BASE_URL = "https://platform.example.test"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function recorder(responses) { + const calls = []; + return { + calls, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), method: init.method, trace: new Headers(init.headers).get("Ai-trace-id") }); + const next = responses.shift(); + if (next instanceof Error) throw next; + return next; + }, + }; +} + +test("read-only GET retries send a unique Ai-trace-id per attempt and report the successful one", async () => { + const fetch = recorder([ + new TypeError("socket closed"), + new Response("busy", { status: 503 }), + new Response('{"ErrCode":0,"ErrMsg":"success","Resp":{"id":"9","status":5}}'), + ]); + const client = new PlatformClient({ + apiKey: "fixture-credential", baseUrl: BASE_URL, fetchImpl: fetch.fetchImpl, sleep: async () => {}, + }); + const result = await client.execute("video.status", { video_id: "9" }); + const traces = fetch.calls.map(({ trace }) => trace); + assert.equal(traces.length, 3); + assert.equal(new Set(traces).size, 3); + for (const trace of traces) assert.match(trace, UUID); + assert.equal(result.traceId, traces.at(-1)); +}); + +test("a billable submission is sent once with its recovery trace, even on 503 or a retryable ErrCode", async () => { + const savedTrace = "11111111-1111-4111-8111-111111111111"; + for (const response of [ + new Response("busy", { status: 503 }), + new Response('{"ErrCode":500069,"ErrMsg":"high load","Resp":null}'), + ]) { + const fetch = recorder([response, new Response('{"ErrCode":0,"Resp":{"video_id":"1"}}')]); + const client = new PlatformClient({ + apiKey: "fixture-credential", baseUrl: BASE_URL, fetchImpl: fetch.fetchImpl, sleep: async () => {}, + }); + await assert.rejects(client.execute("video.text", { + aspect_ratio: "16:9", duration: 5, model: "v6", prompt: "hello", quality: "720p", + }, { recovery: true, traceId: savedTrace }), (error) => error.traceId === savedTrace); + assert.equal(fetch.calls.length, 1); + assert.equal(fetch.calls[0].method, "POST"); + assert.equal(fetch.calls[0].trace, savedTrace); + } +}); diff --git a/test/unit/platform-envelope.test.js b/test/unit/platform-envelope.test.js index 5c90833..ef138a2 100644 --- a/test/unit/platform-envelope.test.js +++ b/test/unit/platform-envelope.test.js @@ -43,3 +43,12 @@ test("parsePlatformEnvelope rejects malformed response bodies", () => { assert.throws(() => parsePlatformEnvelope(body), /Platform API response envelope/i); } }); + +test("parsePlatformEnvelope marks documented transient ErrCodes retryable and others not", () => { + for (const [code, retryable] of [[500069, true], ["500069", true], [500044, true], [10005, false], [500063, false]]) { + assert.throws( + () => parsePlatformEnvelope({ ErrCode: code, ErrMsg: "x", Resp: null }, { retryAfter: 3 }), + (error) => error.retryable === retryable && error.retryAfter === 3, + ); + } +}); diff --git a/test/unit/platform-jobs.test.js b/test/unit/platform-jobs.test.js index 1565569..be9c229 100644 --- a/test/unit/platform-jobs.test.js +++ b/test/unit/platform-jobs.test.js @@ -19,8 +19,8 @@ const AGENT_INPUT = Object.freeze({ agent_id: "414562414124109", prompt: "Recreate pacing.", quality: "720p", - img_references: Object.freeze([{ img_url: "https://cdn.example.test/a.png" }]), - video_references: Object.freeze([{ video_url: "https://cdn.example.test/a.mp4" }]), + img_references: Object.freeze([{ img_id: "1" }]), + video_references: Object.freeze([{ video_media_id: "2" }]), }); const MUSIC_MV_INPUT = Object.freeze({ mv_agent_type: "vibe_mv_v3_custom", @@ -376,3 +376,103 @@ test("polling respects a successful response Retry-After hint", async (t) => { }); assert.deepEqual(delays, [2_000]); }); + +function transientStatusClient(outcomes, counters) { + return { + execute: async (id) => { + if (id === "video.text") { + counters.submits += 1; + return { operation: id, traceId: TRACE, envelope: { ErrCode: 0, Resp: { video_id: "9" } }, data: { video_id: "9" } }; + } + counters.reads += 1; + const next = outcomes.shift(); + if (next instanceof Error) throw next; + return { + operation: id, traceId: `status-${counters.reads}`, + envelope: { ErrCode: 0, Resp: { id: "9", status: next } }, data: { id: "9", status: next }, + }; + }, + }; +} + +test("status polling survives transient read errors and keeps a single billable submit", async (t) => { + const { PixverseCliError } = await import("../../src/core/errors.js"); + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + const delays = []; + let clock = 0; + const final = await submitPlatformJob(transientStatusClient([ + 5, + new PixverseCliError("socket closed", { category: "transport", provider: "platform", traceId: "t-a" }), + new PixverseCliError("busy", { category: "http", status: 503, retryAfter: 7 }), + new PixverseCliError("high load", { category: "provider", status: 200, code: 500069, retryable: true }), + 1, + ], counters), "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, + now: () => clock, + sleep: async (milliseconds) => { delays.push(milliseconds); clock += milliseconds; }, + }); + assert.equal(final.status, "succeeded"); + assert.equal(counters.submits, 1); + assert.equal(counters.reads, 5); + // Default 5s cadence; then error backoff 5s, max(10s backoff, 7s Retry-After), 20s. + assert.deepEqual(delays, [5_000, 5_000, 10_000, 20_000]); + const artifacts = await readJobArtifacts(final.job_dir); + const errors = artifacts["polling.jsonl"].filter((entry) => entry.status === "poll_error"); + assert.deepEqual(errors.map((entry) => entry.error.category), ["transport", "http", "provider"]); + assert.equal(errors[2].error.code, 500069); + assert.equal(artifacts["polling.jsonl"].at(-1).status, "succeeded"); + assert.equal(artifacts["error.json"], undefined); +}); + +test("status polling fails immediately on a non-retryable read error", async (t) => { + const { PixverseCliError } = await import("../../src/core/errors.js"); + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + await assert.rejects(submitPlatformJob(transientStatusClient([ + 5, + new PixverseCliError("invalid id", { category: "provider", status: 200, code: 400017, retryable: false }), + 1, + ], counters), "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, sleep: async () => {}, now: () => 0, + }), (error) => error.code === 400017); + assert.equal(counters.submits, 1); + assert.equal(counters.reads, 2); +}); + +test("status polling stops retrying transient errors once the caller aborts", async (t) => { + const { PixverseCliError } = await import("../../src/core/errors.js"); + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + const controller = new AbortController(); + const client = transientStatusClient([], counters); + const execute = client.execute; + client.execute = async (id, input, options) => { + if (id !== "video.text") { + controller.abort(); + throw new PixverseCliError("socket closed", { category: "transport" }); + } + return execute(id, input, options); + }; + await assert.rejects(submitPlatformJob(client, "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, signal: controller.signal, + sleep: async () => {}, now: () => 0, + }), (error) => error.category === "transport"); +}); + +test("default polling uses a 5s cadence and a resumable 30 minute timeout", async (t) => { + const root = await createTempJobRoot(t); + const counters = { submits: 0, reads: 0 }; + const delays = []; + let clock = 0; + const outcomes = new Array(1_000).fill(5); + await assert.rejects(submitPlatformJob(transientStatusClient(outcomes, counters), "video.text", INPUT, { + jobRoot: root, traceIdFactory: () => TRACE, poll: true, + now: () => clock, + sleep: async (milliseconds) => { delays.push(milliseconds); clock += milliseconds; }, + }), (error) => error.category === "timeout" && error.details.timeout_ms === 30 * 60 * 1_000 + && error.details.id === "9"); + assert.ok(delays.every((delay) => delay === 5_000)); + assert.equal(clock, 30 * 60 * 1_000); + assert.equal(counters.submits, 1); +}); diff --git a/test/unit/platform-operations.test.js b/test/unit/platform-operations.test.js index a945803..2fd3bd8 100644 --- a/test/unit/platform-operations.test.js +++ b/test/unit/platform-operations.test.js @@ -17,7 +17,11 @@ const successShapes = JSON.parse(fs.readFileSync( "utf8", )); -const MUSIC_MV_DOCUMENTATION_URL = "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc"; +// audio.verify has no public page yet; Music MV is documented as One-click VibeMV. +const MUSIC_MV_DOCUMENTATION_URLS = { + "audio.verify": "https://aisphere.feishu.cn/wiki/L8pgwGoSwiDbQ8ksGIHcppcvnrc", + "agent.music-mv": "https://docs.platform.pixverse.ai/one-click-vibemv-44175576e0", +}; const MUSIC_MV_OPERATION_IDS = new Set(["audio.verify", "agent.music-mv"]); test("Platform catalog matches every independently recorded official operation", () => { @@ -54,7 +58,7 @@ test("every operation links to its exact official documentation page", () => { const publicUrls = []; for (const operation of PLATFORM_OPERATIONS) { if (MUSIC_MV_OPERATION_IDS.has(operation.id)) { - assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URL); + assert.equal(operation.documentationUrl, MUSIC_MV_DOCUMENTATION_URLS[operation.id]); } else { assert.equal(new URL(operation.documentationUrl).hostname, "docs.platform.pixverse.ai"); publicUrls.push(operation.documentationUrl); diff --git a/test/unit/platform-validation.test.js b/test/unit/platform-validation.test.js index 4899019..0add4e3 100644 --- a/test/unit/platform-validation.test.js +++ b/test/unit/platform-validation.test.js @@ -305,7 +305,8 @@ test("Music MV rejects ambiguous aliases, unsupported enums, invalid switches, s await assert.rejects(normalizeAndValidatePlatformInput(operation, input), pattern); } - for (const mv_agent_type of ["vibe_mv", "vibe_mv_v3_custom"]) { + await assert.rejects(normalizeAndValidatePlatformInput(operation, { ...base, mv_agent_type: "vibe_mv" }), /mv_agent_type/); + for (const mv_agent_type of ["vibe_mv_v3", "vibe_mv_v3_custom"]) { const result = await normalizeAndValidatePlatformInput(operation, { ...base, mv_agent_type }); assert.equal(result.payload.mv_agent_type, mv_agent_type); } @@ -357,8 +358,8 @@ const distinguishingFieldCases = [ }, "template_id"], ["video.sound-effect", { source_video_id: "1", - prompt: "Soft rain and distant thunder", - }, "prompt"], + sound_effect_content: "Soft rain and distant thunder", + }, "source_video_id"], ]; for (const [id, validInput, distinguishingField] of distinguishingFieldCases) { @@ -473,7 +474,20 @@ test("official multi-transition order and bounds are validated", async () => { await assert.rejects(normalizeAndValidatePlatformInput(operation, { ...base, multi_transition: [{ img_id: "1", duration: 0 }, { img_id: "2" }], - }), /duration.*positive/i); + }), /duration.*1 to 8/i); + // Documented bounds: 1-8 per item, 1-5 with three or more items, last item 0 or omitted. + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...base, + multi_transition: [{ img_id: "1", duration: 9 }, { img_id: "2" }], + }), /duration.*1 to 8/i); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...base, + multi_transition: [{ img_id: "1", duration: 6 }, { img_id: "2", duration: 5 }, { img_id: "3" }], + }), /duration.*1 to 5/i); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...base, + multi_transition: [{ img_id: "1", duration: 3 }, { img_id: "2", duration: 4 }], + }), /last.*0 or omitted/i); }); test("official mutually-exclusive source and lip-sync combinations are validated", async () => { @@ -503,11 +517,11 @@ test("swap and swap-mask accept positive keyframe identifiers after string-safe }); assert.equal(mask.payload.keyframe_id, "12"); - const firstFrameMask = await normalizeAndValidatePlatformInput(getPlatformOperation("video.swap-mask"), { + // The documented keyframe range starts at 1 for swap-mask as well as swap. + await assert.rejects(normalizeAndValidatePlatformInput(getPlatformOperation("video.swap-mask"), { source_video_id: "5", keyframe_id: 0, - }); - assert.equal(firstFrameMask.payload.keyframe_id, "0"); + }), /keyframe_id/); }); test("keyframe identifiers apply endpoint-specific zero rules and reject invalid values", async () => { @@ -554,11 +568,11 @@ test("checked-in tiny media fixtures are non-empty and viral-agent local referen agent_id: "414562414124109", prompt: "Recreate this rhythm without copying branding.", quality: "720p", - img_references: [{ file: "/safe/tiny.png" }], - video_references: [{ file: "/safe/tiny.mp4" }], + img_references: [{ img_id: "11", file: "/safe/tiny.png" }], + video_references: [{ video_media_id: "12", file: "/safe/tiny.mp4" }], }; const calls = []; - await normalizeAndValidatePlatformInput(operation, input, { + const validated = await normalizeAndValidatePlatformInput(operation, input, { inspectLocalMedia: async (file, options) => { calls.push({ file, options }); return file.endsWith(".png") @@ -569,6 +583,18 @@ test("checked-in tiny media fixtures are non-empty and viral-agent local referen assert.equal(calls.length, 2); assert.equal(calls[0].options.maxBytes, 20 * 1024 * 1024); assert.equal(calls[1].options.maxBytes, 100 * 1024 * 1024); + // Local paths only drive the media checks; the API receives the uploaded IDs. + assert.deepEqual(validated.payload.img_references, [{ img_id: "11" }]); + assert.deepEqual(validated.payload.video_references, [{ video_media_id: "12" }]); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...input, + img_references: [{ img_url: "https://cdn.example.test/a.png" }], + }), /img_references\[0\] requires an img_id/); + await assert.rejects(normalizeAndValidatePlatformInput(operation, { + ...input, + img_references: [{ img_id: "11" }], + video_references: [{ video_url: "https://cdn.example.test/a.mp4" }], + }), /video_references\[0\]/); await assert.rejects(normalizeAndValidatePlatformInput(operation, input, { inspectLocalMedia: async (file) => file.endsWith(".png") @@ -576,3 +602,59 @@ test("checked-in tiny media fixtures are non-empty and viral-agent local referen : { size_bytes: 100, width: 1080, height: 1080, duration_seconds: 31 }, }), /duration.*2.*30/i); }); + +test("validators follow the documented field rules found in the API doc review", async () => { + const validate = (id, input) => normalizeAndValidatePlatformInput(getPlatformOperation(id), input); + + // Template prompts may be empty because the template supplies them. + const template = await validate("video.template", { + template_id: "302325299692608", img_id: "1", model: "v6", duration: 5, quality: "720p", + }); + assert.equal(template.payload.prompt, ""); + + // Sound effects describe the sound with sound_effect_content; prompt is not an API field. + await validate("video.sound-effect", { video_media_id: "1" }); + await assert.rejects(validate("video.sound-effect", { video_media_id: "1", prompt: "Rain" }), /sound_effect_content/); + await assert.rejects(validate("video.sound-effect", { video_media_id: "1", original_sound_switch: "yes" }), /boolean/); + + // Music MV: aspect_ratio and quality are optional, and the style lists match the public doc. + const mvBase = { mv_agent_type: "vibe_mv_v3_custom", audio_media_id: "1" }; + await validate("agent.music-mv", mvBase); + await validate("agent.music-mv", { ...mvBase, music_style: "Hip Hop", mv_style: "Golden Age" }); + await assert.rejects(validate("agent.music-mv", { ...mvBase, mv_style: "Vintage" }), /mv_style/); + await assert.rejects(validate("agent.music-mv", { + ...mvBase, mv_style: "Cinematic", style_img_references: [{ img_id: "2" }], + }), /style_img_references.*Custom/); + + // Transition only reads first_frame_img/last_frame_img. + await assert.rejects(validate("video.transition", { + prompt: "Morph", model: "v6", duration: 5, quality: "720p", first_frame_url: "https://cdn.example.test/a.png", last_frame_img: "2", + }), /first_frame_img is required/); + + // Avatar TTS scripts must be 30-200 characters. + await assert.rejects(validate("video.avatar", { + img_id: "1", quality: "720p", lip_sync_tts_speaker_id: "auto", lip_sync_tts_content: "Too short.", + }), /30 to 200/); + + // v6 omni fusion may reference only videos, at most two. + const omni = { model: "v6", reference_mode: "omni", prompt: "Recreate @video1", duration: 0, quality: "720p", aspect_ratio: "auto" }; + await validate("video.fusion", { ...omni, video_references: [{ video_media_id: "1" }] }); + await assert.rejects(validate("video.fusion", { + ...omni, video_references: [{ video_media_id: "1" }, { video_media_id: "2" }, { video_media_id: "3" }], + }), /two reference videos/); + + // Extend is not available on c1; real-estate pro-only fields are rejected for fast. + await assert.rejects(validate("video.extend", { + source_video_id: "1", prompt: "Continue", seed: 1, quality: "720p", duration: 5, model: "c1", + }), /extend model/); + await assert.rejects(validate("agent.real-estate", { + agent_id: "419629433597950", model: "fast", product_url: "https://listing.example.test/home", video_style: "cozy", + }), /video_style.*pro/); + + // Template list query values are documented as strings. + const templates = await validate("resource.templates", { page: "1", pageSize: "50" }); + assert.equal(templates.query.pageSize, "50"); + + // Restyle seeds share the documented 32-bit ceiling. + await assert.rejects(validate("video.restyle", { video_media_id: "1", restyle_id: "2", seed: 2_147_483_648 }), /seed/); +}); diff --git a/test/unit/platform-webhooks.test.js b/test/unit/platform-webhooks.test.js index 4e39194..5a2affb 100644 --- a/test/unit/platform-webhooks.test.js +++ b/test/unit/platform-webhooks.test.js @@ -5,6 +5,7 @@ import test from "node:test"; import { createMemoryNonceStore, createPlatformWebhookHandler, + DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, parsePlatformWebhook, verifyPlatformWebhook, } from "../../src/platform/webhooks.js"; @@ -96,7 +97,11 @@ for (const [name, configure] of [ }); } -for (const [name, timestamp] of [["stale", TIMESTAMP - 301], ["future", TIMESTAMP + 301]]) { +for (const [name, timestamp, options] of [ + ["stale (default 6h window)", TIMESTAMP - (6 * 60 * 60) - 1, {}], + ["stale (configured window)", TIMESTAMP - 301, { timestampToleranceSeconds: 300 }], + ["future", TIMESTAMP + 301, {}], +]) { test(`rejects a ${name} timestamp`, async () => { await assert.rejects(verifyPlatformWebhook({ rawBody: RAW_BODY, @@ -104,20 +109,122 @@ for (const [name, timestamp] of [["stale", TIMESTAMP - 301], ["future", TIMESTAM secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore: createMemoryNonceStore(), + ...options, }), (error) => error.code === "WEBHOOK_TIMESTAMP_OUT_OF_RANGE"); }); } -test("rejects nonce replay, including concurrent verification", async () => { +test("default timestamp window accepts the documented retry schedule reusing the original timestamp", async () => { + // 2s + 10s + 30s + 1m + 5m + 15m + 1h + 4h after the original attempt. + const lastRetryAge = 2 + 10 + 30 + 60 + 300 + 900 + 3_600 + 14_400; + const verified = await verifyPlatformWebhook({ + rawBody: RAW_BODY, + headers: signedHeaders(RAW_BODY, { timestamp: TIMESTAMP - lastRetryAge }), + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(await verified.release(), true); + assert.equal(DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, 6 * 60 * 60); + assert.ok(DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS > lastRetryAge); +}); + +test("timestamp tolerance is configurable and validated", async () => { + const input = { + rawBody: RAW_BODY, + headers: signedHeaders(RAW_BODY, { timestamp: TIMESTAMP - 100 }), + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }; + await assert.rejects( + verifyPlatformWebhook({ ...input, timestampToleranceSeconds: 99 }), + (error) => error.code === "WEBHOOK_TIMESTAMP_OUT_OF_RANGE", + ); + const verified = await verifyPlatformWebhook({ ...input, timestampToleranceSeconds: 100 }); + assert.equal(await verified.release(), true); + for (const invalid of [0, -1, 1.5, "300", 8 * 24 * 60 * 60]) { + await assert.rejects( + verifyPlatformWebhook({ ...input, timestampToleranceSeconds: invalid }), + (error) => error.code === "WEBHOOK_CONFIGURATION_INVALID", + ); + assert.throws( + () => createPlatformWebhookHandler({ + secret: SIGNING_KEY, onDelivery: async () => {}, timestampToleranceSeconds: invalid, + }), + (error) => error.code === "WEBHOOK_CONFIGURATION_INVALID", + ); + } +}); + +test("nonce is held for the whole timestamp window", async () => { + const nonceStore = createMemoryNonceStore(); + const first = await verifyPlatformWebhook({ + rawBody: RAW_BODY, headers: signedHeaders(), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, + }); + assert.equal(await first.commit(), true); + await assert.rejects(verifyPlatformWebhook({ + rawBody: RAW_BODY, + headers: signedHeaders(), + secret: SIGNING_KEY, + now: () => TIMESTAMP + DEFAULT_WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS, + nonceStore, + }), (error) => error.code === "WEBHOOK_REPLAY" && error.duplicate === true); +}); + +test("blocks concurrent verification of an in-flight nonce and flags committed replays as duplicates", async () => { const nonceStore = createMemoryNonceStore(); const attempt = () => verifyPlatformWebhook({ rawBody: RAW_BODY, headers: signedHeaders(), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, }); const results = await Promise.allSettled([attempt(), attempt(), attempt()]); assert.equal(results.filter(({ status }) => status === "fulfilled").length, 1); - assert.equal(results.filter(({ status, reason }) => status === "rejected" && reason.code === "WEBHOOK_REPLAY").length, 2); + assert.equal(results.filter(({ status, reason }) => status === "rejected" + && reason.code === "WEBHOOK_DELIVERY_IN_PROGRESS" && reason.statusCode === 409).length, 2); const [{ value }] = results.filter(({ status }) => status === "fulfilled"); - assert.equal(await value.release(), true); + assert.equal(await value.commit(), true); + await assert.rejects(attempt(), (error) => error.code === "WEBHOOK_REPLAY" + && error.duplicate === true && error.statusCode === 200); +}); + +test("deduplicates a redelivered event by payload id and status even with a fresh nonce", async () => { + const nonceStore = createMemoryNonceStore(); + const success = JSON.stringify({ id: "123456789", status: 1, url: "https://example.test/v.mp4" }); + const verify = (rawBody, nonce) => verifyPlatformWebhook({ + rawBody, headers: signedHeaders(rawBody, { nonce }), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, + }); + const first = await verify(success, "nonce-a"); + assert.equal(await first.commit(), true); + await assert.rejects(verify(success, "nonce-b"), (error) => error.code === "WEBHOOK_DUPLICATE_EVENT" + && error.duplicate === true && error.statusCode === 200); + // The rejected redelivery released its own nonce reservation. + await assert.rejects(verify(success, "nonce-b"), (error) => error.code === "WEBHOOK_DUPLICATE_EVENT"); + + // A different status for the same id is a new event. + const failed = JSON.stringify({ id: "123456789", status: 8, url: null }); + const next = await verify(failed, "nonce-c"); + assert.equal(await next.commit(), true); + + // A released (failed) delivery leaves the event open for the retry. + const other = JSON.stringify({ id: "987", status: 1 }); + const attempt = await verify(other, "nonce-d"); + assert.equal(await attempt.release(), true); + const retry = await verify(other, "nonce-e"); + assert.equal(await retry.commit(), true); +}); + +test("an in-flight event blocks a concurrent redelivery with a different nonce", async () => { + const nonceStore = createMemoryNonceStore(); + const rawBody = JSON.stringify({ id: "55", status: 1 }); + const verify = (nonce) => verifyPlatformWebhook({ + rawBody, headers: signedHeaders(rawBody, { nonce }), secret: SIGNING_KEY, now: () => TIMESTAMP, nonceStore, + }); + const first = await verify("first"); + await assert.rejects(verify("second"), (error) => error.code === "WEBHOOK_DELIVERY_IN_PROGRESS"); + assert.equal(await first.release(), true); + // The losing attempt released its nonce, so its retry proceeds normally. + const retry = await verify("second"); + assert.equal(await retry.commit(), true); }); test("bounds and expires the in-memory nonce store", () => { @@ -141,7 +248,7 @@ test("supports byte bodies, Headers, Date clocks, and Set replay stores", async }; const reserved = await verifyPlatformWebhook(input); assert.equal(reserved.nonce, "nonce-1"); - await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_REPLAY"); + await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_DELIVERY_IN_PROGRESS"); assert.equal(await reserved.release(), true); const committed = await verifyPlatformWebhook(input); assert.equal(await committed.commit(), true); @@ -158,7 +265,7 @@ test("standalone verification requires an explicit commit or release", async () nonceStore, }; const first = await verifyPlatformWebhook(input); - await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_REPLAY"); + await assert.rejects(verifyPlatformWebhook(input), (error) => error.code === "WEBHOOK_DELIVERY_IN_PROGRESS"); assert.equal(await first.release(), true); const retry = await verifyPlatformWebhook(input); assert.equal(await retry.commit(), true); @@ -207,9 +314,8 @@ test("matches independent official-style insertion-order and sorted signature ve } }); -test("rejects composite and null values absent from the official canonicalization", async () => { +test("rejects composite values absent from the official canonicalization", async () => { for (const [nonce, rawBody] of [ - ["null-value", '{"id":"1","value":null}'], ["array-value", '{"id":"1","value":[1,2]}'], ["object-value", '{"id":"1","value":{"nested":true}}'], ]) { @@ -223,6 +329,65 @@ test("rejects composite and null values absent from the official canonicalizatio } }); +test("accepts null values signed as the official Node example serializes them", async () => { + const rawBody = '{"id":"123456789","status":8,"url":null,"has_audio":false}'; + // Node example: new URLSearchParams(entries.map(([k, v]) => [k, String(v)])) -> url=null + const query = "id=123456789&status=8&url=null&has_audio=false"; + const signature = createHmac("sha256", SIGNING_KEY) + .update(`${TIMESTAMP}\nnull-node\n${query}`) + .digest("base64"); + const verified = await verifyPlatformWebhook({ + rawBody, + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "null-node", "Webhook-Signature": signature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(verified.payload.url, null); + assert.equal(await verified.release(), true); + + // Go url.Values.Encode (sorted, fmt %v -> ) is accepted too. + const goQuery = "has_audio=false&id=123456789&status=8&url=%3Cnil%3E"; + const goSignature = createHmac("sha256", SIGNING_KEY) + .update(`${TIMESTAMP}\nnull-go\n${goQuery}`) + .digest("base64"); + const goVerified = await verifyPlatformWebhook({ + rawBody, + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "null-go", "Webhook-Signature": goSignature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(await goVerified.release(), true); + + // A null cannot be swapped for a different value under the same signature. + await assert.rejects(verifyPlatformWebhook({ + rawBody: '{"id":"123456789","status":8,"url":"null","has_audio":false}', + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "null-swap", "Webhook-Signature": goSignature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }), (error) => error.code === "WEBHOOK_SIGNATURE_INVALID"); +}); + +test("sorted signatures use code-point key order, not locale order", async () => { + // localeCompare sorts "a" before "B"; Go/Java byte order sorts "B" first. + const rawBody = '{"a":"1","B":"2","_c":"3"}'; + const byteOrderQuery = "B=2&_c=3&a=1"; + assert.notEqual(["a", "B", "_c"].sort((l, r) => l.localeCompare(r)).join(), "B,_c,a"); + const signature = createHmac("sha256", SIGNING_KEY) + .update(`${TIMESTAMP}\nbyte-order\n${byteOrderQuery}`) + .digest("base64"); + const verified = await verifyPlatformWebhook({ + rawBody, + headers: { "Webhook-Timestamp": String(TIMESTAMP), "Webhook-Nonce": "byte-order", "Webhook-Signature": signature }, + secret: SIGNING_KEY, + now: () => TIMESTAMP, + nonceStore: createMemoryNonceStore(), + }); + assert.equal(await verified.release(), true); +}); + test("fails closed for invalid replay, clock, and header inputs", async () => { const base = { rawBody: RAW_BODY, secret: SIGNING_KEY, now: () => TIMESTAMP }; await assert.rejects(