From 0b7d7898de6bdd3771a362ed632e0b595d414368 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:06:34 -0400 Subject: [PATCH 1/4] Observe LinkedIn, Threads and Substack MP4 publishing and repair create-response bindings - Graduates linkedin-web media.publish@1 (registered upload, byte transfer, normalized create, permalink readback), meta-web threads media.publish@1, and substack-web media.publish@1 (upload init, ordered S3 multipart, transcode polling, video attachment, exact Note readback) to observed with accepted-target reconciliation. - Repairs the X, Threads, LinkedIn and Substack create-response bindings that drifted from their provider payloads, the Substack Note create body and feed entity projection, and the video attachment readback shape (media_upload_id + nested mediaUpload state). - Fixes derivation network-guard readiness for wildcard domains whose sibling-negative probe previously stayed inside the wildcard subtree. - Stops counting orphaned state-helper coordination artifacts as encrypted session state so a crash-interrupted removal cannot wedge a keyless store; the predicate is shared with read projections. - Updates reviewed contract identity, inventory, package-budget, and capability pins for the new observed routes, retains the shipped substack-web 1.7.0 reader identity, and records the crash counterexample in the property seed corpus. Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...js => imessage-direct-install-xgyjatez.js} | 4 +- dist/{index-pngvnj0q.js => index-hynts08j.js} | 6 +- dist/{index-s52dfzqt.js => index-wszz7b2r.js} | 7 + dist/messaging-automation-api.js | 4 +- ...0y.js => messaging-automation-s68zhpsa.js} | 2 +- ...s => messaging-native-install-efhe2e86.js} | 4 +- ...> whatsapp-automation-runtime-yjz34ncv.js} | 2 +- ...826bc693f68be9c429aaffb23b61e927a22b26.jpg | Bin 0 -> 83198 bytes ...0f29969e0f448818662d81a5be8cba3346da25.jpg | Bin 0 -> 64820 bytes ...ec76eb7450da2222994ecbe048489abc08e22c.jpg | Bin 0 -> 12869 bytes .../post-by-threads-on-threads/capture.json | 105 +++ .../post-by-threads-on-threads.md | 77 ++ scripts/npm-release-workflow.test.ts | 22 +- scripts/package-budget.ts | 21 +- .../social-video-platform-routing.md | 23 +- .../adapters/substack/wrench-web-adapter.json | 2 +- src/derivation-network-guard.test.ts | 14 + src/derivation-network-guard.ts | 2 +- src/plugins/linkedin-web/plugin.ts | 38 +- src/plugins/substack-web/plugin.test.ts | 12 +- src/plugins/substack-web/plugin.ts | 19 +- src/provider-contract-inventory.test.ts | 46 +- src/provider-plugin-contract-identity.ts | 5 +- src/provider-plugin-registry.test.ts | 2 +- src/providers/linkedin-web-feed.ts | 11 +- .../linkedin-web-post-browser.test.ts | 409 ++++++++- src/providers/linkedin-web-post-browser.ts | 277 ++++-- .../linkedin-web-runtime.internal.test.ts | 180 ++++ src/providers/linkedin-web-runtime.ts | 281 +++++- src/providers/linkedin-web.test.ts | 18 +- src/providers/linkedin-web.ts | 128 ++- src/providers/meta-web-runtime.ts | 190 ++++- src/providers/meta-web.ts | 51 +- .../substack-web-runtime.internal.test.ts | 483 ++++++++++- src/providers/substack-web-runtime.ts | 805 ++++++++++++++++-- src/providers/substack-web.test.ts | 31 + src/providers/substack-web.ts | 34 +- src/providers/x-web-runtime.internal.test.ts | 47 +- src/providers/x-web-runtime.ts | 36 +- src/read-projections.ts | 18 +- src/session-secrets.ts | 8 +- src/social-video-capabilities.test.ts | 31 +- src/state-crash-harness.test.ts | 51 +- src/storage.ts | 19 + src/web-session-contract-definitions.ts | 11 +- verification/seeds/corpus.json | 11 + 46 files changed, 3184 insertions(+), 363 deletions(-) rename dist/{imessage-direct-install-rc7txgp5.js => imessage-direct-install-xgyjatez.js} (99%) rename dist/{index-pngvnj0q.js => index-hynts08j.js} (98%) rename dist/{index-s52dfzqt.js => index-wszz7b2r.js} (98%) rename dist/{messaging-automation-tn4gf00y.js => messaging-automation-s68zhpsa.js} (99%) rename dist/{messaging-native-install-a1jy4mse.js => messaging-native-install-efhe2e86.js} (89%) rename dist/{whatsapp-automation-runtime-2v9x3j80.js => whatsapp-automation-runtime-yjz34ncv.js} (99%) create mode 100644 kb/articles/post-by-threads-on-threads/assets/69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26.jpg create mode 100644 kb/articles/post-by-threads-on-threads/assets/9f4dead02f45b686580db309380f29969e0f448818662d81a5be8cba3346da25.jpg create mode 100644 kb/articles/post-by-threads-on-threads/assets/b056d369885d5dceb4c000cd67ec76eb7450da2222994ecbe048489abc08e22c.jpg create mode 100644 kb/articles/post-by-threads-on-threads/capture.json create mode 100644 kb/articles/post-by-threads-on-threads/post-by-threads-on-threads.md diff --git a/dist/imessage-direct-install-rc7txgp5.js b/dist/imessage-direct-install-xgyjatez.js similarity index 99% rename from dist/imessage-direct-install-rc7txgp5.js rename to dist/imessage-direct-install-xgyjatez.js index 36be3eb1..943aaea2 100644 --- a/dist/imessage-direct-install-rc7txgp5.js +++ b/dist/imessage-direct-install-xgyjatez.js @@ -2,11 +2,11 @@ import { ensureImsgNativeResources, verifyImsgNativeResources -} from "./index-pngvnj0q.js"; +} from "./index-hynts08j.js"; import { ensurePrivateStateDirectory, ghostgetStateHome -} from "./index-s52dfzqt.js"; +} from "./index-wszz7b2r.js"; import"./index-6fv50zce.js"; import"./index-5m1wfgkw.js"; import"./index-26yq8q16.js"; diff --git a/dist/index-pngvnj0q.js b/dist/index-hynts08j.js similarity index 98% rename from dist/index-pngvnj0q.js rename to dist/index-hynts08j.js index 05eb9330..fe7344da 100644 --- a/dist/index-pngvnj0q.js +++ b/dist/index-hynts08j.js @@ -1,7 +1,7 @@ // @bun import { ensurePrivateStateDirectory -} from "./index-s52dfzqt.js"; +} from "./index-wszz7b2r.js"; import { __require } from "./index-z1w83f81.js"; @@ -217,11 +217,11 @@ async function installBundledMessagingRuntime(provider, environment = process.en await file.close(); } if (provider === "imessage") { - const { installReviewedImsgBinary } = await import("./imessage-direct-install-rc7txgp5.js"); + const { installReviewedImsgBinary } = await import("./imessage-direct-install-xgyjatez.js"); const result = await installReviewedImsgBinary(path, environment); return { version: result.version, sha256: result.executableSha256, alreadyPresent: result.alreadyPresent }; } - const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime-2v9x3j80.js"); + const { installReviewedWhatsAppAutomationBinary } = await import("./whatsapp-automation-runtime-yjz34ncv.js"); return await installReviewedWhatsAppAutomationBinary(path, environment); } finally { await unlink(path); diff --git a/dist/index-s52dfzqt.js b/dist/index-wszz7b2r.js similarity index 98% rename from dist/index-s52dfzqt.js rename to dist/index-wszz7b2r.js index f25bb6c8..a037cf41 100644 --- a/dist/index-s52dfzqt.js +++ b/dist/index-wszz7b2r.js @@ -1282,6 +1282,13 @@ function ensurePrivateStateDirectory(path, environment = process.env) { } return response.targetIdentity; } +var stateHelperArtifactNamePatterns = Object.freeze([ + /^\.io-write-[1-9][0-9]{0,9}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u, + /^\.io-mutation-[a-f0-9]{64}-(?:waiting|candidate|held)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.lock$/u, + /^\.io-mutation-stage-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}-[1-9][0-9]{0,9}\.tmp$/u, + /^\.io-remove-file-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.quarantine$/u, + /^\.io-remove(?:-tree)?-[1-9][0-9]{0,9}-[1-9][0-9]{0,15}-[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}\.quarantine$/u +]); function snapshotPrivateStateDirectory(path, environment = process.env, expectedTarget, options = {}) { const root = ghostgetStateHome(environment); const identity = ensureClaimedStateRoot(root); diff --git a/dist/messaging-automation-api.js b/dist/messaging-automation-api.js index fc8fa49e..c6b92f29 100644 --- a/dist/messaging-automation-api.js +++ b/dist/messaging-automation-api.js @@ -10,11 +10,11 @@ import { // src/messaging-automation-api.ts async function createMessagingAutomationHost(providers, environment) { - const { MessagingAutomationHost } = await import("./messaging-automation-tn4gf00y.js"); + const { MessagingAutomationHost } = await import("./messaging-automation-s68zhpsa.js"); return new MessagingAutomationHost(providers, environment); } async function installBundledMessagingRuntime(provider, environment) { - return (await import("./messaging-native-install-a1jy4mse.js")).installBundledMessagingRuntime(provider, environment); + return (await import("./messaging-native-install-efhe2e86.js")).installBundledMessagingRuntime(provider, environment); } export { installBundledMessagingRuntime, diff --git a/dist/messaging-automation-tn4gf00y.js b/dist/messaging-automation-s68zhpsa.js similarity index 99% rename from dist/messaging-automation-tn4gf00y.js rename to dist/messaging-automation-s68zhpsa.js index 546f1e89..cb75e89d 100644 --- a/dist/messaging-automation-tn4gf00y.js +++ b/dist/messaging-automation-s68zhpsa.js @@ -22,7 +22,7 @@ import { ensurePrivateStateDirectory, ghostgetStateHome, snapshotPrivateStateDirectory -} from "./index-s52dfzqt.js"; +} from "./index-wszz7b2r.js"; import"./index-6fv50zce.js"; import"./index-5m1wfgkw.js"; import"./index-26yq8q16.js"; diff --git a/dist/messaging-native-install-a1jy4mse.js b/dist/messaging-native-install-efhe2e86.js similarity index 89% rename from dist/messaging-native-install-a1jy4mse.js rename to dist/messaging-native-install-efhe2e86.js index 7da24257..2fd2bfaf 100644 --- a/dist/messaging-native-install-a1jy4mse.js +++ b/dist/messaging-native-install-efhe2e86.js @@ -5,8 +5,8 @@ import { materializeImsgNativeResources, readBundledMessagingAsset, verifyImsgNativeResources -} from "./index-pngvnj0q.js"; -import"./index-s52dfzqt.js"; +} from "./index-hynts08j.js"; +import"./index-wszz7b2r.js"; import"./index-6fv50zce.js"; import"./index-5m1wfgkw.js"; import"./index-26yq8q16.js"; diff --git a/dist/whatsapp-automation-runtime-2v9x3j80.js b/dist/whatsapp-automation-runtime-yjz34ncv.js similarity index 99% rename from dist/whatsapp-automation-runtime-2v9x3j80.js rename to dist/whatsapp-automation-runtime-yjz34ncv.js index 60a97a49..9e1e4add 100644 --- a/dist/whatsapp-automation-runtime-2v9x3j80.js +++ b/dist/whatsapp-automation-runtime-yjz34ncv.js @@ -12,7 +12,7 @@ import { ensurePrivateStateDirectory, ghostgetStateHome, processOwnerStatus -} from "./index-s52dfzqt.js"; +} from "./index-wszz7b2r.js"; import"./index-6fv50zce.js"; import { startProviderPluginCleanupTrackedOperation diff --git a/kb/articles/post-by-threads-on-threads/assets/69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26.jpg b/kb/articles/post-by-threads-on-threads/assets/69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b65a3f468c3122624a7cb67ce60d3b02b0d0a805 GIT binary patch literal 83198 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 H-@^X_B%MJM literal 0 HcmV?d00001 diff --git a/kb/articles/post-by-threads-on-threads/assets/9f4dead02f45b686580db309380f29969e0f448818662d81a5be8cba3346da25.jpg b/kb/articles/post-by-threads-on-threads/assets/9f4dead02f45b686580db309380f29969e0f448818662d81a5be8cba3346da25.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4719cb92607ff26e3f4a0971bd2e2f947fca7932 GIT binary patch literal 64820 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 Zb`wBN5AJxImVmfIQ>DAKm;P_*{{U(DmAU`` literal 0 HcmV?d00001 diff --git a/kb/articles/post-by-threads-on-threads/assets/b056d369885d5dceb4c000cd67ec76eb7450da2222994ecbe048489abc08e22c.jpg b/kb/articles/post-by-threads-on-threads/assets/b056d369885d5dceb4c000cd67ec76eb7450da2222994ecbe048489abc08e22c.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1a00a7d73bed00afd4c4d653bdc47670ebd045fd GIT binary patch literal 12869 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 literal 0 HcmV?d00001 diff --git a/kb/articles/post-by-threads-on-threads/capture.json b/kb/articles/post-by-threads-on-threads/capture.json new file mode 100644 index 00000000..221d0590 --- /dev/null +++ b/kb/articles/post-by-threads-on-threads/capture.json @@ -0,0 +1,105 @@ +{ + "schemaVersion": 4, + "document": { + "path": "post-by-threads-on-threads.md", + "bytes": 1757, + "sha256": "ab30f3617004c8c33698c05920886951d5ca6116d6cbb7501964db3baa25feae" + }, + "sourceUrl": "https://www.threads.com/@hraness", + "canonicalUrl": "https://www.threads.com/@hraness", + "capturedAt": "2026-10-05T14:10:16.833Z", + "platform": "threads", + "status": "complete", + "scope": "page", + "acquisition": { + "method": "browser-fresh", + "finalUrl": "https://www.threads.com/@hraness", + "contentType": "text/html; charset=utf-8" + }, + "extraction": { + "extractor": "defuddle:threads", + "score": 9548, + "wordCount": 223, + "capturedItems": 1, + "expectedItems": null + }, + "attempts": [ + { + "method": "public-api", + "outcome": "skipped", + "message": "no stable public structured adapter" + }, + { + "method": "http", + "outcome": "succeeded", + "message": "complete; 0 words; 1 items" + }, + { + "method": "cookie-http", + "outcome": "succeeded", + "message": "complete; 0 words; 1 items" + }, + { + "method": "browser-fresh", + "outcome": "succeeded", + "message": "complete; 147 words; 1 items" + } + ], + "assets": [ + { + "source": "https://scontent-mia5-1.cdninstagram.com/v/t51.82787-15/834318489_17986965720099423_793010400322550474_n.jpg", + "url": "https://scontent-mia5-1.cdninstagram.com/v/t51.82787-15/834318489_17986965720099423_793010400322550474_n.jpg", + "path": "assets/69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26.jpg", + "mimeType": "image/jpeg", + "bytes": 83198, + "sha256": "69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26" + }, + { + "source": "https://scontent-mia3-1.cdninstagram.com/v/t51.82787-19/779787970_17980344444099423_5647745513502034641_n.jpg", + "url": "https://scontent-mia3-1.cdninstagram.com/v/t51.82787-19/779787970_17980344444099423_5647745513502034641_n.jpg", + "path": "assets/9f4dead02f45b686580db309380f29969e0f448818662d81a5be8cba3346da25.jpg", + "mimeType": "image/jpeg", + "bytes": 64820, + "sha256": "9f4dead02f45b686580db309380f29969e0f448818662d81a5be8cba3346da25" + }, + { + "source": "https://scontent-mia3-1.cdninstagram.com/v/t51.71878-15/830324728_1611951653761384_8875728722481838194_n.jpg", + "url": "https://scontent-mia3-1.cdninstagram.com/v/t51.71878-15/830324728_1611951653761384_8875728722481838194_n.jpg", + "path": "assets/b056d369885d5dceb4c000cd67ec76eb7450da2222994ecbe048489abc08e22c.jpg", + "mimeType": "image/jpeg", + "bytes": 12869, + "sha256": "b056d369885d5dceb4c000cd67ec76eb7450da2222994ecbe048489abc08e22c" + } + ], + "artifacts": { + "images": { + "requested": true, + "status": "captured", + "files": 3 + }, + "media": { + "requested": false, + "status": "not-requested", + "files": 0 + }, + "videoContext": { + "requested": false, + "status": "not-requested", + "thumbnailPath": null, + "transcriptLanguage": null, + "transcriptCueCount": 0, + "transcriptTruncated": false, + "metadata": null + } + }, + "evidence": { + "requested": "none", + "screenshotPath": null, + "screenshotStatus": "not-requested", + "sourceHtmlStatus": "not-requested", + "sourceHtmlPath": null + }, + "warnings": [ + "Seeded explicitly selected cookies into the owned browser without broadening their domain, path, Secure, HttpOnly, SameSite, or expiry attributes." + ] +} diff --git a/kb/articles/post-by-threads-on-threads/post-by-threads-on-threads.md b/kb/articles/post-by-threads-on-threads/post-by-threads-on-threads.md new file mode 100644 index 00000000..95185cc4 --- /dev/null +++ b/kb/articles/post-by-threads-on-threads/post-by-threads-on-threads.md @@ -0,0 +1,77 @@ +--- +title: "Post by @Threads on Threads" +source: "https://www.threads.com/@hraness" +author: "@Threads" +published: "2026-09-30" +description: "hraness" +clipped: "2026-10-05" +platform: "threads" +capture_status: "complete" +capture_method: "browser-fresh" +capture_scope: "page" +--- +# Post by @Threads on Threads + +hraness + +tokenmaxxer + +AI Threads + +Business Threads + +Tech Threads + +AI Threads + +Business Threads + +Tech Threads + +Music Threads + +Food Threads + +Art Threads + +Book Threads + +Photographers of Threads + +Cooking Threads + +Pinned + +Music Threads + +i'm reimagining the DAW + +one-shotted in a couple hours with a bunch of GPT 6.1 Sol Ultra in my software factory + +original track "Valhalla" – handcrafted using my human brain and hands (though i'm cooking on some agentic music composition tools, more on that later) + +sound on 🔊 + +threads image verification probe 2026-10-05 06:55 UTC + +threads image verification probe 2026-10-05 06:50 UTC + +![](assets/b056d369885d5dceb4c000cd67ec76eb7450da2222994ecbe048489abc08e22c.jpg) ![Photo by hraness on October 05, 2026. May be a meme of text.](assets/69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26.jpg) ![Photo by hraness on October 05, 2026. May be a meme of text.](assets/69f72f037fb9b5a96b109cf295826bc693f68be9c429aaffb23b61e927a22b26.jpg) + +> **@hraness** · 2026-09-30 +> +> Pinned +> +> Music Threads +> +> 4d +> +> i'm reimagining the DAW +> +> one-shotted in a couple hours with a bunch of GPT 6.1 Sol Ultra in my software factory +> +> original track "Valhalla" – handcrafted using my human brain and hands (though i'm cooking on some agentic music composition tools, more on that later) +> +> sound on 🔊 + +![Cover image](assets/9f4dead02f45b686580db309380f29969e0f448818662d81a5be8cba3346da25.jpg) diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 33fb9c48..591ee1ad 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -1220,7 +1220,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(25_179_136); + expect(MAX_PACKAGE_TAR_BYTES).toBe(25_233_408); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1430,10 +1430,10 @@ describe("npm publication contract", () => { expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - scope: "0.18.83 engine-aware contained sessions over the invoke --execute release", - npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.2.12", - archiveSha256: "34a0261f74428b18798b6be57ab39bf72e68e45f000a20fa55d6d88272505082", - packedBytes: 12_237_548, unpackedBytes: 24_524_887, entryCount: 638, + scope: "0.18.84 media.publish video lanes and create-response binding repairs", + npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.3.2.1-motley-42c2f19", + archiveSha256: "602e1d625e808b72fd8ed58134adc8434198a6c435ba63d881045069cf6f4870", + packedBytes: 12_259_609, unpackedBytes: 24_579_206, entryCount: 638, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -1449,8 +1449,8 @@ describe("npm publication contract", () => { expect(budget).toContain("24,024,705 + 353 + 65 = 24,025,123 unpacked"); expect(budget).toContain("12,152,562 + 12,387 + 4,096 = 12,169,045 packed"); expect(budget).toContain("24,093,786 + 353 + 65 = 24,094,204 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_254_031); - expect(MAX_PACKED_BYTES).toBe(12_237_548 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_276_092); + expect(MAX_PACKED_BYTES).toBe(12_259_609 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -1571,7 +1571,7 @@ describe("npm publication contract", () => { expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); expect(budget).toContain("23,688,277 + 353 + 65 = 23,688,695"); expect(budget).toContain("23,759,283 + 353 + 65 = 23,759,701"); - expect(MAX_UNPACKED_BYTES).toBe(24_525_305); + expect(MAX_UNPACKED_BYTES).toBe(24_579_624); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1604,7 +1604,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(24_524_887 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(24_579_206 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -1640,8 +1640,8 @@ describe("npm publication contract", () => { expect(packageArtifactBudget).toEqual({ entryCount: { min: 638, max: 638 }, fileCount: { min: 638, max: 638 }, - packedBytes: { min: 1_600_000, max: 12_254_031 }, - unpackedBytes: { min: 9_000_000, max: 24_525_305 }, + packedBytes: { min: 1_600_000, max: 12_276_092 }, + unpackedBytes: { min: 9_000_000, max: 24_579_624 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 4de2d007..c1539e21 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2500,16 +2500,27 @@ // 34a0261f74428b18798b6be57ab39bf72e68e45f000a20fa55d6d88272505082. Retain the // reviewed allowances: 12,237,548 + 12,387 + 4,096 = 12,254,031 packed; // 24,524,887 + 353 + 65 = 24,525,305 unpacked. +// The 0.18.84 release adds the Substack, LinkedIn and Threads video +// media.publish lanes, their accepted-target reconciliations, the X, +// Threads, LinkedIn and Substack create-response binding repairs and the +// derivation network-guard wildcard readiness repair over the reviewed +// 0.18.83 release. Two clean npm 11.19.0 packs on Node 24.20.0 +// (zlib 1.3.2.1-motley-42c2f19) with --ignore-scripts on darwin arm64 were +// byte-identical at 638 files/entries, 12,259,609 packed bytes and +// 24,579,206 unpacked bytes; archive SHA-256 +// 602e1d625e808b72fd8ed58134adc8434198a6c435ba63d881045069cf6f4870. Retain the +// reviewed allowances: 12,259,609 + 12,387 + 4,096 = 12,276,092 packed; +// 24,579,206 + 353 + 65 = 24,579,624 unpacked. export const repairPackageMeasurement = Object.freeze({ - scope: "0.18.83 engine-aware contained sessions over the invoke --execute release", + scope: "0.18.84 media.publish video lanes and create-response binding repairs", command: "npm pack --ignore-scripts", npmVersion: "11.19.0", nodeVersion: "24.20.0", - zlibVersion: "1.2.12", + zlibVersion: "1.3.2.1-motley-42c2f19", platform: "darwin-arm64", - archiveSha256: "34a0261f74428b18798b6be57ab39bf72e68e45f000a20fa55d6d88272505082", - packedBytes: 12_237_548, - unpackedBytes: 24_524_887, + archiveSha256: "602e1d625e808b72fd8ed58134adc8434198a6c435ba63d881045069cf6f4870", + packedBytes: 12_259_609, + unpackedBytes: 24_579_206, entryCount: 638, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, diff --git a/skills/ghostget/references/social-video-platform-routing.md b/skills/ghostget/references/social-video-platform-routing.md index 2fe79d96..7f515507 100644 --- a/skills/ghostget/references/social-video-platform-routing.md +++ b/skills/ghostget/references/social-video-platform-routing.md @@ -9,7 +9,7 @@ are authoritative and may change. | X | `x-web` | `posts.publish` when the installed schema accepts `video/mp4` | Consumer X post. Observed `x-web` `posts.publish` now accepts one plan-bound PNG or one MP4. | | X | `x` | `posts.publish` when the installed schema accepts `video/mp4` | Official OAuth post. Already schemas MP4. | | LinkedIn | `linkedin` | video-capable `posts.publish` | Observed official OAuth member or explicitly bound organization post. | -| LinkedIn | `linkedin-web` | `media.publish` when observed | Consumer-web member video; currently a separate capture-required route so image `posts.publish` remains unchanged. | +| LinkedIn | `linkedin-web` | `media.publish` | Observed consumer-web member video post; image `posts.publish` remains a separate observed contract. | | Bluesky | `bluesky-web` | `media.publish` | Observed AT Protocol video feed post; image `posts.publish` remains a separate observed contract. | | Substack | `substack-web` | `media.publish` when observed | Public Substack Note, not an article or newsletter. | | TikTok | `tiktok-web` | `media.publish` when observed | Native TikTok video | @@ -28,19 +28,18 @@ single-request video upload, durable created-post identity, and independent permalink actor/text/video readback. Bluesky `media.publish@2` observes one plan-bound ISO BMFF MP4, the fixed first-party legacy upload and response-bound processing job, processed blob, exact repository record, durable accepted -target, and authoritative PDS plus public AppView readbacks. LinkedIn web, -Substack Notes, TikTok, Instagram, and YouTube expose bounded video `media.publish` +target, and authoritative PDS plus public AppView readbacks. LinkedIn web +`media.publish@1` observes one plan-bound MP4, the member video upload +registration and byte transfer, the normalized create response, and an exact +permalink readback. Substack `media.publish@1` observes one plan-bound MP4, +authenticated upload initialization, ordered S3 multipart transfer, transcode +and status polling, attachment binding, exact Note create, and independent +readback of the bound `media_upload_id` and transcoded `mediaUpload` state. +TikTok, Instagram, and YouTube expose bounded video `media.publish` reservations. Those routes stay `capture-required` until their exact upload, processing, request, response, actor/target, and independent readback contracts -are implemented and proven. -For Substack specifically, an authorized profile-backed fixture proved 200 -initialization, ordered raw multipart transfer, transcode, status polling, and -video-attachment creation. The final Note create returned 403 twice, including -one GhostGet-native attempt, so there is no provider-created video Note or exact -readback to graduate. `content.delete@1` is independently observed for cleanup -of an exact authored personal Note; that does not imply video publication. -Treat them as unavailable until the installed capability independently says -`observed`. +are implemented and proven. Treat them as unavailable until the installed +capability independently says `observed`. ## Selection rules diff --git a/src/assets/adapters/substack/wrench-web-adapter.json b/src/assets/adapters/substack/wrench-web-adapter.json index 6898fa7e..c15f7cb5 100644 --- a/src/assets/adapters/substack/wrench-web-adapter.json +++ b/src/assets/adapters/substack/wrench-web-adapter.json @@ -175,7 +175,7 @@ } }, "media.publish": { - "description": "Capture-required contract reservation: publish one confirmed Substack Note with one MP4 only after exact upload initialization, byte transfer, processing, viewer binding, public Note creation, returned-entity attachment binding, and independent exact-Note readback are reviewed; this is not article or newsletter publication.", + "description": "Observed contract: publish one confirmed Substack Note with one exact MP4 after reviewed upload initialization, ordered presigned byte transfer, transcode and processing binding, viewer-bound video attachment, durable accepted-Note targeting, and an independent exact Note readback.", "risk": "R3", "sideEffect": "Uploads one video and publishes one externally visible Substack Note with the exact confirmed body.", "idempotency": "local-at-most-once", diff --git a/src/derivation-network-guard.test.ts b/src/derivation-network-guard.test.ts index c80ed452..8ff44a03 100644 --- a/src/derivation-network-guard.test.ts +++ b/src/derivation-network-guard.test.ts @@ -112,6 +112,20 @@ describe("contained derivation MV3 guard", () => { ]) expect(wildcardRegex.test(value)).toBeFalse(); }); + test("sibling-negative readiness cases stay outside wildcard subtrees", () => { + const domains = ["studio.example.com", "*.upload.example.com"]; + const script = derivationGuardExtensionFiles(domains)["readiness.js"] ?? ""; + // A wildcard's sibling-negative must not remain inside the allowed subtree, + // or the block expectation can never hold and readiness can never pass. + expect(script).toContain('"url":"https://not-upload.example.com/wrench-check"'); + expect(script).not.toContain("not-wrench-check.upload.example.com"); + const allowRegex = new RegExp( + derivationGuardRules(domains)[2]?.condition.regexFilter ?? "", + "iu", + ); + expect(allowRegex.test("https://not-upload.example.com/wrench-check")).toBeFalse(); + }); + test("binds exact private extension identities and detects byte, mode, and inode drift", async () => { const root = mkdtempSync(join(tmpdir(), "wrench-guard-files-test-")); chmodSync(root, 0o700); diff --git a/src/derivation-network-guard.ts b/src/derivation-network-guard.ts index f1edb546..4535c412 100644 --- a/src/derivation-network-guard.ts +++ b/src/derivation-network-guard.ts @@ -192,7 +192,7 @@ function runtimeCases(browserDomains: readonly string[]): readonly GuardRuntimeC { label: `block-${index}-ws`, url: `ws://${host}/wrench-check`, type: "websocket", ruleId: 1 }, { label: `block-${index}-trailing-dot`, url: `https://${host}./wrench-check`, type: "main_frame", ruleId: 1 }, { label: `block-${index}-suffix`, url: `https://${host}.invalid/wrench-check`, type: "main_frame", ruleId: 1 }, - { label: `block-${index}-sibling`, url: `https://not-${host}/wrench-check`, type: "main_frame", ruleId: 1 }, + { label: `block-${index}-sibling`, url: `https://not-${base}/wrench-check`, type: "main_frame", ruleId: 1 }, ); if (domain.startsWith("*.")) { cases.push({ diff --git a/src/plugins/linkedin-web/plugin.ts b/src/plugins/linkedin-web/plugin.ts index 2e669c4d..a950abed 100644 --- a/src/plugins/linkedin-web/plugin.ts +++ b/src/plugins/linkedin-web/plugin.ts @@ -154,6 +154,23 @@ function linkedinPostIssues( return Object.freeze([]); } +function linkedinMediaPublishIssues( + input: Readonly>, +): readonly string[] { + const issues: string[] = []; + if ( + input.title !== undefined + && (typeof input.title !== "string" + || input.title.length < 1 + || input.title.length > 200 + || /[\0\r\n]/u.test(input.title)) + ) issues.push("input.title must be one bounded plain-text video title of 1-200 characters"); + if (input.alt_text !== undefined) { + issues.push("input.alt_text is not supported; LinkedIn video posts carry input.title"); + } + return Object.freeze(issues); +} + function linkedinCommentPostIssues( input: Readonly>, ): readonly string[] { @@ -221,7 +238,7 @@ function linkedinArticleDraftV2Dispatches( const currentOperations = webSessionContractOperations( Object.values(linkedinContracts), - "4e05b18690ed8561f50cc7ad6678c5dacfa72784d6afdccc5c234139b6b6db86", + "938952b892b1263cc73dd1398cbf716c3727e55997f222d40eaa84e18e59cb36", { "posts.publish": [2], "feeds.read": [2], @@ -241,6 +258,10 @@ const currentOperations = webSessionContractOperations( id: "posts.publish", description: "Publish one externally visible LinkedIn post with the exact confirmed audience and content.", })]), + "media.publish": () => Object.freeze([Object.freeze({ + id: "media.publish", + description: "Upload one exact MP4 and publish one externally visible LinkedIn video post with the exact confirmed audience and commentary.", + })]), "comments.create": () => Object.freeze([Object.freeze({ id: "comments.create", description: "Publish one externally visible comment on the exact confirmed LinkedIn post.", @@ -261,6 +282,15 @@ const currentOperations = webSessionContractOperations( }), }); } + if (operation.name === "media.publish") { + return Object.freeze({ + ...operation, + validateInput: linkedinMediaPublishIssues, + reconciliation: Object.freeze({ + kind: "provider-accepted-target-presence" as const, + }), + }); + } if (operation.name === "comments.create") { return Object.freeze({ ...operation, @@ -446,14 +476,14 @@ export const linkedinWebPlugin = defineProviderPlugin({ execute: (_manifest, recipe, input, auth, options) => runtime.executeLinkedInWebOperation(recipe, input, auth, options), reconcile: async (operation, input, auth, context) => { - if (operation === "posts.publish") { + if (operation === "posts.publish" || operation === "media.publish") { if (context?.kind !== "provider-accepted-target-presence") { - throw new Error("LinkedIn posts.publish reconciliation requires one exact accepted target"); + throw new Error(`LinkedIn ${operation} reconciliation requires one exact accepted target`); } const readback = await runtime.readLinkedInWebAcceptedPostTargetPresence({ site: "linkedin", action: operation, - contractVersion: 3, + contractVersion: operation === "media.publish" ? 1 : 3, timeoutMs: 60_000, maxOutputBytes: 2 * 1024 * 1024, }, input, auth, context.target.identifier); diff --git a/src/plugins/substack-web/plugin.test.ts b/src/plugins/substack-web/plugin.test.ts index 0760a9f9..0a8e1977 100644 --- a/src/plugins/substack-web/plugin.test.ts +++ b/src/plugins/substack-web/plugin.test.ts @@ -100,8 +100,8 @@ describe("Substack web provider plugin", () => { expect(originalReceipt).toBe("f0a20609b1d0a0c902b52bdb99483a4115a1f2ad61c40d797e45e92f803bc796"); const identity = reviewedBuiltInContractIdentity("substack-web", "1.7.0"); const predecessors = identity.legacyDistributionReadImplementationSha256 ?? []; - expect(predecessors).toHaveLength(12); - expect(predecessors[0]?.implementationSha256).toBe("62507025edf4836d122027832fc2f49d04e6331487c40e10dbe5f8a1fa6ae797"); + expect(predecessors).toHaveLength(13); + expect(predecessors[0]?.implementationSha256).toBe("1ff335a9f8d2f4d071dfa0c197bd2fcf7d5c59884e815db230a357891e02080f"); const subscriberV2 = new Set(["subscribers.export", "subscribers.import", "subscribers.import.status"]); for (const distribution of predecessors) { for (const operation of registered.operations) { @@ -153,15 +153,17 @@ describe("Substack web provider plugin", () => { expect(binding.reconcile).toBeFunction(); }); - test("keeps video inert while graduating exact personal-Note deletion", () => { + test("graduates exact MP4 Note publication and personal-Note deletion", () => { const video = binding.operations.find((candidate) => candidate.name === "media.publish"); expect(video).toMatchObject({ contractVersion: 1, risk: "R3", - state: "capture-required", + state: "observed", dispatch: "single", + reconciliation: { + kind: "provider-accepted-target-presence", + }, }); - expect(video).not.toHaveProperty("reconciliation"); const deletion = binding.operations.find((candidate) => candidate.name === "content.delete"); expect(deletion).toMatchObject({ contractVersion: 1, diff --git a/src/plugins/substack-web/plugin.ts b/src/plugins/substack-web/plugin.ts index 4bfcac27..e6a2f36d 100644 --- a/src/plugins/substack-web/plugin.ts +++ b/src/plugins/substack-web/plugin.ts @@ -86,7 +86,7 @@ export const substackWebPlugin = defineProviderPlugin({ authKinds: browserSessionAuthKinds, operations: [...webSessionContractOperations( Object.values(substackContracts), - "094ccfba4f96ee1deed26201cab94154b8471f4f80db5d6e273c4f4117601427", + "44899d0fb82aa7063b171818934f73e5cf3414d4bbc177f833a9e5e6fd22b633", { "posts.publish": [2], }, @@ -106,6 +106,7 @@ export const substackWebPlugin = defineProviderPlugin({ ).map((operation) => { if ( operation.name === "posts.publish" + || operation.name === "media.publish" || operation.name === "comments.create" || operation.name === "replies.create" ) { @@ -167,6 +168,22 @@ export const substackWebPlugin = defineProviderPlugin({ reason: "exact-target-readback", }; } + if (operation === "media.publish") { + if (context?.kind !== "provider-accepted-target-presence") { + throw new Error("Substack media.publish reconciliation requires one exact accepted target"); + } + const readback = await runtime.readSubstackWebAcceptedNoteTargetPresence({ + site: "substack", + action: operation, + contractVersion: 1, + timeoutMs: 60_000, + maxOutputBytes: 8 * 1024 * 1024, + }, input, auth, context.target.identifier); + return { + actualState: readback.present, + reason: "exact-target-readback", + }; + } if (operation !== "posts.publish") { throw new Error(`Substack ${operation} has no reconciliation hook`); } diff --git a/src/provider-contract-inventory.test.ts b/src/provider-contract-inventory.test.ts index b7cd8072..ddd2caa1 100644 --- a/src/provider-contract-inventory.test.ts +++ b/src/provider-contract-inventory.test.ts @@ -4,29 +4,29 @@ import { pathToFileURL } from "node:url"; import { describe, expect, test } from "bun:test"; const predecessorDefaultInventorySha256 = - "d6539497472d2d7f6c0ddb36f8c8709c6ac888c0d3e29da81d7f282d414f5692"; + "250f23bb4ddbb36712a8a6c63e577b67ee70873d8c4d857425b3e1ab6d1a6ab0"; const predecessorLegacyInventorySha256 = [ - "147bf2b55528b929c9ae5eac3c05c07d0ac62064d76b5e55ec3a4d2701e4cd5e", - "f9a8a10c4f65fa9f5c9f67e331f200c75038dd9f77be2ba855ab97c2a1063df7", - "3525acffd2dad94b5148c3b388aa2802bf285ebbdb30b535ace61fdfe4d73787", - "7c81dcc4311c85e4711fdcc7823d2c3dfc1e1fd587c980ac14416208790538bf", - "997fb00e2a04cc4c074e5daa6cf13885253c2f5ee13019e1f35342ae8f1929f3", - "6a0626e2a328bae41d20049a049fc4dd65bf2e651ddef1c8b4c6f22bbd4cc1f2", - "92bad323398e551244f5df4016b5ff9827958cb9a6ba91dbca9e20d962aab40a", - "d186bf8efc1d1bec541dc1ac86a4fabd6dc60dfdbaab45cb511ba514c494b96c", - "c4517f45bbde70f43c54748a88fa3622800a62c9da69d18524d8398c06cf1639", - "4af16ed6a95faaa50ae356e969a9d3d33eb01f0600f12e9055a188784b5a2dba", - "1b761fbec7c29383071ddde4201b7f96b29a4afc77de4d8fc8beff62aff3ad9e", - "2113e8c8183519f89a131ffa07c06e6f3c614788537e350a0d9fff1093c423d1", - "1bcab0bb7fb40aaa0b66635a01eb9f661b4fb5b06c4a8f08a1568c9a176c80ca", - "89b8e7d0019d1d08bb3cb754a64d2aeed38f063d53fe582163dab0e061d5dda3", - "3772f3ebe8c8f98044f28fda6cf5674fb095a5c4927c99d1d99d73f0faebe7c3", - "67ab1b33bb2ccfa73d4278aa44b65b0f7de5a830865e3c1708bf5497196dad92", - "4e5585d98c282a7d1b9c12e2f1ee07bb21453b4ee42f865a8a3feb67d0712a9e", - "934d2a3a386716ce1bc1a23f32c5e28e0121c9052be37413282ff5ed66959b46", - "d3d7e9d7bfab55d5065848dad77b6f8a8b3f89a7c0e9307d847ed6bd35a3386d", - "ea44b331fd032b3ce00d78f1567c94c112d5ea159ad2905e96595d5149235c34", - "70b98b1f795a88425f4e2b782a167413df4fdf844036d1ecc968575d5e4e76a8", + "dae91740ee05d34b1911b58ed72da21124386e081c9535f90deaeb9202e7cf15", + "beaa0b64b0cafc4b731d17b58127545916142a7f4f2f99a1ee03b6b0d42a7de2", + "d9987c035f0af84b7710219d9b1fd0a3dbf1a1d825be501bb4f6df05e8147ef1", + "6eef4aad8c3f968323a0c350f5d47e6f828d0a258854de4a5f58ebc1f89d1d96", + "42a9305b3c39fba9db3173194e77d9784fb31cbe84711c69e449bfc3710ab28f", + "9aa1613307bd83142ebd44a67662fa710322d0e16e28e7123a0ef1f0b0c65abc", + "f9bdcbfb6c91b41dcf854084eaf4df735539a6336ad4493694f3497a90e28f0d", + "50486a32717db744ad431c4c4f12e6c85fb58bee98dfac00a861a6427aca9c51", + "24d36e66e7ed5e789c5c241387a111a64898947b992648422718282d5bbbd970", + "629d4eff2221796c0910559a3c518b35075d2cd274923895e1292c6491ed40f5", + "54b7a91a1cbf09403ccc761771722b2098128dcef5d65f80dd961f8330679ad5", + "270b5c7402ea3f3ec2ea722242da471c7434606c57dbf55f3ccc283ba90f0dbe", + "de3736679b42c786a14addac94fa1bec815b51999e3eac2469eb9428c61a803d", + "10e67fd19f6e7e2461e602594c42677855e271129b8d51bb670f76140f68d8ad", + "1ce3777afbb67877439f3620cab8c3244a046815bdd45460033ea73ea5117606", + "4bb4b3d77fe4b963476bdc9e93af48a286903089ed94a632df2e2e740e14954b", + "960160ad499c088b89718cfa9c32e3aac8e8a68e37ed3016f9d8faa2d4c080bd", + "4d61e63bd5980c7dcaf9fdff7409889dc305911dac238cfa7bf2218409167227", + "c9a3c4bc07caee4cef22d0bdcd95074b68a00b24bc8550aacdf01ff24646bae9", + "c10e8d8c5ca8c67efd34c3113a4d6b81b78bd37fbc3a02b883a0017c8b122401", + "5e1ac92c9b07fbe4bfeeb7a86ae1a7442ab74acaa6ac4608503ff9c950c66c52", ]; const moduleUrl = (name: string) => pathToFileURL( @@ -375,7 +375,7 @@ describe("durable provider contract inventory", () => { rows: 325, sha256: predecessorDefaultInventorySha256, currentOnlyRows: 87, - currentOnlySha256: "a8ff6d99b948861084dc3a88ee4ad7d5f157d5f6bed10dc5a56c2a6f60bab8d1", + currentOnlySha256: "944efbe9059debc0b55cfe1cad9c793114e8f493cb74ba4ea7a3c9f1c68f4537", automationRows: [ ["linked-device", "whatsapp", "messaging.automation.read", 1], ["linked-device", "whatsapp", "messaging.automation.send.attachment", 1], diff --git a/src/provider-plugin-contract-identity.ts b/src/provider-plugin-contract-identity.ts index 69690f3d..81c8c935 100644 --- a/src/provider-plugin-contract-identity.ts +++ b/src/provider-plugin-contract-identity.ts @@ -440,11 +440,14 @@ const identities = Object.freeze({ "substack-web": { schemaVersion: 1, pluginVersion: "1.7.0", - implementationSha256: "1ff335a9f8d2f4d071dfa0c197bd2fcf7d5c59884e815db230a357891e02080f", + implementationSha256: "9c4d5aeae3f09242cf4c656f1b7eb5eacfed9c06e30e5786e0c8c3adff84638d", legacyCurrentReadImplementationSha256: [], + // The 0.18.x line shipped 1ff335a9 before media.publish was observed; + // retain it as a predecessor reader for exactly the routes it owned. // 1.5.0 read every route below plus subscribers.export@2, whose paging, // cursor, and completeness semantics changed in place before any release. legacyDistributionReadImplementationSha256: [ + "1ff335a9f8d2f4d071dfa0c197bd2fcf7d5c59884e815db230a357891e02080f", "62507025edf4836d122027832fc2f49d04e6331487c40e10dbe5f8a1fa6ae797", "7e12bbfffb9629b9195b98fc42394d6c5163ae3e2efa8063a1086ff1cc3dde1b", "58438f60cf9b2d2db9363cb7dece0c6ca56e60c2178fe4bcbd60c844fc8893ba", diff --git a/src/provider-plugin-registry.test.ts b/src/provider-plugin-registry.test.ts index a33acb2d..3abe0f39 100644 --- a/src/provider-plugin-registry.test.ts +++ b/src/provider-plugin-registry.test.ts @@ -581,7 +581,7 @@ describe("provider plugin definition and registry", () => { id: "substack-web", surface: "substack", version: "1.7.0", - current: "1ff335a9f8d2f4d071dfa0c197bd2fcf7d5c59884e815db230a357891e02080f", + current: "9c4d5aeae3f09242cf4c656f1b7eb5eacfed9c06e30e5786e0c8c3adff84638d", prior: "62507025edf4836d122027832fc2f49d04e6331487c40e10dbe5f8a1fa6ae797", operation: "profiles.read", contractVersion: 1, diff --git a/src/providers/linkedin-web-feed.ts b/src/providers/linkedin-web-feed.ts index defe492d..57849679 100644 --- a/src/providers/linkedin-web-feed.ts +++ b/src/providers/linkedin-web-feed.ts @@ -625,7 +625,7 @@ function collectionResponses( const collections: LinkedInWebJsonRecord[] = []; if ( candidate.$type === COLLECTION_TYPE - && Array.isArray(candidate["*elements"]) + && (Array.isArray(candidate["*elements"]) || Array.isArray(candidate.elements)) ) collections.push(candidate); for (const nested of Object.values(candidate)) { collections.push(...collectionResponses(nested, depth + 1, seen)); @@ -774,7 +774,9 @@ export function projectLinkedInProfileActivityPage(input: { throw new Error("LinkedIn profile-activity response did not bind one collection"); } const collection = collections[0]!; - const references = collection["*elements"]; + const references = Array.isArray(collection["*elements"]) + ? collection["*elements"] + : collection.elements; if (!Array.isArray(references) || references.length > LINKEDIN_PROFILE_ACTIVITY_MAX_ITEMS) { throw new Error("LinkedIn profile-activity collection exceeded its reviewed bound"); } @@ -783,6 +785,11 @@ export function projectLinkedInProfileActivityPage(input: { } const counts = socialCountsByActivityUrn(normalized.included); const posts = references.map((reference, index) => { + // `*elements` carries URN references resolved through `included`; the + // `elements` encoding may inline the Update entity directly. + if (isRecord(reference)) { + return projectUpdate(reference, counts); + } const urn = boundedText(reference, `LinkedIn profile-activity elements[${index}]`, 4_096); const entity = normalized.entitiesByUrn.get(urn); if (entity === undefined) { diff --git a/src/providers/linkedin-web-post-browser.test.ts b/src/providers/linkedin-web-post-browser.test.ts index ba9a9e60..f0a0c6de 100644 --- a/src/providers/linkedin-web-post-browser.test.ts +++ b/src/providers/linkedin-web-post-browser.test.ts @@ -94,6 +94,9 @@ describe("LinkedIn native post contained-browser transport", () => { ]); return Promise.resolve([{ success: true, result: { opened: true } }]); } + if (command?.[0] === "wait") { + return Promise.resolve([{ success: true, result: { waited: true } }]); + } if (command?.[0] === "network") return Promise.resolve([pageBindingRecord()]); if (commands.some((candidate) => candidate[0] !== "eval" || candidate[1] === undefined)) { throw new Error("unexpected LinkedIn post browser command"); @@ -137,7 +140,7 @@ describe("LinkedIn native post contained-browser transport", () => { entityUrn: "urn:li:fsd_share:7000000000000000000", })]); } - if (sources[0]?.includes("const readbackPath=")) { + if (sources[0]?.includes("LinkedIn post readback permalink")) { expect(sources).toHaveLength(1); readbacks += 1; return Promise.resolve([browserRecord({ read: true })]); @@ -310,4 +313,408 @@ describe("LinkedIn native post contained-browser transport", () => { } await transport.close(); }); + + test("executes create and readback sources against the observed normalized-included and permalink shapes", async () => { + const postText = "observed create & permalink shapes"; + const activityId = "7512910091130421248"; + const entityUrn = `urn:li:fsd_update:(urn:li:activity:${activityId},FEED_DETAIL,EMPTY,DEFAULT,false)`; + const permalink = `https://www.linkedin.com/feed/update/urn:li:activity:${activityId}/`; + const profileUrn = "urn:li:fsd_profile:ACoAAExactCurrentProfile"; + const mediaUrn = "urn:li:digitalmediaAsset:C4D22AQExactImage"; + const identityBody = { + data: { + plainId: "123456789", + "*miniProfile": "urn:li:fs_miniProfile:ACoAAExactCurrentProfile", + }, + included: [{ + entityUrn: "urn:li:fs_miniProfile:ACoAAExactCurrentProfile", + publicIdentifier: "hraness", + firstName: "Ben", + lastName: "Guo", + }], + }; + const createBody = { + data: {}, + included: [ + { + $type: "com.linkedin.voyager.dash.feed.Update", + entityUrn, + }, + { + $type: "com.linkedin.voyager.dash.contentcreation.Share", + entityUrn: "urn:li:fsd_share:7512910091130421248", + status: { + lifecycleState: { + UnpublishedState: null, + "*PublishedState": entityUrn, + }, + }, + }, + ], + }; + const permalinkHtml = [ + ``, + `"entityUrn":"urn:li:activity:${activityId}"`, + `"commentary":{"text":"${postText.replace(/&/u, "&")}"}`, + `href="https://www.linkedin.com/in/hraness/"`, + `Ben Guo`, + mediaUrn, + ``, + ].join(""); + + const globals = globalThis as Record; + const priorFetch = globals.fetch; + const priorLocation = globals.location; + const priorDocument = globals.document; + const jsonResponse = (body: unknown) => ({ + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" + ? "application/vnd.linkedin.normalized+json+2.1" + : null, + }, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); + globals.location = { origin: "https://www.linkedin.com" }; + globals.document = { cookie: 'JSESSIONID="ajax:test-csrf-token"' }; + globals.fetch = ((url: unknown, init: unknown) => { + if ( + init !== null + && typeof init === "object" + && (init as { method?: string }).method === "POST" + ) { + return Promise.resolve(jsonResponse(createBody)); + } + if (url === "/voyager/api/me") return Promise.resolve(jsonResponse(identityBody)); + if (url === permalink) { + return Promise.resolve({ + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" ? "text/html" : null, + }, + status: 200, + text: () => Promise.resolve(permalinkHtml), + }); + } + return Promise.reject(new Error(`unexpected fetch ${String(url)}`)); + }) as typeof fetch; + + let createdUrn = ""; + let read: Readonly> | null = null; + const session: BrowserSession = { + runBatch: (commands) => { + const command = commands[0]; + if (command?.[0] === "open" || command?.[0] === "wait") { + return Promise.resolve([{ success: true, result: { ok: true } }]); + } + if (command?.[0] === "network") return Promise.resolve([pageBindingRecord()]); + const source = command?.[1] ?? ""; + if ( + !source.includes("const createPath=") + && !source.includes("LinkedIn post readback permalink") + ) { + throw new Error("unexpected LinkedIn evaluation source"); + } + return (async () => { + const value = await (new Function(`return ${source}`)() as Promise); + return [browserRecord(value as Readonly>)]; + })(); + }, + close: () => Promise.resolve(), + cleanup: () => Promise.resolve(), + }; + + try { + const transport = await createLinkedInPostBrowserTransport(auth, { + timeoutMs: 10_000, + dependencies: { createBrowserSession: () => Promise.resolve(session) }, + }); + const variables = { post: { commentary: { text: postText } } }; + createdUrn = await transport.createPost( + auth.subject, + profileUrn, + variables, + mediaUrn, + ); + read = await transport.readPost( + auth.subject, + profileUrn, + variables, + mediaUrn, + createdUrn, + ) as Readonly>; + await transport.close(); + } finally { + globals.fetch = priorFetch as typeof fetch; + if (priorLocation === undefined) delete globals.location; + else globals.location = priorLocation; + if (priorDocument === undefined) delete globals.document; + else globals.document = priorDocument; + } + + expect(createdUrn).toBe(entityUrn); + expect(read).toMatchObject({ + entityMatched: true, + actorMatched: true, + textMatched: true, + mediaMatched: true, + mediaUrn, + lifecycle: "PUBLISHED", + url: permalink, + }); + }); + + test("binds the observed share-only MEDIA_PROCESSING video create response and ugcPost permalink readback", async () => { + const postText = "observed video create & permalink shapes"; + const ugcPostId = "7512923399980044289"; + const entityUrn = `urn:li:fsd_share:urn:li:ugcPost:${ugcPostId}`; + const permalink = `https://www.linkedin.com/feed/update/urn:li:ugcPost:${ugcPostId}/`; + const profileUrn = "urn:li:fsd_profile:ACoAAExactCurrentProfile"; + const mediaUrn = "urn:li:fsd_video:C4D22AQExactVideo"; + const identityBody = { + data: { + plainId: "123456789", + "*miniProfile": "urn:li:fs_miniProfile:ACoAAExactCurrentProfile", + }, + included: [{ + entityUrn: "urn:li:fs_miniProfile:ACoAAExactCurrentProfile", + publicIdentifier: "hraness", + firstName: "Ben", + lastName: "Guo", + }], + }; + const createBody = { + data: {}, + included: [ + { + $type: "com.linkedin.voyager.dash.contentcreation.Share", + entityUrn, + status: { + lifecycleState: { + PublishedState: null, + UnpublishedState: "MEDIA_PROCESSING", + }, + }, + }, + ], + }; + const permalinkHtml = [ + ``, + `"entityUrn":"urn:li:ugcPost:${ugcPostId}"`, + `"commentary":{"text":"${postText}"}`, + `href="https://www.linkedin.com/in/hraness/"`, + `Ben Guo`, + mediaUrn, + ``, + ].join(""); + + const globals = globalThis as Record; + const priorFetch = globals.fetch; + const priorLocation = globals.location; + const priorDocument = globals.document; + const jsonResponse = (body: unknown) => ({ + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" + ? "application/vnd.linkedin.normalized+json+2.1" + : null, + }, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); + globals.location = { origin: "https://www.linkedin.com" }; + globals.document = { cookie: 'JSESSIONID="ajax:test-csrf-token"' }; + globals.fetch = ((url: unknown, init: unknown) => { + if ( + init !== null + && typeof init === "object" + && (init as { method?: string }).method === "POST" + ) { + return Promise.resolve(jsonResponse(createBody)); + } + if (url === "/voyager/api/me") return Promise.resolve(jsonResponse(identityBody)); + if (url === permalink) { + return Promise.resolve({ + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" ? "text/html" : null, + }, + status: 200, + text: () => Promise.resolve(permalinkHtml), + }); + } + return Promise.reject(new Error(`unexpected fetch ${String(url)}`)); + }) as typeof fetch; + + let createdUrn = ""; + let read: Readonly> | null = null; + const session: BrowserSession = { + runBatch: (commands) => { + const command = commands[0]; + if (command?.[0] === "open" || command?.[0] === "wait") { + return Promise.resolve([{ success: true, result: { ok: true } }]); + } + if (command?.[0] === "network") return Promise.resolve([pageBindingRecord()]); + const source = command?.[1] ?? ""; + if ( + !source.includes("const createPath=") + && !source.includes("LinkedIn post readback permalink") + ) { + throw new Error("unexpected LinkedIn evaluation source"); + } + return (async () => { + const value = await (new Function(`return ${source}`)() as Promise); + return [browserRecord(value as Readonly>)]; + })(); + }, + close: () => Promise.resolve(), + cleanup: () => Promise.resolve(), + }; + + try { + const transport = await createLinkedInPostBrowserTransport(auth, { + timeoutMs: 10_000, + dependencies: { createBrowserSession: () => Promise.resolve(session) }, + }); + const variables = { post: { commentary: { text: postText } } }; + createdUrn = await transport.createPost( + auth.subject, + profileUrn, + variables, + mediaUrn, + "VIDEO", + ); + read = await transport.readPost( + auth.subject, + profileUrn, + variables, + mediaUrn, + createdUrn, + "VIDEO", + ) as Readonly>; + await transport.close(); + } finally { + globals.fetch = priorFetch as typeof fetch; + if (priorLocation === undefined) delete globals.location; + else globals.location = priorLocation; + if (priorDocument === undefined) delete globals.document; + else globals.document = priorDocument; + } + + expect(createdUrn).toBe(entityUrn); + expect(read).toMatchObject({ + entityMatched: true, + actorMatched: true, + textMatched: true, + mediaMatched: true, + mediaUrn, + lifecycle: "PUBLISHED", + url: permalink, + }); + }); + + test("rejects a permalink readback that omits the author's member markers", async () => { + const postText = "observed permalink without the author"; + const activityId = "7512910091130421248"; + const entityUrn = `urn:li:fsd_update:(urn:li:activity:${activityId},FEED_DETAIL,EMPTY,DEFAULT,false)`; + const permalink = `https://www.linkedin.com/feed/update/urn:li:activity:${activityId}/`; + const profileUrn = "urn:li:fsd_profile:ACoAAExactCurrentProfile"; + const identityBody = { + data: { + plainId: "123456789", + "*miniProfile": "urn:li:fs_miniProfile:ACoAAExactCurrentProfile", + }, + included: [{ + entityUrn: "urn:li:fs_miniProfile:ACoAAExactCurrentProfile", + publicIdentifier: "hraness", + firstName: "Ben", + lastName: "Guo", + }], + }; + const permalinkHtml = [ + ``, + `"entityUrn":"urn:li:activity:${activityId}"`, + `"commentary":{"text":"${postText}"}`, + `href="https://www.linkedin.com/in/someone-else/"`, + `Sam Person`, + ``, + ].join(""); + + const globals = globalThis as Record; + const priorFetch = globals.fetch; + const priorLocation = globals.location; + const priorDocument = globals.document; + globals.location = { origin: "https://www.linkedin.com" }; + globals.document = { cookie: 'JSESSIONID="ajax:test-csrf-token"' }; + globals.fetch = ((url: unknown) => { + if (url === "/voyager/api/me") { + return Promise.resolve({ + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" + ? "application/vnd.linkedin.normalized+json+2.1" + : null, + }, + status: 200, + json: () => Promise.resolve(identityBody), + text: () => Promise.resolve(JSON.stringify(identityBody)), + }); + } + if (url === permalink) { + return Promise.resolve({ + headers: { + get: (name: string) => + name.toLowerCase() === "content-type" ? "text/html" : null, + }, + status: 200, + text: () => Promise.resolve(permalinkHtml), + }); + } + return Promise.reject(new Error(`unexpected fetch ${String(url)}`)); + }) as typeof fetch; + + const session: BrowserSession = { + runBatch: (commands) => { + const command = commands[0]; + if (command?.[0] === "open" || command?.[0] === "wait") { + return Promise.resolve([{ success: true, result: { ok: true } }]); + } + if (command?.[0] === "network") return Promise.resolve([pageBindingRecord()]); + const source = command?.[1] ?? ""; + if (!source.includes("LinkedIn post readback permalink")) { + throw new Error("unexpected LinkedIn evaluation source"); + } + return (async () => { + const value = await (new Function(`return ${source}`)() as Promise); + return [browserRecord(value as Readonly>)]; + })(); + }, + close: () => Promise.resolve(), + cleanup: () => Promise.resolve(), + }; + + try { + const transport = await createLinkedInPostBrowserTransport(auth, { + timeoutMs: 10_000, + dependencies: { createBrowserSession: () => Promise.resolve(session) }, + }); + await expect(transport.readPost( + auth.subject, + profileUrn, + { post: { commentary: { text: postText } } }, + null, + entityUrn, + )).rejects.toThrow("did not bind the confirmed post"); + await transport.close(); + } finally { + globals.fetch = priorFetch as typeof fetch; + if (priorLocation === undefined) delete globals.location; + else globals.location = priorLocation; + if (priorDocument === undefined) delete globals.document; + else globals.document = priorDocument; + } + }); }); diff --git a/src/providers/linkedin-web-post-browser.ts b/src/providers/linkedin-web-post-browser.ts index 0189cdca..156ccf99 100644 --- a/src/providers/linkedin-web-post-browser.ts +++ b/src/providers/linkedin-web-post-browser.ts @@ -18,7 +18,6 @@ import type { import { LINKEDIN_GRAPHQL_PATH, LINKEDIN_POST_CREATE_MUTATION_ID, - LINKEDIN_POST_READBACK_QUERY_ID, linkedInPostEntityUrn, linkedInPostMediaUrn, } from "./linkedin-web"; @@ -31,6 +30,8 @@ const LINKEDIN_IMAGE_FINALIZATION_PATH = "/voyager/api/voyagerVideoDashMediaUploadMetadata?action=completeMultipartUpload"; const MAX_BROWSER_OUTPUT_BYTES = 2 * 1024 * 1024; const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const MAX_VIDEO_BYTES = 64 * 1024 * 1024; +const MIN_VIDEO_BYTES = 75_000; const IMAGE_STAGING_CHUNK_CHARACTERS = 48 * 1024; const IMAGE_STAGING_COMMANDS_PER_BATCH = 32; const MAX_IMAGE_STAGING_COMMAND_CHARACTERS = 64 * 1024; @@ -38,6 +39,59 @@ const MAX_IMAGE_BASE64_CHARACTERS = Math.ceil(MAX_IMAGE_BYTES / 3) * 4; const MAX_IMAGE_STAGING_CHUNKS = Math.ceil( MAX_IMAGE_BASE64_CHARACTERS / IMAGE_STAGING_CHUNK_CHARACTERS, ); +const MAX_VIDEO_BASE64_CHARACTERS = Math.ceil(MAX_VIDEO_BYTES / 3) * 4; +const MAX_VIDEO_STAGING_CHUNKS = Math.ceil( + MAX_VIDEO_BASE64_CHARACTERS / IMAGE_STAGING_CHUNK_CHARACTERS, +); + +/** Exact upload-shape constants bound to one reviewed LinkedIn media family. */ +type LinkedInPostMediaSpec = { + readonly label: "image" | "video"; + readonly stagingKeyPrefix: string; + readonly minBytes: number; + readonly maxBytes: number; + readonly maxBase64Characters: number; + readonly maxStagingChunks: number; + readonly maxParts: number; + readonly blobType: string; + readonly filename: string; + readonly uploadType: string; + readonly pemMetadata: string; + readonly urnFamilies: string; + readonly vectorFamily: string; +}; + +const IMAGE_MEDIA_SPEC: LinkedInPostMediaSpec = Object.freeze({ + label: "image", + stagingKeyPrefix: "__ghostgetLinkedInPostImage_", + minBytes: 24, + maxBytes: MAX_IMAGE_BYTES, + maxBase64Characters: MAX_IMAGE_BASE64_CHARACTERS, + maxStagingChunks: MAX_IMAGE_STAGING_CHUNKS, + maxParts: 20, + blobType: "image/png", + filename: "image.png", + uploadType: "IMAGE_SHARING", + pemMetadata: "Voyager - Feed Images=register-vector-upload", + urnFamilies: "digitalmediaAsset|fsd_image", + vectorFamily: "STILLIMAGE", +}); + +const VIDEO_MEDIA_SPEC: LinkedInPostMediaSpec = Object.freeze({ + label: "video", + stagingKeyPrefix: "__ghostgetLinkedInPostVideo_", + minBytes: MIN_VIDEO_BYTES, + maxBytes: MAX_VIDEO_BYTES, + maxBase64Characters: MAX_VIDEO_BASE64_CHARACTERS, + maxStagingChunks: MAX_VIDEO_STAGING_CHUNKS, + maxParts: 64, + blobType: "video/mp4", + filename: "video.mp4", + uploadType: "VIDEO_SHARING", + pemMetadata: "Voyager - Feed Video=register-upload", + urnFamilies: "digitalmediaAsset|fsd_video", + vectorFamily: "VIDEO", +}); const postBrowserManifest: GhostgetManifest = Object.freeze({ schemaVersion: 4, @@ -56,11 +110,16 @@ export type LinkedInPostBrowserTransport = { expectedSubject: string, image: Uint8Array, ) => Promise; + readonly uploadVideo: ( + expectedSubject: string, + video: Uint8Array, + ) => Promise; readonly createPost: ( expectedSubject: string, expectedProfileUrn: string, variables: Readonly>, mediaUrn: string | null, + mediaCategory?: "IMAGE" | "VIDEO", ) => Promise; readonly readPost: ( expectedSubject: string, @@ -68,6 +127,7 @@ export type LinkedInPostBrowserTransport = { variables: Readonly>, mediaUrn: string | null, entityUrn: string, + mediaCategory?: "IMAGE" | "VIDEO", ) => Promise; readonly close: () => Promise; }; @@ -127,6 +187,9 @@ function linkedInPostCreateFailureStage(error: unknown): LinkedInPostCreateFailu if (message.includes("LinkedIn post create content type")) return "post create content type"; if (message.includes("LinkedIn GraphQL response changed shape")) return "post create response shape"; if (message.includes("LinkedIn post create returned provider errors")) return "post create provider errors"; + if (message.includes("LinkedIn post create returned nested provider errors")) { + return "post create provider errors"; + } if (message.includes("LinkedIn post create entity used direct nesting")) { return "post create entity direct nesting"; } @@ -265,7 +328,7 @@ export function browserEvaluationResult( } export function commonEvaluationPrelude(input: Readonly>): string { - return `const input=${canonicalJsonScriptLiteral(input)};if(location.origin!=="${LINKEDIN_ORIGIN}")throw new Error("unexpected LinkedIn origin");const raw=document.cookie.split("; ").find((part)=>part.startsWith("JSESSIONID="));if(typeof raw!=="string")throw new Error("missing LinkedIn browser CSRF cookie");const csrf=decodeURIComponent(raw.slice("JSESSIONID=".length)).replace(/^\"|\"$/g,"");if(!/^ajax:[A-Za-z0-9_-]{1,512}$/.test(csrf))throw new Error("invalid LinkedIn browser CSRF cookie");const baseHeaders={accept:"application/vnd.linkedin.normalized+json+2.1","csrf-token":csrf,"x-li-lang":"en_US","x-requested-with":"XMLHttpRequest","x-restli-protocol-version":"2.0.0"};const jsonTypes=new Set(["application/graphql","application/json","application/vnd.linkedin.normalized+json+2.1"]);const jsonResponse=async(response,label)=>{const contentType=(response.headers.get("content-type")||"").split(";",1)[0].trim().toLowerCase();if(!jsonTypes.has(contentType))throw new Error(label+" content type changed");if(response.status<200||response.status>=300)throw new Error(label+" status changed");return response.json()};const requestJson=async(path,init,label)=>jsonResponse(await fetch(path,{credentials:"include",redirect:"error",referrer:"${LINKEDIN_FEED_URL}",...init}),label);const identity=async()=>requestJson("/voyager/api/me",{headers:baseHeaders,method:"GET"},"LinkedIn current member");const assertIdentity=(body)=>{if(!body||typeof body!=="object"||Array.isArray(body)||!body.data||typeof body.data!=="object"||Array.isArray(body.data))throw new Error("LinkedIn current member changed shape");const plain=typeof body.data.plainId==="string"?body.data.plainId:Number.isSafeInteger(body.data.plainId)?String(body.data.plainId):"";if("urn:li:fsd_profile:"+plain!==input.expectedSubject)throw new Error("LinkedIn current member changed before dispatch");if(input.expectedProfileUrn!==undefined){const mini=body.data["*miniProfile"]??body.data.miniProfile;const suffix=typeof mini==="string"?/^urn:li:fs_miniProfile:([A-Za-z0-9_-]{1,256})$/.exec(mini)?.[1]:undefined;if("urn:li:fsd_profile:"+suffix!==input.expectedProfileUrn)throw new Error("LinkedIn current profile changed before dispatch")}};`; + return `const input=${canonicalJsonScriptLiteral(input)};if(location.origin!=="${LINKEDIN_ORIGIN}")throw new Error("unexpected LinkedIn origin");const raw=document.cookie.split("; ").find((part)=>part.startsWith("JSESSIONID="));if(typeof raw!=="string")throw new Error("missing LinkedIn browser CSRF cookie");const csrf=decodeURIComponent(raw.slice("JSESSIONID=".length)).replace(/^\"|\"$/g,"");if(!/^ajax:[A-Za-z0-9_-]{1,512}$/.test(csrf))throw new Error("invalid LinkedIn browser CSRF cookie");const baseHeaders={accept:"application/vnd.linkedin.normalized+json+2.1","csrf-token":csrf,"x-li-lang":"en_US","x-requested-with":"XMLHttpRequest","x-restli-protocol-version":"2.0.0"};const jsonTypes=new Set(["application/graphql","application/json","application/vnd.linkedin.normalized+json+2.1"]);const jsonResponse=async(response,label)=>{const contentType=(response.headers.get("content-type")||"").split(";",1)[0].trim().toLowerCase();if(!jsonTypes.has(contentType))throw new Error(label+" content type changed: status="+response.status+" ct="+contentType);if(response.status<200||response.status>=300)throw new Error(label+" status changed");return response.json()};const requestJson=async(path,init,label)=>jsonResponse(await fetch(path,{credentials:"include",redirect:"error",referrer:"${LINKEDIN_FEED_URL}",...init}),label);const identity=async()=>requestJson("/voyager/api/me",{headers:baseHeaders,method:"GET"},"LinkedIn current member");const assertIdentity=(body)=>{if(!body||typeof body!=="object"||Array.isArray(body)||!body.data||typeof body.data!=="object"||Array.isArray(body.data))throw new Error("LinkedIn current member changed shape");const plain=typeof body.data.plainId==="string"?body.data.plainId:Number.isSafeInteger(body.data.plainId)?String(body.data.plainId):"";if("urn:li:fsd_profile:"+plain!==input.expectedSubject)throw new Error("LinkedIn current member changed before dispatch");if(input.expectedProfileUrn!==undefined){const mini=body.data["*miniProfile"]??body.data.miniProfile;const suffix=typeof mini==="string"?/^urn:li:fs_miniProfile:([A-Za-z0-9_-]{1,256})$/.exec(mini)?.[1]:undefined;if("urn:li:fsd_profile:"+suffix!==input.expectedProfileUrn)throw new Error("LinkedIn current profile changed before dispatch")}};`; } function identityEvaluationSource(): string { @@ -280,18 +343,22 @@ type LinkedInPostImageStaging = { readonly chunkCount: number; }; -function imageStagingInitializationSource(staging: LinkedInPostImageStaging): string { +function imageStagingInitializationSource( + staging: LinkedInPostImageStaging, + spec: LinkedInPostMediaSpec, +): string { const input = Object.freeze({ stagingKey: staging.key, expectedChunkCount: staging.chunkCount, }); - return `(async()=>{const input=${canonicalJsonScriptLiteral(input)};if(location.origin!=="${LINKEDIN_ORIGIN}")throw new Error("unexpected LinkedIn origin");if(!/^__ghostgetLinkedInPostImage_[a-f0-9]{32}$/.test(input.stagingKey)||!Number.isSafeInteger(input.expectedChunkCount)||input.expectedChunkCount<1||input.expectedChunkCount>${MAX_IMAGE_STAGING_CHUNKS})throw new Error("LinkedIn image staging input changed shape");if(Object.hasOwn(globalThis,input.stagingKey))throw new Error("LinkedIn image staging key collision");Object.defineProperty(globalThis,input.stagingKey,{configurable:true,enumerable:false,value:[],writable:false});return{ready:true}})()`; + return `(async()=>{const input=${canonicalJsonScriptLiteral(input)};if(location.origin!=="${LINKEDIN_ORIGIN}")throw new Error("unexpected LinkedIn origin");if(!/^${spec.stagingKeyPrefix}[a-f0-9]{32}$/.test(input.stagingKey)||!Number.isSafeInteger(input.expectedChunkCount)||input.expectedChunkCount<1||input.expectedChunkCount>${spec.maxStagingChunks})throw new Error("LinkedIn ${spec.label} staging input changed shape");if(Object.hasOwn(globalThis,input.stagingKey))throw new Error("LinkedIn ${spec.label} staging key collision");Object.defineProperty(globalThis,input.stagingKey,{configurable:true,enumerable:false,value:[],writable:false});return{ready:true}})()`; } function imageStagingChunkSource( staging: LinkedInPostImageStaging, index: number, chunk: string, + spec: LinkedInPostMediaSpec, ): string { const input = Object.freeze({ chunk, @@ -299,9 +366,9 @@ function imageStagingChunkSource( index, stagingKey: staging.key, }); - const source = `(async()=>{const input=${canonicalJsonScriptLiteral(input)};if(location.origin!=="${LINKEDIN_ORIGIN}")throw new Error("unexpected LinkedIn origin");const chunks=globalThis[input.stagingKey];if(!Array.isArray(chunks)||!Number.isSafeInteger(input.index)||input.index<0||input.index>=input.expectedChunkCount||input.expectedChunkCount<1||input.expectedChunkCount>${MAX_IMAGE_STAGING_CHUNKS}||chunks.length!==input.index)throw new Error("LinkedIn image staging order changed");if(typeof input.chunk!=="string"||input.chunk.length<1||input.chunk.length>${IMAGE_STAGING_CHUNK_CHARACTERS}||!/^[A-Za-z0-9+/]*={0,2}$/.test(input.chunk))throw new Error("LinkedIn image staging chunk changed shape");chunks.push(input.chunk);return{staged:chunks.length}})()`; + const source = `(async()=>{const input=${canonicalJsonScriptLiteral(input)};if(location.origin!=="${LINKEDIN_ORIGIN}")throw new Error("unexpected LinkedIn origin");const chunks=globalThis[input.stagingKey];if(!Array.isArray(chunks)||!Number.isSafeInteger(input.index)||input.index<0||input.index>=input.expectedChunkCount||input.expectedChunkCount<1||input.expectedChunkCount>${spec.maxStagingChunks}||chunks.length!==input.index)throw new Error("LinkedIn ${spec.label} staging order changed");if(typeof input.chunk!=="string"||input.chunk.length<1||input.chunk.length>${IMAGE_STAGING_CHUNK_CHARACTERS}||!/^[A-Za-z0-9+/]*={0,2}$/.test(input.chunk))throw new Error("LinkedIn ${spec.label} staging chunk changed shape");chunks.push(input.chunk);return{staged:chunks.length}})()`; if (source.length > MAX_IMAGE_STAGING_COMMAND_CHARACTERS) { - throw new Error("LinkedIn image staging command exceeded its reviewed bound"); + throw new Error("LinkedIn media staging command exceeded its reviewed bound"); } return source; } @@ -315,16 +382,17 @@ function baseUploadEvaluationSource( bindings: LinkedInPostPageBindings, expectedSubject: string, staging: LinkedInPostImageStaging, + spec: LinkedInPostMediaSpec, ): string { if ( - staging.byteLength < 24 - || staging.byteLength > MAX_IMAGE_BYTES + staging.byteLength < spec.minBytes + || staging.byteLength > spec.maxBytes || staging.base64Length < 32 - || staging.base64Length > MAX_IMAGE_BASE64_CHARACTERS + || staging.base64Length > spec.maxBase64Characters || staging.chunkCount < 1 - || staging.chunkCount > MAX_IMAGE_STAGING_CHUNKS + || staging.chunkCount > spec.maxStagingChunks ) { - throw new Error("LinkedIn post image is outside the reviewed byte bound"); + throw new Error(`LinkedIn post ${spec.label} is outside the reviewed byte bound`); } const input = Object.freeze({ expectedSubject, @@ -335,7 +403,7 @@ function baseUploadEvaluationSource( stagingKey: staging.key, track: bindings.track, }); - return `(async()=>{${commonEvaluationPrelude(input)}const chunks=globalThis[input.stagingKey];if(!Array.isArray(chunks)||chunks.length!==input.expectedChunkCount||chunks.length<1||chunks.length>${MAX_IMAGE_STAGING_CHUNKS})throw new Error("missing bounded LinkedIn image bytes");delete globalThis[input.stagingKey];if(Object.hasOwn(globalThis,input.stagingKey))throw new Error("LinkedIn image staging cleanup failed");let encoded="";for(let index=0;index${IMAGE_STAGING_CHUNK_CHARACTERS}||!/^[A-Za-z0-9+/]*={0,2}$/.test(chunk))throw new Error("invalid LinkedIn image bytes");encoded+=chunk;chunks[index]=""}if(encoded.length!==input.expectedBase64Length||encoded.length>${MAX_IMAGE_BASE64_CHARACTERS})throw new Error("LinkedIn image changed encoded size");const binary=atob(encoded);encoded="";if(binary.length!==input.expectedByteLength||binary.length<24||binary.length>${MAX_IMAGE_BYTES})throw new Error("LinkedIn image changed size");const bytes=new Uint8Array(binary.length);for(let index=0;index1024)throw new Error("LinkedIn image registration omitted its artifact URN");const checkedUrl=(value)=>{if(typeof value!=="string"||value.length<1||value.length>16384)throw new Error("LinkedIn image upload target changed shape");const url=new URL(value);if(url.protocol!=="https:"||url.username!==""||url.password!==""||url.hash!==""||url.port!==""||!(url.hostname==="linkedin.com"||url.hostname.endsWith(".linkedin.com")||url.hostname==="licdn.com"||url.hostname.endsWith(".licdn.com")))throw new Error("LinkedIn image upload target escaped its reviewed host family");return url};const checkedHeaders=(value,formData)=>{if(value===undefined)return {"csrf-token":csrf};if(!value||typeof value!=="object"||Array.isArray(value)||Object.keys(value).length>32)throw new Error("LinkedIn image upload headers changed shape");const output={};for(const [name,headerValue] of Object.entries(value)){const lower=name.toLowerCase();if(!/^[a-z0-9!#$%&'*+.^_|~-]{1,128}$/.test(lower)||typeof headerValue!=="string"||headerValue.length>8192||/[\\0\\r\\n]/.test(headerValue)||["cookie","host","content-length","origin","referer"].includes(lower)||lower.startsWith("sec-"))throw new Error("LinkedIn image upload headers changed shape");if(formData&&lower==="content-type")continue;output[lower]=headerValue}output["csrf-token"]=csrf;return output};const upload=async(urlValue,body,headers,formData)=>{const url=checkedUrl(urlValue);const response=await fetch(url.href,{body,credentials:url.origin===location.origin?"include":"omit",headers:checkedHeaders(headers,formData),method:"PUT",redirect:"error",referrer:"${LINKEDIN_FEED_URL}"});if(response.status<200||response.status>=300)throw new Error("LinkedIn image upload status changed");return{headers:Object.fromEntries(response.headers.entries()),httpStatusCode:response.status}};if(registration.type==="SINGLE"||registration.type==="MULTIPART_FORMDATA"){if(registration.partUploadRequests!==undefined||registration.multipartMetadata!==undefined)throw new Error("LinkedIn single image upload returned multipart fields");await upload(registration.singleUploadUrl,image,registration.singleUploadHeaders,registration.type==="MULTIPART_FORMDATA")}else if(registration.type==="MULTIPART"){if(registration.singleUploadUrl!==undefined||registration.singleUploadHeaders!==undefined||!Array.isArray(registration.partUploadRequests)||registration.partUploadRequests.length<1||registration.partUploadRequests.length>20||!registration.multipartMetadata||typeof registration.multipartMetadata!=="object"||Array.isArray(registration.multipartMetadata)||JSON.stringify(registration.multipartMetadata).length>65536)throw new Error("LinkedIn multipart image registration changed shape");let next=0;const parts=[];for(const part of registration.partUploadRequests){if(!part||typeof part!=="object"||Array.isArray(part)||Object.keys(part).sort().join(",")!=="firstByte,headers,lastByte,uploadUrl"||!Number.isSafeInteger(part.firstByte)||!Number.isSafeInteger(part.lastByte)||part.firstByte!==next||part.lastByte=image.size)throw new Error("LinkedIn multipart image offsets changed shape");parts.push(await upload(part.uploadUrl,image.slice(part.firstByte,part.lastByte+1,"image/png"),part.headers,false));next=part.lastByte+1}if(next!==image.size)throw new Error("LinkedIn multipart image registration did not cover the file");await requestJson("${LINKEDIN_IMAGE_FINALIZATION_PATH}",{body:JSON.stringify({completeUploadRequest:{mediaArtifactUrn:registration.mediaArtifactUrn,multipartMetadata:registration.multipartMetadata,partUploadResponses:parts}}),headers:mutationHeaders,method:"POST"},"LinkedIn image finalization")}else throw new Error("LinkedIn image upload mechanism changed");return{mediaUrn:registration.urn}})()`; + return `(async()=>{${commonEvaluationPrelude(input)}const chunks=globalThis[input.stagingKey];if(!Array.isArray(chunks)||chunks.length!==input.expectedChunkCount||chunks.length<1||chunks.length>${spec.maxStagingChunks})throw new Error("missing bounded LinkedIn ${spec.label} bytes");delete globalThis[input.stagingKey];if(Object.hasOwn(globalThis,input.stagingKey))throw new Error("LinkedIn ${spec.label} staging cleanup failed");let encoded="";for(let index=0;index${IMAGE_STAGING_CHUNK_CHARACTERS}||!/^[A-Za-z0-9+/]*={0,2}$/.test(chunk))throw new Error("invalid LinkedIn ${spec.label} bytes");encoded+=chunk;chunks[index]=""}if(encoded.length!==input.expectedBase64Length||encoded.length>${spec.maxBase64Characters})throw new Error("LinkedIn ${spec.label} changed encoded size");const binary=atob(encoded);encoded="";if(binary.length!==input.expectedByteLength||binary.length<${spec.minBytes}||binary.length>${spec.maxBytes})throw new Error("LinkedIn ${spec.label} changed size");const bytes=new Uint8Array(binary.length);for(let index=0;index1024)throw new Error("LinkedIn ${spec.label} registration omitted its artifact URN");const checkedUrl=(value)=>{if(typeof value!=="string"||value.length<1||value.length>16384)throw new Error("LinkedIn ${spec.label} upload target changed shape");const url=new URL(value);if(url.protocol!=="https:"||url.username!==""||url.password!==""||url.hash!==""||url.port!==""||!(url.hostname==="linkedin.com"||url.hostname.endsWith(".linkedin.com")||url.hostname==="licdn.com"||url.hostname.endsWith(".licdn.com")))throw new Error("LinkedIn ${spec.label} upload target escaped its reviewed host family");return url};const checkedHeaders=(value,formData)=>{if(value===undefined)return {"csrf-token":csrf};if(!value||typeof value!=="object"||Array.isArray(value)||Object.keys(value).length>32)throw new Error("LinkedIn ${spec.label} upload headers changed shape");const output={};for(const [name,headerValue] of Object.entries(value)){const lower=name.toLowerCase();if(!/^[a-z0-9!#$%&'*+.^_|~-]{1,128}$/.test(lower)||typeof headerValue!=="string"||headerValue.length>8192||/[\\0\\r\\n]/.test(headerValue)||["cookie","host","content-length","origin","referer"].includes(lower)||lower.startsWith("sec-"))throw new Error("LinkedIn ${spec.label} upload headers changed shape");if(formData&&lower==="content-type")continue;output[lower]=headerValue}output["csrf-token"]=csrf;return output};const upload=async(urlValue,body,headers,formData)=>{const url=checkedUrl(urlValue);const response=await fetch(url.href,{body,credentials:url.origin===location.origin?"include":"omit",headers:checkedHeaders(headers,formData),method:"PUT",redirect:"error",referrer:"${LINKEDIN_FEED_URL}"});if(response.status<200||response.status>=300)throw new Error("LinkedIn ${spec.label} upload status changed");return{headers:Object.fromEntries(response.headers.entries()),httpStatusCode:response.status}};if(registration.type==="SINGLE"||registration.type==="MULTIPART_FORMDATA"){if(registration.partUploadRequests!==undefined||registration.multipartMetadata!==undefined)throw new Error("LinkedIn single ${spec.label} upload returned multipart fields");await upload(registration.singleUploadUrl,media,registration.singleUploadHeaders,registration.type==="MULTIPART_FORMDATA")}else if(registration.type==="MULTIPART"){if(registration.singleUploadUrl!==undefined||registration.singleUploadHeaders!==undefined||!Array.isArray(registration.partUploadRequests)||registration.partUploadRequests.length<1||registration.partUploadRequests.length>${spec.maxParts}||!registration.multipartMetadata||typeof registration.multipartMetadata!=="object"||Array.isArray(registration.multipartMetadata)||JSON.stringify(registration.multipartMetadata).length>65536)throw new Error("LinkedIn multipart ${spec.label} registration changed shape");let next=0;const parts=[];for(const part of registration.partUploadRequests){if(!part||typeof part!=="object"||Array.isArray(part)||Object.keys(part).sort().join(",")!=="firstByte,headers,lastByte,uploadUrl"||!Number.isSafeInteger(part.firstByte)||!Number.isSafeInteger(part.lastByte)||part.firstByte!==next||part.lastByte=media.size)throw new Error("LinkedIn multipart ${spec.label} offsets changed shape");parts.push(await upload(part.uploadUrl,media.slice(part.firstByte,part.lastByte+1,"${spec.blobType}"),part.headers,false));next=part.lastByte+1}if(next!==media.size)throw new Error("LinkedIn multipart ${spec.label} registration did not cover the file");await requestJson("${LINKEDIN_IMAGE_FINALIZATION_PATH}",{body:JSON.stringify({completeUploadRequest:{mediaArtifactUrn:registration.mediaArtifactUrn,multipartMetadata:registration.multipartMetadata,partUploadResponses:parts}}),headers:mutationHeaders,method:"POST"},"LinkedIn ${spec.label} finalization")}else throw new Error("LinkedIn ${spec.label} upload mechanism changed");return{mediaUrn:registration.urn}})()`; } function replaceRequiredUploadSource( @@ -354,12 +422,13 @@ function uploadEvaluationSource( bindings: LinkedInPostPageBindings, expectedSubject: string, staging: LinkedInPostImageStaging, + spec: LinkedInPostMediaSpec, ): string { - let source = baseUploadEvaluationSource(bindings, expectedSubject, staging); + let source = baseUploadEvaluationSource(bindings, expectedSubject, staging, spec); source = replaceRequiredUploadSource( source, 'const allowedKeys=new Set(["mediaArtifactUrn","multipartMetadata","partUploadRequests","recipes","singleUploadHeaders","singleUploadUrl","type","urn"]);', - 'const allowedKeys=new Set(["$type","assetRealtimeTopic","mediaArtifactUrn","multipartMetadata","partUploadRequests","pollingUrl","recipes","singleUploadHeaders","singleUploadUrl","type","urn"]);if(registration.$type!==undefined&®istration.$type!=="com.linkedin.mediauploader.MediaUploadMetadata")throw new Error("LinkedIn image registration changed response type");if(registration.assetRealtimeTopic!==undefined&&(typeof registration.assetRealtimeTopic!=="string"||registration.assetRealtimeTopic.length<1||registration.assetRealtimeTopic.length>4096))throw new Error("LinkedIn image registration changed realtime topic");if(registration.recipes!==undefined&&(!Array.isArray(registration.recipes)||registration.recipes.length<1||registration.recipes.length>20||registration.recipes.some((recipe)=>typeof recipe!=="string"||!/^urn:li:[A-Za-z0-9_(),.:%=-]{1,448}$/.test(recipe))||new Set(registration.recipes).size!==registration.recipes.length))throw new Error("LinkedIn image registration changed recipes");', + 'const allowedKeys=new Set(["$type","assetRealtimeTopic","mediaArtifactUrn","multipartMetadata","partUploadRequests","pollingUrl","recipes","singleUploadHeaders","singleUploadUrl","type","urn"]);if(registration.$type!==undefined&®istration.$type!=="com.linkedin.mediauploader.MediaUploadMetadata")throw new Error("LinkedIn registration changed response type");if(registration.assetRealtimeTopic!==undefined&&(typeof registration.assetRealtimeTopic!=="string"||registration.assetRealtimeTopic.length<1||registration.assetRealtimeTopic.length>4096))throw new Error("LinkedIn registration changed realtime topic");if(registration.recipes!==undefined&&(!Array.isArray(registration.recipes)||registration.recipes.length<1||registration.recipes.length>20||registration.recipes.some((recipe)=>typeof recipe!=="string"||!/^urn:li:[A-Za-z0-9_(),.:%=-]{1,448}$/.test(recipe))||new Set(registration.recipes).size!==registration.recipes.length))throw new Error("LinkedIn registration changed recipes");', ); source = replaceRequiredUploadSource( source, @@ -369,7 +438,7 @@ function uploadEvaluationSource( source = replaceRequiredUploadSource( source, 'if(registration.type==="SINGLE"||registration.type==="MULTIPART_FORMDATA"){', - 'if(registration.type==="SINGLE"||registration.type==="MULTIPART_FORMDATA"||registration.type==="VECTOR"){if(registration.type==="VECTOR"&&(!registration.singleUploadHeaders||typeof registration.singleUploadHeaders!=="object"||Array.isArray(registration.singleUploadHeaders)||Object.keys(registration.singleUploadHeaders).sort().join(",")!=="media-type-family"||registration.singleUploadHeaders["media-type-family"]!=="STILLIMAGE"))throw new Error("LinkedIn vector image upload headers changed shape");', + `if(registration.type==="SINGLE"||registration.type==="MULTIPART_FORMDATA"||registration.type==="VECTOR"){if(registration.type==="VECTOR"&&(!registration.singleUploadHeaders||typeof registration.singleUploadHeaders!=="object"||Array.isArray(registration.singleUploadHeaders)||Object.keys(registration.singleUploadHeaders).sort().join(",")!=="media-type-family"||registration.singleUploadHeaders["media-type-family"]!=="${spec.vectorFamily}"))throw new Error("LinkedIn vector ${spec.label} upload headers changed shape");`, ); return source; } @@ -380,16 +449,18 @@ function baseCreateEvaluationSource( expectedProfileUrn: string, variables: Readonly>, mediaUrn: string | null, + mediaCategory: "IMAGE" | "VIDEO" | null, ): string { const input = Object.freeze({ expectedProfileUrn, expectedSubject, + mediaCategory, mediaUrn, pageInstance: bindings.pageInstance, track: bindings.track, variables, }); - return `(async()=>{${commonEvaluationPrelude(input)}const firstIdentity=await identity();assertIdentity(firstIdentity);const mutationHeaders={...baseHeaders,"content-type":"application/json; charset=UTF-8","x-li-page-instance":input.pageInstance,"x-li-pem-metadata":"Voyager - Sharing - CreateShare=sharing-create-content","x-li-track":input.track};const createPath="${LINKEDIN_GRAPHQL_PATH}?action=execute&queryId=${encodeURIComponent(LINKEDIN_POST_CREATE_MUTATION_ID)}";const createBody=await requestJson(createPath,{body:JSON.stringify({includeWebMetadata:true,queryId:"${LINKEDIN_POST_CREATE_MUTATION_ID}",variables:input.variables}),headers:mutationHeaders,method:"POST"},"LinkedIn post create");const createPayload=createBody&&typeof createBody==="object"&&!Array.isArray(createBody)&&createBody.data&&typeof createBody.data==="object"&&!Array.isArray(createBody.data)&&createBody.data.value&&typeof createBody.data.value==="object"&&!Array.isArray(createBody.data.value)?createBody.data.value:createBody&&typeof createBody==="object"&&!Array.isArray(createBody)&&createBody.value&&typeof createBody.value==="object"&&!Array.isArray(createBody.value)?createBody.value:createBody;if(!createPayload||typeof createPayload!=="object"||Array.isArray(createPayload))throw new Error("LinkedIn GraphQL response changed shape");if(Array.isArray(createPayload.errors)&&createPayload.errors.length>0)throw new Error("LinkedIn post create returned provider errors");const entity=createPayload.data?.createContentcreationDashShares?.entity;if(!entity||typeof entity!=="object"||Array.isArray(entity))throw new Error("LinkedIn post create omitted its entity");const entityUrn=entity.entityUrn;if(!/^urn:li:(?:fsd_share|share|ugcPost):[A-Za-z0-9_(),.:%=-]{1,448}$/.test(entityUrn||""))throw new Error("LinkedIn post create returned an invalid entity URN");if(!entity.status||typeof entity.status!=="object"||Array.isArray(entity.status)||!entity.status.lifecycleState||typeof entity.status.lifecycleState!=="object"||Array.isArray(entity.status.lifecycleState)||!entity.status.lifecycleState.PublishedState||typeof entity.status.lifecycleState.PublishedState!=="object"||Array.isArray(entity.status.lifecycleState.PublishedState))throw new Error("LinkedIn post create did not report a published lifecycle");return{entityUrn}})()`; + return `(async()=>{${commonEvaluationPrelude(input)}const firstIdentity=await identity();assertIdentity(firstIdentity);const mutationHeaders={...baseHeaders,"content-type":"application/json; charset=UTF-8","x-li-page-instance":input.pageInstance,"x-li-pem-metadata":"Voyager - Sharing - CreateShare=sharing-create-content","x-li-track":input.track};const createPath="${LINKEDIN_GRAPHQL_PATH}?action=execute&queryId=${encodeURIComponent(LINKEDIN_POST_CREATE_MUTATION_ID)}";const createBody=await requestJson(createPath,{body:JSON.stringify({includeWebMetadata:true,queryId:"${LINKEDIN_POST_CREATE_MUTATION_ID}",variables:input.variables}),headers:mutationHeaders,method:"POST"},"LinkedIn post create");const createPayload=createBody&&typeof createBody==="object"&&!Array.isArray(createBody)&&createBody.data&&typeof createBody.data==="object"&&!Array.isArray(createBody.data)&&createBody.data.value&&typeof createBody.data.value==="object"&&!Array.isArray(createBody.data.value)?createBody.data.value:createBody&&typeof createBody==="object"&&!Array.isArray(createBody)&&createBody.value&&typeof createBody.value==="object"&&!Array.isArray(createBody.value)?createBody.value:createBody;if(!createPayload||typeof createPayload!=="object"||Array.isArray(createPayload))throw new Error("LinkedIn GraphQL response changed shape");if(Array.isArray(createPayload.errors)&&createPayload.errors.length>0)throw new Error("LinkedIn post create returned provider errors");if(createPayload.data&&typeof createPayload.data==="object"&&!Array.isArray(createPayload.data)&&Array.isArray(createPayload.data.errors)&&createPayload.data.errors.length>0)throw new Error("LinkedIn post create returned nested provider errors");const entity=createPayload.data?.createContentcreationDashShares?.entity;if(!entity||typeof entity!=="object"||Array.isArray(entity))throw new Error("LinkedIn post create omitted its entity");const entityUrn=entity.entityUrn;if(!/^urn:li:(?:fsd_share|share|ugcPost):[A-Za-z0-9_(),.:%=-]{1,448}$/.test(entityUrn||""))throw new Error("LinkedIn post create returned an invalid entity URN");if(!entity.status||typeof entity.status!=="object"||Array.isArray(entity.status)||!entity.status.lifecycleState||typeof entity.status.lifecycleState!=="object"||Array.isArray(entity.status.lifecycleState)||!entity.status.lifecycleState.PublishedState||typeof entity.status.lifecycleState.PublishedState!=="object"||Array.isArray(entity.status.lifecycleState.PublishedState))throw new Error("LinkedIn post create did not report a published lifecycle");return{entityUrn}})()`; } function createEvaluationSource( @@ -398,6 +469,7 @@ function createEvaluationSource( expectedProfileUrn: string, variables: Readonly>, mediaUrn: string | null, + mediaCategory: "IMAGE" | "VIDEO" | null, ): string { return replaceRequiredUploadSource( baseCreateEvaluationSource( @@ -406,12 +478,26 @@ function createEvaluationSource( expectedProfileUrn, variables, mediaUrn, + mediaCategory, ), 'const entity=createPayload.data?.createContentcreationDashShares?.entity;if(!entity||typeof entity!=="object"||Array.isArray(entity))throw new Error("LinkedIn post create omitted its entity");', - 'const entity=createPayload.data?.createContentcreationDashShares?.entity;if(!entity||typeof entity!=="object"||Array.isArray(entity)){const direct=createPayload.createContentcreationDashShares?.entity;const alternate=createBody&&typeof createBody==="object"&&!Array.isArray(createBody)?createBody.data?.createContentcreationDashShares?.entity:null;if(direct&&typeof direct==="object"&&!Array.isArray(direct))throw new Error("LinkedIn post create entity used direct nesting");if(alternate&&typeof alternate==="object"&&!Array.isArray(alternate))throw new Error("LinkedIn post create entity used alternate data nesting");if(createBody&&typeof createBody==="object"&&!Array.isArray(createBody)&&Array.isArray(createBody.included)&&createBody.included.length>0)throw new Error("LinkedIn post create returned normalized included entities");throw new Error("LinkedIn post create omitted its entity")}', + 'const entity=createPayload.data?.createContentcreationDashShares?.entity;if(!entity||typeof entity!=="object"||Array.isArray(entity)){const direct=createPayload.createContentcreationDashShares?.entity;const alternate=createBody&&typeof createBody==="object"&&!Array.isArray(createBody)?createBody.data?.createContentcreationDashShares?.entity:null;if(direct&&typeof direct==="object"&&!Array.isArray(direct))throw new Error("LinkedIn post create entity used direct nesting");if(alternate&&typeof alternate==="object"&&!Array.isArray(alternate))throw new Error("LinkedIn post create entity used alternate data nesting");if(createBody&&typeof createBody==="object"&&!Array.isArray(createBody)&&Array.isArray(createBody.included)&&createBody.included.length>0){let includedShare=null;let includedUpdate=null;for(const candidate of createBody.included){if(!candidate||typeof candidate!=="object"||Array.isArray(candidate))continue;if(candidate.$type==="com.linkedin.voyager.dash.contentcreation.Share"){if(includedShare!==null)throw new Error("LinkedIn post create returned duplicate share entities");includedShare=candidate}if(candidate.$type==="com.linkedin.voyager.dash.feed.Update"){if(includedUpdate!==null)throw new Error("LinkedIn post create returned duplicate update entities");includedUpdate=candidate}}if(includedUpdate===null&&includedShare!==null&&input.mediaCategory==="VIDEO"){const shareUrn=typeof includedShare.entityUrn==="string"?includedShare.entityUrn:"";if(!/^urn:li:fsd_share:urn:li:(?:ugcPost|share|activity):[0-9]{10,20}$/.test(shareUrn))throw new Error("LinkedIn video post create share URN changed shape: "+shareUrn.slice(0,256));const videoLifecycle=includedShare.status&&typeof includedShare.status==="object"&&!Array.isArray(includedShare.status)?includedShare.status.lifecycleState:null;if(!videoLifecycle||typeof videoLifecycle!=="object"||Array.isArray(videoLifecycle))throw new Error("LinkedIn video post create omitted its lifecycle");const publishedRef=videoLifecycle["*PublishedState"];const publishedFlag=videoLifecycle.PublishedState;const unpublished=videoLifecycle.UnpublishedState;const lifecycleOk=(typeof publishedRef==="string"&&publishedRef===shareUrn)||(publishedFlag===null&&unpublished==="MEDIA_PROCESSING");if(!lifecycleOk)throw new Error("LinkedIn video post create lifecycle changed shape: "+JSON.stringify(videoLifecycle).slice(0,512));return{entityUrn:shareUrn}}if(includedShare===null||includedUpdate===null)throw new Error("LinkedIn post create normalized included entities omitted the share or update: "+JSON.stringify(createBody.included.map((entry)=>entry&&typeof entry==="object"?entry.$type:null)).slice(0,512)+" share="+JSON.stringify(includedShare===null?null:{entityUrn:includedShare.entityUrn,status:includedShare.status,content:includedShare.content&&typeof includedShare.content==="object"?Object.keys(includedShare.content):includedShare.content}).slice(0,768));const lifecycle=includedShare.status&&typeof includedShare.status==="object"&&!Array.isArray(includedShare.status)?includedShare.status.lifecycleState:null;if(!lifecycle||typeof lifecycle!=="object"||Array.isArray(lifecycle)||lifecycle.UnpublishedState!==null||typeof lifecycle["*PublishedState"]!=="string")throw new Error("LinkedIn post create did not report a published lifecycle: "+JSON.stringify(includedShare.status).slice(0,512));const updateUrn=typeof includedUpdate.entityUrn==="string"?includedUpdate.entityUrn:"";if(lifecycle["*PublishedState"]!==updateUrn)throw new Error("LinkedIn post create published lifecycle did not bind the update entity: "+String(updateUrn).slice(0,256));const prefix="urn:li:fsd_update:(urn:li:activity:";if(!updateUrn.startsWith(prefix)||!updateUrn.endsWith(")")||updateUrn.length>512)throw new Error("LinkedIn post create update entity URN changed shape: "+String(updateUrn).slice(0,256));const tail=updateUrn.slice(prefix.length,-1);const comma=tail.indexOf(",");const activityId=comma<0?"":tail.slice(0,comma);if(activityId.length<10||activityId.length>20||!/^[0-9]+$/.test(activityId))throw new Error("LinkedIn post create update entity URN changed shape: "+String(updateUrn).slice(0,256));return{entityUrn:updateUrn}}throw new Error("LinkedIn post create omitted its entity")}', ); } +function linkedInPostPermalinkUrl(entityUrn: string): string { + for (const kind of ["urn:li:activity:", "urn:li:ugcPost:", "urn:li:share:"]) { + const index = entityUrn.indexOf(kind); + if (index >= 0) { + const id = entityUrn.slice(index + kind.length).replace(/[^0-9].*/u, ""); + if (/^[0-9]{10,20}$/u.test(id)) { + return `${LINKEDIN_ORIGIN}/feed/update/${kind}${id}/`; + } + } + } + return `${LINKEDIN_ORIGIN}/feed/update/${entityUrn}/`; +} + function readbackEvaluationSource( bindings: LinkedInPostPageBindings, expectedSubject: string, @@ -419,6 +505,7 @@ function readbackEvaluationSource( variables: Readonly>, mediaUrn: string | null, entityUrn: string, + postUrl: string, ): string { const input = Object.freeze({ entityUrn, @@ -426,10 +513,11 @@ function readbackEvaluationSource( expectedSubject, mediaUrn, pageInstance: bindings.pageInstance, + postUrl, track: bindings.track, variables, }); - return `(async()=>{${commonEvaluationPrelude(input)}const graphqlEnvelope=(body)=>{if(!body||typeof body!=="object"||Array.isArray(body))throw new Error("LinkedIn GraphQL response changed shape");const value=body.data&&typeof body.data==="object"&&!Array.isArray(body.data)&&body.data.value&&typeof body.data.value==="object"&&!Array.isArray(body.data.value)?body.data.value:body.value&&typeof body.value==="object"&&!Array.isArray(body.value)?body.value:body;return value};const variablesText="(moduleKey:feed-item:desktop,urnOrNss:"+input.entityUrn+")";const readbackPath="${LINKEDIN_GRAPHQL_PATH}?includeWebMetadata=true&queryId=${encodeURIComponent(LINKEDIN_POST_READBACK_QUERY_ID)}&variables="+encodeURIComponent(variablesText);const readbackBody=await requestJson(readbackPath,{headers:baseHeaders,method:"GET"},"LinkedIn post readback");const readbackPayload=graphqlEnvelope(readbackBody);if(Array.isArray(readbackPayload.errors)&&readbackPayload.errors.length>0)throw new Error("LinkedIn post readback returned provider errors");const elements=readbackPayload.data?.feedDashUpdatesByBackendUrnOrNss?.elements;if(!Array.isArray(elements)||elements.length!==1||!elements[0]||typeof elements[0]!=="object"||Array.isArray(elements[0]))throw new Error("LinkedIn post readback omitted its exact update");const update=elements[0];let visited=0;const contains=(value,expected,depth=0)=>{if(++visited>50000||depth>32)throw new Error("LinkedIn post readback exceeded its reviewed shape");if(value===expected)return true;if(Array.isArray(value))return value.some((item)=>contains(item,expected,depth+1));if(value&&typeof value==="object")return Object.values(value).some((item)=>contains(item,expected,depth+1));return false};const withinNamed=(value,name,expected,depth=0)=>{if(depth>32||!value||typeof value!=="object")return false;if(Array.isArray(value))return value.some((item)=>withinNamed(item,name,expected,depth+1));for(const [key,item] of Object.entries(value)){if(key===name&&contains(item,expected,depth+1))return true;if(withinNamed(item,name,expected,depth+1))return true}return false};visited=0;const entityMatched=contains(update,input.entityUrn);visited=0;const actorMatched=withinNamed(update,"actor",input.expectedProfileUrn);visited=0;const textMatched=withinNamed(update,"commentary",input.variables.post.commentary.text);visited=0;const mediaMatched=input.mediaUrn===null?false:contains(update,input.mediaUrn);if(!entityMatched||!actorMatched||!textMatched||(input.mediaUrn!==null&&!mediaMatched))throw new Error("LinkedIn independent post readback did not bind the confirmed post");const id=input.entityUrn.slice(input.entityUrn.lastIndexOf(":")+1);if(!/^[A-Za-z0-9_(),.=%-]{1,448}$/.test(id))throw new Error("LinkedIn post entity ID changed shape");const url="${LINKEDIN_ORIGIN}/feed/update/urn:li:activity:"+id+"/";return{actorMatched,entityMatched,entityUrn:input.entityUrn,lifecycle:"PUBLISHED",mediaMatched,mediaUrn:input.mediaUrn,textMatched,url}})()`; + return `(async()=>{${commonEvaluationPrelude(input)}const htmlEscape=(text)=>text.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'");const response=await fetch(input.postUrl,{credentials:"include",redirect:"error",referrer:"${LINKEDIN_FEED_URL}",headers:baseHeaders,method:"GET"});const contentType=(response.headers.get("content-type")||"").split(";",1)[0].trim().toLowerCase();if(contentType!=="text/html"||response.status<200||response.status>=300)throw new Error("LinkedIn post readback permalink changed shape; status="+response.status+" ct="+contentType);const html=await response.text();if(html.length>8*1024*1024)throw new Error("LinkedIn post readback permalink exceeded its reviewed bound");const activityIdx=input.entityUrn.indexOf("urn:li:activity:");const activityUrn=activityIdx<0?null:"urn:li:activity:"+input.entityUrn.slice(activityIdx+16).replace(/[^0-9].*/,"");const digits=(input.entityUrn.match(/[0-9]{10,20}/g)||[]);const entityMatched=html.indexOf(input.entityUrn)>=0||(activityUrn!==null&&html.indexOf(activityUrn)>=0)||digits.some((d)=>html.indexOf("urn:li:activity:"+d)>=0||html.indexOf("urn:li:share:"+d)>=0||html.indexOf("urn:li:ugcPost:"+d)>=0||html.indexOf("urn%3Ali%3Aactivity%3A"+d)>=0||html.indexOf("urn%3Ali%3Ashare%3A"+d)>=0||html.indexOf("urn%3Ali%3AugcPost%3A"+d)>=0);const text=input.variables.post.commentary.text;const textMatched=html.indexOf(text)>=0||html.indexOf(htmlEscape(text))>=0;const me=await identity();assertIdentity(me);const miniRef=me.data&&typeof me.data==="object"?(me.data["*miniProfile"]??me.data.miniProfile):null;let mini=null;if(miniRef&&typeof miniRef==="object"){mini=miniRef}else if(typeof miniRef==="string"&&Array.isArray(me.included)){for(const cand of me.included){if(cand&&typeof cand==="object"&&cand.entityUrn===miniRef){mini=cand}}}const slug=mini&&typeof mini.publicIdentifier==="string"&&mini.publicIdentifier.length>0?mini.publicIdentifier:null;const authorName=mini&&typeof mini.firstName==="string"&&typeof mini.lastName==="string"&&mini.firstName.length>0&&mini.lastName.length>0?mini.firstName+" "+mini.lastName:null;const fsdId=input.expectedProfileUrn.split(":").pop();const urnMatched=html.indexOf(input.expectedProfileUrn)>=0||html.indexOf("fsd_profile:"+fsdId)>=0||html.indexOf("fsd_profile%3A"+fsdId)>=0;const slugMatched=slug!==null&&(html.indexOf("linkedin.com/in/"+slug+"/")>=0||html.indexOf("linkedin.com/in/"+slug+"?")>=0);const nameMatched=authorName!==null&&html.indexOf(authorName)>=0;const actorMatched=urnMatched||(slugMatched&&nameMatched);const mediaMatched=input.mediaUrn===null?true:html.indexOf(input.mediaUrn)>=0||html.indexOf(htmlEscape(input.mediaUrn))>=0||html.indexOf(encodeURIComponent(input.mediaUrn))>=0;if(!entityMatched||!actorMatched||!textMatched||!mediaMatched)throw new Error("LinkedIn independent post readback did not bind the confirmed post: "+JSON.stringify({entityMatched,actorMatched,textMatched,mediaMatched,htmlBytes:html.length,urnMatched,slugMatched,nameMatched}));return{actorMatched,entityMatched,entityUrn:input.entityUrn,lifecycle:"PUBLISHED",mediaMatched:input.mediaUrn===null?false:mediaMatched,mediaUrn:input.mediaUrn,textMatched,url:input.postUrl}})()`; } async function finalizeBrowserSession(session: BrowserSession): Promise { @@ -537,6 +625,79 @@ export async function createLinkedInPostBrowserTransport( throw error; } + const uploadMedia = async ( + expectedSubject: string, + bytes: Uint8Array, + spec: LinkedInPostMediaSpec, + ): Promise => { + if (bytes.byteLength < spec.minBytes || bytes.byteLength > spec.maxBytes) { + throw new Error(`LinkedIn post ${spec.label} is outside the reviewed byte bound`); + } + const encoded = Buffer.from(bytes).toString("base64"); + const chunkCount = Math.ceil(encoded.length / IMAGE_STAGING_CHUNK_CHARACTERS); + const staging = Object.freeze({ + key: `${spec.stagingKeyPrefix}${randomUUID().replaceAll("-", "")}`, + byteLength: bytes.byteLength, + base64Length: encoded.length, + chunkCount, + }); + let failureStage: LinkedInPostImageFailureStage = "page image staging"; + try { + const initialized = await run(imageStagingInitializationSource(staging, spec)); + exactKeys(initialized, ["ready"], `LinkedIn ${spec.label} staging initialization`); + if (initialized.ready !== true) { + throw new Error(`LinkedIn ${spec.label} staging initialization changed shape`); + } + const sources: string[] = []; + for (let index = 0; index < chunkCount; index += 1) { + const offset = index * IMAGE_STAGING_CHUNK_CHARACTERS; + sources.push(imageStagingChunkSource( + staging, + index, + encoded.slice(offset, offset + IMAGE_STAGING_CHUNK_CHARACTERS), + spec, + )); + } + for ( + let offset = 0; + offset < sources.length; + offset += IMAGE_STAGING_COMMANDS_PER_BATCH + ) { + const batch = sources.slice(offset, offset + IMAGE_STAGING_COMMANDS_PER_BATCH); + const staged = await runEvaluations(batch); + for (let index = 0; index < staged.length; index += 1) { + const result = staged[index]!; + exactKeys(result, ["staged"], `LinkedIn ${spec.label} staging command`); + if (result.staged !== offset + index + 1) { + throw new Error(`LinkedIn ${spec.label} staging command changed order`); + } + } + } + failureStage = "image registration or upload response"; + const result = await run(uploadEvaluationSource( + bindings, + expectedSubject, + staging, + spec, + )); + exactKeys(result, ["mediaUrn"], `LinkedIn ${spec.label} upload browser request`); + return linkedInPostMediaUrn(result.mediaUrn); + } catch (error) { + throw new LinkedInPostImagePreparationError( + failureStage === "page image staging" + ? failureStage + : linkedInPostImageFailureStage(error), + error, + ); + } finally { + try { + await run(imageStagingCleanupSource(staging.key)); + } catch { + // Browser finalization remains the authoritative private-artifact cleanup. + } + } + }; + return Object.freeze({ currentIdentityResponse: async () => { const result = await run(identityEvaluationSource()); @@ -547,72 +708,17 @@ export async function createLinkedInPostBrowserTransport( return result.body; }, uploadImage: async (expectedSubject: string, image: Uint8Array) => { - if (image.byteLength < 24 || image.byteLength > MAX_IMAGE_BYTES) { - throw new Error("LinkedIn post image is outside the reviewed byte bound"); - } - const encoded = Buffer.from(image).toString("base64"); - const chunkCount = Math.ceil(encoded.length / IMAGE_STAGING_CHUNK_CHARACTERS); - const staging = Object.freeze({ - key: `__ghostgetLinkedInPostImage_${randomUUID().replaceAll("-", "")}`, - byteLength: image.byteLength, - base64Length: encoded.length, - chunkCount, - }); - let failureStage: LinkedInPostImageFailureStage = "page image staging"; - try { - const initialized = await run(imageStagingInitializationSource(staging)); - exactKeys(initialized, ["ready"], "LinkedIn image staging initialization"); - if (initialized.ready !== true) { - throw new Error("LinkedIn image staging initialization changed shape"); - } - const sources: string[] = []; - for (let index = 0; index < chunkCount; index += 1) { - const offset = index * IMAGE_STAGING_CHUNK_CHARACTERS; - sources.push(imageStagingChunkSource( - staging, - index, - encoded.slice(offset, offset + IMAGE_STAGING_CHUNK_CHARACTERS), - )); - } - for ( - let offset = 0; - offset < sources.length; - offset += IMAGE_STAGING_COMMANDS_PER_BATCH - ) { - const batch = sources.slice(offset, offset + IMAGE_STAGING_COMMANDS_PER_BATCH); - const staged = await runEvaluations(batch); - for (let index = 0; index < staged.length; index += 1) { - const result = staged[index]!; - exactKeys(result, ["staged"], "LinkedIn image staging command"); - if (result.staged !== offset + index + 1) { - throw new Error("LinkedIn image staging command changed order"); - } - } - } - failureStage = "image registration or upload response"; - const result = await run(uploadEvaluationSource(bindings, expectedSubject, staging)); - exactKeys(result, ["mediaUrn"], "LinkedIn image upload browser request"); - return linkedInPostMediaUrn(result.mediaUrn); - } catch (error) { - throw new LinkedInPostImagePreparationError( - failureStage === "page image staging" - ? failureStage - : linkedInPostImageFailureStage(error), - error, - ); - } finally { - try { - await run(imageStagingCleanupSource(staging.key)); - } catch { - // Browser finalization remains the authoritative private-artifact cleanup. - } - } + return uploadMedia(expectedSubject, image, IMAGE_MEDIA_SPEC); + }, + uploadVideo: async (expectedSubject: string, video: Uint8Array) => { + return uploadMedia(expectedSubject, video, VIDEO_MEDIA_SPEC); }, createPost: async ( expectedSubject: string, expectedProfileUrn: string, variables: Readonly>, mediaUrn: string | null, + mediaCategory: "IMAGE" | "VIDEO" = "IMAGE", ) => { try { const result = await run(createEvaluationSource( @@ -621,6 +727,7 @@ export async function createLinkedInPostBrowserTransport( expectedProfileUrn, variables, mediaUrn, + mediaCategory, )); exactKeys(result, ["entityUrn"], "LinkedIn post create browser request"); return linkedInPostEntityUrn(result.entityUrn); @@ -634,14 +741,26 @@ export async function createLinkedInPostBrowserTransport( variables: Readonly>, mediaUrn: string | null, entityUrn: string, + mediaCategory: "IMAGE" | "VIDEO" = "IMAGE", ) => { + const boundUrn = linkedInPostEntityUrn(entityUrn); + const postUrl = linkedInPostPermalinkUrl(boundUrn); + // Let the provider settle briefly, then bind the exact post through its + // independent server-rendered permalink readback. Video posts enter + // MEDIA_PROCESSING after acceptance and need a longer settle. + await session.runBatch( + [["wait", mediaCategory === "VIDEO" ? "15000" : "3000"]], + Math.min(remaining(), 25_000), + MAX_BROWSER_OUTPUT_BYTES, + ); const result = await run(readbackEvaluationSource( bindings, expectedSubject, expectedProfileUrn, variables, mediaUrn, - linkedInPostEntityUrn(entityUrn), + boundUrn, + postUrl, )); return result; }, diff --git a/src/providers/linkedin-web-runtime.internal.test.ts b/src/providers/linkedin-web-runtime.internal.test.ts index 762b019f..64b131fb 100644 --- a/src/providers/linkedin-web-runtime.internal.test.ts +++ b/src/providers/linkedin-web-runtime.internal.test.ts @@ -401,6 +401,24 @@ function postRecipe(): WebSessionRecipe { }; } +function mediaRecipe(): WebSessionRecipe { + return { + site: "linkedin", + action: "media.publish", + contractVersion: 1, + timeoutMs: 1_000, + maxOutputBytes: 2 * 1024 * 1024, + }; +} + +function mp4Fixture(): Buffer { + const bytes = Buffer.alloc(75_000); + bytes.writeUInt32BE(24, 0); + bytes.write("ftyp", 4, "ascii"); + bytes.write("mp42", 8, "ascii"); + return bytes; +} + function pngFixture(width: number, height: number): Buffer { const bytes = Buffer.alloc(24); Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes, 0); @@ -2882,6 +2900,7 @@ describe("LinkedIn authenticated internal-API runtime", () => { expect(bytes).toEqual(new Uint8Array(imageBytes)); return Promise.resolve(mediaUrn); }, + uploadVideo: () => Promise.reject(new Error("image plan must not upload video")), createPost: (subject, profileUrn, variables, receivedMediaUrn) => { events.push("create"); expect(subject).toBe(MEMBER_URN); @@ -2991,6 +3010,163 @@ describe("LinkedIn authenticated internal-API runtime", () => { } }); + test("uploads one plan-bound MP4 and publishes a VIDEO post through media.publish", async () => { + const root = mkdtempSync(join(tmpdir(), "wrench-linkedin-video-")); + chmodSync(root, 0o700); + const videoPath = join(root, "fixture.mp4"); + const videoBytes = mp4Fixture(); + writeFileSync(videoPath, videoBytes, { mode: 0o600 }); + const body = "shipping with captioned video"; + const title = "Launch clip"; + const mediaUrn = "urn:li:fsd_video:C4D22AQExactVideo"; + const entityUrn = "urn:li:fsd_share:urn:li:ugcPost:7512923399980044289"; + const finalUrl = "https://www.linkedin.com/feed/update/urn:li:ugcPost:7512923399980044289/"; + const events: string[] = []; + try { + const transport: LinkedInPostBrowserTransport = { + currentIdentityResponse: () => { + events.push("identity"); + return Promise.resolve(currentIdentityResponse()); + }, + uploadImage: () => Promise.reject(new Error("video plan must not upload image")), + uploadVideo: (subject, bytes) => { + events.push("upload"); + expect(subject).toBe(MEMBER_URN); + expect(bytes).toEqual(new Uint8Array(videoBytes)); + return Promise.resolve(mediaUrn); + }, + createPost: (subject, profileUrn, variables, receivedMediaUrn, mediaCategory) => { + events.push("create"); + expect(subject).toBe(MEMBER_URN); + expect(profileUrn).toBe(ARTICLE_PROFILE_URN); + expect(receivedMediaUrn).toBe(mediaUrn); + expect(mediaCategory).toBe("VIDEO"); + expect(variables).toMatchObject({ + post: { + commentary: { text: body }, + intendedShareLifeCycleState: "PUBLISHED", + media: { category: "VIDEO", mediaUrn, title }, + visibilityDataUnion: { visibilityType: "ANYONE" }, + }, + }); + expect( + (variables.post as { media?: { altText?: unknown } }).media?.altText, + ).toBeUndefined(); + return Promise.resolve(entityUrn); + }, + readPost: (subject, profileUrn, _variables, receivedMediaUrn, receivedEntityUrn, mediaCategory) => { + events.push("readback"); + expect(subject).toBe(MEMBER_URN); + expect(profileUrn).toBe(ARTICLE_PROFILE_URN); + expect(receivedMediaUrn).toBe(mediaUrn); + expect(receivedEntityUrn).toBe(entityUrn); + expect(mediaCategory).toBe("VIDEO"); + return Promise.resolve({ + actorMatched: true, + entityMatched: true, + entityUrn, + lifecycle: "PUBLISHED", + mediaMatched: true, + mediaUrn, + textMatched: true, + url: finalUrl, + }); + }, + close: () => { + events.push("close"); + return Promise.resolve(); + }, + }; + const result = await executeLinkedInWebOperation( + mediaRecipe(), + { + body, + media: { kind: "file", reference: "fixture" }, + title, + visibility: "public", + }, + linkedinAuth, + { + fileResolver: (files) => { + expect(files).toEqual([{ kind: "file", reference: "fixture" }]); + return Promise.resolve([videoPath]); + }, + dependencies: { + createPostBrowserTransport: () => Promise.resolve(transport), + }, + afterProviderAcceptedMutationTarget: (event) => { + expect(event).toEqual({ + id: "media.publish", + index: 1, + target: { + schemaVersion: 1, + identifier: canonicalJson({ entityUrn, mediaUrn }), + }, + }); + events.push("accepted"); + return Promise.resolve(); + }, + }, + ); + expect(result).toMatchObject({ + status: "succeeded", + output: { + provider: "linkedin", + operation: "media.publish", + post: { entityUrn, url: finalUrl }, + visibility: "public", + video: { mediaType: "video/mp4", title }, + }, + finalUrl, + dispatchStarted: true, + dispatch: { planned: 1, started: 1, verified: 1 }, + }); + expect(events).toEqual([ + "identity", + "upload", + "create", + "accepted", + "readback", + "close", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("rejects non-MP4 video bytes before any transport is opened", async () => { + const root = mkdtempSync(join(tmpdir(), "wrench-linkedin-video-")); + chmodSync(root, 0o700); + const videoPath = join(root, "fixture.mp4"); + const bytes = Buffer.alloc(80_000); + bytes.write("IHDR", 12, "ascii"); + writeFileSync(videoPath, bytes, { mode: 0o600 }); + let transports = 0; + try { + await expect(executeLinkedInWebOperation( + mediaRecipe(), + { + body: "rejects a renamed PNG", + media: { kind: "file", reference: "fixture" }, + visibility: "public", + }, + linkedinAuth, + { + fileResolver: () => Promise.resolve([videoPath]), + dependencies: { + createPostBrowserTransport: () => { + transports += 1; + return Promise.reject(new Error("must not open a transport")); + }, + }, + }, + )).rejects.toThrow("ISO-BMFF MP4"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(transports).toBe(0); + }); + test("keeps a preparatory upload failure before public-post admission", async () => { const root = mkdtempSync(join(tmpdir(), "wrench-linkedin-upload-failure-")); chmodSync(root, 0o700); @@ -3006,6 +3182,7 @@ describe("LinkedIn authenticated internal-API runtime", () => { uploads += 1; return Promise.reject(new Error("uncertain upload result")); }, + uploadVideo: () => Promise.reject(new Error("image plan must not upload video")), createPost: () => { creates += 1; return Promise.reject(new Error("must not create after upload failure")); @@ -3063,6 +3240,7 @@ describe("LinkedIn authenticated internal-API runtime", () => { uploads += 1; return Promise.resolve("urn:li:digitalmediaAsset:C4D22AQPreparedImage"); }, + uploadVideo: () => Promise.reject(new Error("image plan must not upload video")), createPost: () => { creates += 1; return Promise.reject(new LinkedInPostCreateResponseError( @@ -3122,6 +3300,7 @@ describe("LinkedIn authenticated internal-API runtime", () => { uploads += 1; return Promise.reject(new Error("accepted-target read must not upload")); }, + uploadVideo: () => Promise.reject(new Error("image plan must not upload video")), createPost: () => { creates += 1; return Promise.reject(new Error("accepted-target read must not create")); @@ -3201,6 +3380,7 @@ describe("LinkedIn authenticated internal-API runtime", () => { uploads += 1; return Promise.reject(new Error("must not upload")); }, + uploadVideo: () => Promise.reject(new Error("image plan must not upload video")), createPost: () => Promise.reject(new Error("must not create")), readPost: () => Promise.reject(new Error("must not read")), close: () => Promise.resolve(), diff --git a/src/providers/linkedin-web-runtime.ts b/src/providers/linkedin-web-runtime.ts index c244cdc1..4fef24b2 100644 --- a/src/providers/linkedin-web-runtime.ts +++ b/src/providers/linkedin-web-runtime.ts @@ -91,6 +91,7 @@ import { linkedInParentCommentTarget, linkedInPostAltText, linkedInPostEntityUrn, + linkedInPostMediaTitle, linkedInPostMediaUrn, linkedInPostText, linkedInPostVisibility, @@ -163,6 +164,8 @@ const MAX_LINKEDIN_ARTICLE_CHARACTERS = 125_000; const MAX_LINKEDIN_ARTICLE_INLINE_IMAGES = 20; const MAX_LINKEDIN_ARTICLE_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_LINKEDIN_POST_IMAGE_BYTES = 20 * 1024 * 1024; +const MIN_LINKEDIN_POST_VIDEO_BYTES = 75_000; +const MAX_LINKEDIN_POST_VIDEO_BYTES = 64 * 1024 * 1024; type JsonRecord = Record; @@ -1319,6 +1322,97 @@ async function materializeLinkedInPostImage( } } +type LinkedInPostVideo = { + readonly bytes: Uint8Array; +}; + +function linkedInVideoFileInput(value: unknown): FileInputValue { + if (!isRecord(value)) { + throw new Error("LinkedIn media.publish media must be one plan-bound MP4"); + } + const descriptor = value; + if ( + !hasExactKeys(descriptor, ["kind", "reference"]) + || descriptor.kind !== "file" + || typeof descriptor.reference !== "string" + || descriptor.reference.length < 1 + || descriptor.reference.length > 1_024 + ) throw new Error("LinkedIn media.publish media must be one plan-bound MP4"); + return descriptor as FileInputValue; +} + +async function materializeLinkedInPostVideo( + media: FileInputValue, + fileResolver: BrowserFileResolver | undefined, + operationDeadline: WebSessionOperationDeadline | undefined, +): Promise { + if (fileResolver === undefined) { + throw new Error("LinkedIn video upload requires the plan-bound file resolver"); + } + const resolveFile = () => fileResolver([media]); + const paths = operationDeadline === undefined + ? await resolveFile() + : await operationDeadline.run( + resolveFile, + "authenticated web operation deadline", + ); + if (paths.length !== 1 || typeof paths[0] !== "string") { + throw new Error("LinkedIn video resolver did not return exactly one file"); + } + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = operationDeadline === undefined + ? await open(paths[0], constants.O_RDONLY | noFollow) + : await operationDeadline.run( + () => open(paths[0]!, constants.O_RDONLY | noFollow), + "authenticated web operation deadline", + ); + try { + const before = operationDeadline === undefined + ? await handle.stat() + : await operationDeadline.run( + () => handle.stat(), + "authenticated web operation deadline", + ); + if ( + !before.isFile() + || before.size < MIN_LINKEDIN_POST_VIDEO_BYTES + || before.size > MAX_LINKEDIN_POST_VIDEO_BYTES + ) throw new Error("LinkedIn video must be a regular MP4 between 75,000 bytes and 64 MiB"); + const bytes = operationDeadline === undefined + ? await handle.readFile() + : await operationDeadline.run( + () => handle.readFile(), + "authenticated web operation deadline", + ); + const after = operationDeadline === undefined + ? await handle.stat() + : await operationDeadline.run( + () => handle.stat(), + "authenticated web operation deadline", + ); + if ( + before.dev !== after.dev + || before.ino !== after.ino + || before.size !== after.size + || before.mtimeMs !== after.mtimeMs + || before.ctimeMs !== after.ctimeMs + || bytes.byteLength !== before.size + ) throw new Error("LinkedIn video changed while it was materialized"); + const brand = bytes.subarray(8, 12).toString("ascii"); + if ( + bytes.byteLength < 12 + || bytes.subarray(4, 8).toString("ascii") !== "ftyp" + || !/^[A-Za-z0-9 ]{3,4}$/u.test(brand) + || brand.trim() === "qt" + ) throw new Error("LinkedIn video must be an ISO-BMFF MP4 fixture"); + return Object.freeze({ + bytes: new Uint8Array(bytes), + }); + } finally { + await handle.close(); + } +} + async function createLinkedInPostTransport( auth: GhostgetAuth, timeoutMs: number, @@ -1381,19 +1475,32 @@ async function readLinkedInWebAcceptedPostTargetPresenceInternal( entityUrn: string; mediaUrn: string | null; }>> { - if ( - recipe.site !== "linkedin" - || recipe.action !== "posts.publish" - || recipe.contractVersion !== 3 - ) throw new Error("LinkedIn accepted post readback supports only posts.publish@3"); + const isPostPublish = recipe.action === "posts.publish" && recipe.contractVersion === 3; + const isMediaPublish = recipe.action === "media.publish" && recipe.contractVersion === 1; + if (recipe.site !== "linkedin" || (!isPostPublish && !isMediaPublish)) { + throw new Error("LinkedIn accepted post readback supports only posts.publish@3 or media.publish@1"); + } if (input.media_title !== undefined || input.link_url !== undefined) { - throw new Error("LinkedIn reviewed post publishing supports text or one PNG image only"); + throw new Error("LinkedIn reviewed post publishing supports text plus one reviewed media attachment only"); } const target = parseLinkedInAcceptedPostTarget(acceptedIdentifier); const body = linkedInPostText(input.body); const visibility = linkedInPostVisibility(input.visibility); - const media = linkedInPostFileInput(input.media); - const altText = linkedInPostAltText(input.alt_text, media !== null); + const media = isMediaPublish + ? linkedInVideoFileInput(input.media) + : linkedInPostFileInput(input.media); + const altText = isMediaPublish + ? null + : linkedInPostAltText(input.alt_text, media !== null); + if (isMediaPublish && input.alt_text !== undefined) { + throw new Error("LinkedIn media.publish readback does not bind alt_text"); + } + if (isMediaPublish && target.mediaUrn === null) { + throw new Error("LinkedIn accepted video target omitted its media URN"); + } + const mediaTitle = isMediaPublish + ? linkedInPostMediaTitle(input.title) + : null; if ((media !== null) !== (target.mediaUrn !== null)) { throw new Error("LinkedIn accepted post target did not bind the confirmed media input"); } @@ -1408,6 +1515,8 @@ async function readLinkedInWebAcceptedPostTargetPresenceInternal( const variables = buildLinkedInPostCreateVariables({ altText, body, + mediaCategory: isMediaPublish ? "VIDEO" : "IMAGE", + mediaTitle, mediaUrn: target.mediaUrn, visibility, }); @@ -1418,6 +1527,7 @@ async function readLinkedInWebAcceptedPostTargetPresenceInternal( variables, target.mediaUrn, target.entityUrn, + isMediaPublish ? "VIDEO" : "IMAGE", ), { body, mediaUrn: target.mediaUrn, profileUrn }, ); @@ -1777,6 +1887,140 @@ async function executeLinkedInPostPublish( } } +async function executeLinkedInMediaPublish( + recipe: WebSessionRecipe, + input: OperationInput, + auth: GhostgetAuth, + options: LinkedInWebExecutionOptions, +): Promise { + if ( + recipe.site !== "linkedin" + || recipe.action !== "media.publish" + || recipe.contractVersion !== 1 + ) throw new Error("LinkedIn media publishing supports only media.publish@1"); + if ( + input.alt_text !== undefined + || input.media_title !== undefined + || input.link_url !== undefined + ) { + throw new Error("LinkedIn reviewed media publishing supports body, visibility, one MP4, and an optional title only"); + } + const body = linkedInPostText(input.body); + const visibility = linkedInPostVisibility(input.visibility); + const media = linkedInVideoFileInput(input.media); + const mediaTitle = linkedInPostMediaTitle(input.title); + const video = await materializeLinkedInPostVideo( + media, + options.fileResolver, + options.operationDeadline, + ); + let started = 0; + let verified = 0; + let transport: LinkedInPostBrowserTransport | null = null; + let projection: ReturnType | null = null; + let failureStage = "opening the contained post transport"; + try { + transport = await createLinkedInPostTransport(auth, recipe.timeoutMs, options); + failureStage = "current-member binding"; + const identity = identityFromMeResponse(await transport.currentIdentityResponse()); + const profileUrn = requireBoundLinkedInIdentity(identity, auth); + const expectedSubject = webSessionAuthSubject(auth); + if (expectedSubject === null || expectedSubject !== identity.subject) { + throw new Error("LinkedIn current member no longer matches the bound auth subject"); + } + failureStage = "video preparation"; + // Page staging and VIDEO_SHARING upload can leave an orphaned private + // provider asset, but neither can publish a feed post. Keep the durable + // public-post dispatch boundary immediately in front of createPost so a + // preparatory failure remains safely retryable. + const mediaUrn = await transport.uploadVideo(expectedSubject, video.bytes); + const variables = buildLinkedInPostCreateVariables({ + altText: null, + body, + mediaCategory: "VIDEO", + mediaTitle, + mediaUrn, + visibility, + }); + failureStage = "public post dispatch admission"; + await options.beforeDispatch?.( + articleDispatchEvent("media.publish", 1, 1, started, verified), + ); + started = 1; + failureStage = "post create response"; + const entityUrn = await transport.createPost( + expectedSubject, + profileUrn, + variables, + mediaUrn, + "VIDEO", + ); + failureStage = "accepted target retention"; + await options.afterProviderAcceptedMutationTarget?.({ + id: "media.publish", + index: 1, + target: { + schemaVersion: 1, + identifier: canonicalJson({ entityUrn, mediaUrn }), + }, + }); + failureStage = "independent post readback"; + projection = normalizeLinkedInPostProjection( + await transport.readPost( + expectedSubject, + profileUrn, + variables, + mediaUrn, + entityUrn, + "VIDEO", + ), + { body, mediaUrn, profileUrn }, + ); + verified = 1; + failureStage = "dispatch verification"; + await options.afterDispatchVerified?.( + articleDispatchEvent("media.publish", 1, 1, started, verified), + ); + return { + status: "succeeded", + output: Object.freeze({ + provider: "linkedin", + operation: "media.publish", + post: Object.freeze({ + entityUrn: projection.entityUrn, + url: projection.url, + }), + visibility, + video: Object.freeze({ + mediaType: "video/mp4", + title: mediaTitle, + }), + }), + finalUrl: projection.url, + dispatchStarted: true, + dispatch: { planned: 1, started, verified }, + }; + } catch (error) { + const publicFailureStage = error instanceof LinkedInPostImagePreparationError + ? error.stage + : error instanceof LinkedInPostCreateResponseError + ? error.stage + : failureStage; + return { + status: started > verified ? "indeterminate" : "failed", + output: null, + finalUrl: projection?.url ?? null, + dispatchStarted: started > 0, + dispatch: { planned: 1, started, verified }, + error: started > verified + ? `LinkedIn may have accepted the video post but exact member, text, media, and permalink readback was not verified; failure stage: ${publicFailureStage}; reconcile before retrying` + : `LinkedIn video publishing failed before public post submission; failure stage: ${publicFailureStage}; retry with a fresh confirmed plan`, + }; + } finally { + await transport?.close(); + } +} + async function createLinkedInCommentTransport( auth: GhostgetAuth, timeoutMs: number, @@ -2782,6 +3026,27 @@ export async function executeLinkedInWebOperation( browserCleanupBarrier, ); } + if ( + recipe.site === "linkedin" + && recipe.contractVersion === 1 + && recipe.action === "media.publish" + ) { + return startWebSessionCleanupTrackedOperation( + options.registerCleanupBarrier, + (publishCleanupResource) => executeLinkedInMediaPublish( + recipe, + input, + auth, + { + ...options, + ...(publishCleanupResource === undefined + ? {} + : { publishCleanupResource }), + }, + ), + browserCleanupBarrier, + ); + } if ( recipe.site === "linkedin" && recipe.contractVersion === 2 diff --git a/src/providers/linkedin-web.test.ts b/src/providers/linkedin-web.test.ts index 6c97dd5b..2369f276 100644 --- a/src/providers/linkedin-web.test.ts +++ b/src/providers/linkedin-web.test.ts @@ -43,7 +43,6 @@ import { linkedInOrganizationTarget, linkedInParentCommentTarget, linkedInPersonalProfileTarget, - linkedInPostReadbackUrl, linkedInWebFolderCategory, normalizeLinkedInCommentsProjection, normalizeLinkedInGraphqlEnvelope, @@ -111,6 +110,7 @@ describe("LinkedIn internal-web operation registry", () => { "comments.read", "contacts.read", "feeds.read", + "media.publish", "organizations.read", "posts.publish", "profiles.read", @@ -120,7 +120,7 @@ describe("LinkedIn internal-web operation registry", () => { const contract = LINKEDIN_WEB_OPERATIONS[operation]; expect(contract.state).toBe(observed.has(operation) ? "observed" : "capture-required"); expect(contract.requests).toHaveLength( - operation === "posts.publish" ? 5 + operation === "posts.publish" || operation === "media.publish" ? 5 : operation === "contacts.read" ? 3 : operation === "comments.create" || operation === "replies.create" ? 2 @@ -135,8 +135,10 @@ describe("LinkedIn internal-web operation registry", () => { expect(LINKEDIN_WEB_OPERATIONS["articles.draft.save"].risk).toBe("R2"); expect(LINKEDIN_WEB_OPERATIONS["articles.draft.save"].evidence).toBe("live-har"); expect(LINKEDIN_WEB_OPERATIONS["posts.publish"].evidence).toBe("first-party-bundle"); + expect(LINKEDIN_WEB_OPERATIONS["media.publish"].evidence).toBe("live-har"); for (const operation of [ "messaging.send", + "media.publish", "posts.publish", "posts.repost", "posts.quote", @@ -391,7 +393,6 @@ describe("LinkedIn native post contract", () => { post: { allowedCommentersScope: "ALL", commentary: { - $type: "com.linkedin.voyager.dash.deco.common.text.TextViewModelV2", attributesV2: [], text: body, }, @@ -422,16 +423,7 @@ describe("LinkedIn native post contract", () => { expect("altText" in ((withoutAlt.post as { media: object }).media)).toBeFalse(); }); - test("builds an exact registered readback and rejects projection drift", () => { - const url = linkedInPostReadbackUrl(entityUrn); - expect(url.pathname).toBe("/voyager/api/graphql"); - expect(url.searchParams.get("includeWebMetadata")).toBe("true"); - expect(url.searchParams.get("queryId")).toBe( - "voyagerFeedDashUpdates.00f9ed72d35c2a949114759b829f9886", - ); - expect(url.searchParams.get("variables")).toBe( - `(moduleKey:feed-item:desktop,urnOrNss:${entityUrn})`, - ); + test("rejects projection drift from the independent permalink readback", () => { const projection = { actorMatched: true, entityMatched: true, diff --git a/src/providers/linkedin-web.ts b/src/providers/linkedin-web.ts index 39e57b67..6964293f 100644 --- a/src/providers/linkedin-web.ts +++ b/src/providers/linkedin-web.ts @@ -18,8 +18,6 @@ export const LINKEDIN_MESSENGER_CONVERSATIONS_OBSERVED_QUERY_ID = export const LINKEDIN_MESSENGER_GRAPHQL_PATH = "/voyager/api/voyagerMessagingGraphQL/graphql"; export const LINKEDIN_POST_CREATE_MUTATION_ID = "voyagerContentcreationDashShares.80089eb2e82a2dfa23cb621fb09eb7bf"; -export const LINKEDIN_POST_READBACK_QUERY_ID = - "voyagerFeedDashUpdates.00f9ed72d35c2a949114759b829f9886"; export const LINKEDIN_GRAPHQL_PATH = "/voyager/api/graphql"; export const LINKEDIN_COMMENT_CREATE_PATH = "/voyager/api/voyagerSocialDashNormComments"; @@ -215,9 +213,55 @@ export const LINKEDIN_WEB_OPERATIONS = { "media.publish": { effect: "write", risk: "R3", - state: "capture-required", - evidence: "none", - requests: [], + state: "observed", + evidence: "live-har", + requests: [ + { + kind: "restli-write", + method: "POST", + path: "/voyager/api/voyagerVideoDashMediaUploadMetadata", + queryId: null, + fixedQueryParameters: [["action", "upload"]], + bodyContract: "VIDEO_SHARING registration for the exact plan-bound MP4 size and fixed filename", + targetHostnameFamilies: ["linkedin.com"], + }, + { + kind: "server-bound-upload", + method: "PUT", + path: "server-returned exact upload URL", + queryId: null, + fixedQueryParameters: [], + bodyContract: "exact registered MP4 bytes once, or exact contiguous registered parts once", + targetHostnameFamilies: ["linkedin.com", "licdn.com"], + }, + { + kind: "restli-write", + method: "POST", + path: "/voyager/api/voyagerVideoDashMediaUploadMetadata", + queryId: null, + fixedQueryParameters: [["action", "completeMultipartUpload"]], + bodyContract: "registered artifact, multipart metadata, and exact per-part response evidence only", + targetHostnameFamilies: ["linkedin.com"], + }, + { + kind: "registered-mutation", + method: "POST", + path: LINKEDIN_GRAPHQL_PATH, + queryId: LINKEDIN_POST_CREATE_MUTATION_ID, + fixedQueryParameters: [["action", "execute"]], + bodyContract: "one PUBLISHED FEED post with exact commentary, fixed commenter scope, confirmed visibility, and a response-bound VIDEO media URN with optional exact title", + targetHostnameFamilies: ["linkedin.com"], + }, + { + kind: "server-rendered-read", + method: "GET", + path: "/feed/update/:acceptedUrn/ bounded post permalink HTML", + queryPrefix: null, + allowedQueryParameters: [], + requiredQueryParameters: [], + fixedQueryParameters: [], + }, + ], }, "posts.read": { effect: "read", @@ -269,16 +313,13 @@ export const LINKEDIN_WEB_OPERATIONS = { targetHostnameFamilies: ["linkedin.com"], }, { - kind: "registered-query", + kind: "server-rendered-read", method: "GET", - path: LINKEDIN_GRAPHQL_PATH, - queryPrefix: "voyagerFeedDashUpdates", - allowedQueryParameters: ["includeWebMetadata", "queryId", "variables"], - requiredQueryParameters: ["includeWebMetadata", "queryId", "variables"], - fixedQueryParameters: [ - ["includeWebMetadata", "true"], - ["queryId", LINKEDIN_POST_READBACK_QUERY_ID], - ], + path: "/feed/update/:acceptedUrn/ bounded post permalink HTML", + queryPrefix: null, + allowedQueryParameters: [], + requiredQueryParameters: [], + fixedQueryParameters: [], }, ], }, @@ -2840,11 +2881,15 @@ export function linkedInMessengerConversationsUrl( export type LinkedInPostVisibility = "public" | "connections"; +export type LinkedInPostMediaCategory = "IMAGE" | "VIDEO"; + export type LinkedInPostCreateInput = { readonly body: string; readonly visibility: LinkedInPostVisibility; readonly mediaUrn: string | null; readonly altText: string | null; + readonly mediaCategory?: LinkedInPostMediaCategory; + readonly mediaTitle?: string | null; }; export type LinkedInPostProjection = { @@ -2870,6 +2915,17 @@ export function linkedInPostVisibility(value: unknown): LinkedInPostVisibility { return value; } +export function linkedInPostMediaTitle(value: unknown): string | null { + if (value === undefined) return null; + if ( + typeof value !== "string" + || value.length < 1 + || value.length > 200 + || /[\0\r\n]/u.test(value) + ) throw new Error("LinkedIn video title must be 1-200 plain-text characters"); + return value; +} + export function linkedInPostAltText(value: unknown, mediaPresent: boolean): string | null { if (value === undefined) return null; if (!mediaPresent) throw new Error("LinkedIn alt_text requires one reviewed image"); @@ -2886,8 +2942,8 @@ export function linkedInPostMediaUrn(value: unknown): string { if ( typeof value !== "string" || value.length > 512 - || !/^urn:li:(?:digitalmediaAsset|fsd_image):[A-Za-z0-9_(),.:%=-]{1,448}$/u.test(value) - ) throw new Error("LinkedIn image upload returned an invalid media URN"); + || !/^urn:li:(?:digitalmediaAsset|fsd_image|fsd_video):[A-Za-z0-9_(),.:%=-]{1,448}$/u.test(value) + ) throw new Error("LinkedIn media upload returned an invalid media URN"); return value; } @@ -2895,7 +2951,11 @@ export function linkedInPostEntityUrn(value: unknown): string { if ( typeof value !== "string" || value.length > 512 - || !/^urn:li:(?:fsd_share|share|ugcPost):[A-Za-z0-9_(),.:%=-]{1,448}$/u.test(value) + || ( + !/^urn:li:(?:fsd_share|share|ugcPost|activity):[A-Za-z0-9_(),.:%=-]{1,448}$/u.test(value) + && !/^urn:li:fsd_update:\(urn:li:activity:\d{10,20},[A-Za-z0-9_(),.%=-]{1,380}\)$/u.test(value) + && !/^urn:li:fsd_share:urn:li:(?:ugcPost|share|activity):[0-9]{10,20}$/u.test(value) + ) ) throw new Error("LinkedIn post response returned an invalid entity URN"); return value; } @@ -2907,11 +2967,25 @@ export function buildLinkedInPostCreateVariables( const body = linkedInPostText(input.body); const visibility = linkedInPostVisibility(input.visibility); const mediaUrn = input.mediaUrn === null ? null : linkedInPostMediaUrn(input.mediaUrn); - const altText = linkedInPostAltText(input.altText ?? undefined, mediaUrn !== null); + const mediaCategory = input.mediaCategory ?? "IMAGE"; + if (mediaCategory !== "IMAGE" && mediaCategory !== "VIDEO") { + throw new Error("LinkedIn media category must be IMAGE or VIDEO"); + } + if (mediaCategory === "VIDEO" && mediaUrn === null) { + throw new Error("LinkedIn VIDEO posts require a registered media URN"); + } + const altText = mediaCategory === "VIDEO" + ? null + : linkedInPostAltText(input.altText ?? undefined, mediaUrn !== null); + const mediaTitle = mediaCategory === "VIDEO" + ? linkedInPostMediaTitle(input.mediaTitle ?? undefined) + : null; + if (mediaCategory === "VIDEO" && input.altText !== undefined && input.altText !== null) { + throw new Error("LinkedIn VIDEO posts carry the video title, not alt_text"); + } const post: Record = { allowedCommentersScope: "ALL", commentary: { - $type: "com.linkedin.voyager.dash.deco.common.text.TextViewModelV2", attributesV2: [], text: body, }, @@ -2924,28 +2998,16 @@ export function buildLinkedInPostCreateVariables( }; if (mediaUrn !== null) { post.media = { - category: "IMAGE", + category: mediaCategory, mediaUrn, tapTargets: [], ...(altText === null ? {} : { altText }), + ...(mediaTitle === null ? {} : { title: mediaTitle }), }; } return Object.freeze({ post: Object.freeze(post) }); } -/** Exact current registered GraphQL readback for one response-bound backend share URN. */ -export function linkedInPostReadbackUrl(entityUrnValue: unknown): URL { - const entityUrn = linkedInPostEntityUrn(entityUrnValue); - const url = new URL(LINKEDIN_GRAPHQL_PATH, "https://www.linkedin.com"); - url.searchParams.set("includeWebMetadata", "true"); - url.searchParams.set("queryId", LINKEDIN_POST_READBACK_QUERY_ID); - url.searchParams.set( - "variables", - `(moduleKey:feed-item:desktop,urnOrNss:${entityUrn})`, - ); - return url; -} - /** Parse the minimal code-owned browser projection after create plus independent readback. */ export function normalizeLinkedInPostProjection( value: unknown, diff --git a/src/providers/meta-web-runtime.ts b/src/providers/meta-web-runtime.ts index 9a8a54fe..aed0e279 100644 --- a/src/providers/meta-web-runtime.ts +++ b/src/providers/meta-web-runtime.ts @@ -102,6 +102,7 @@ import { parseMetaJsonScripts, parseInstagramViewerId, parseThreadsViewerId, + projectThreadsPublishPost, projectThreadsPublishVideo, ThreadsAuthRepairRequiredError, type FacebookMarketplaceFeed, @@ -158,11 +159,16 @@ const THREADS_ASBD_ID = "359341"; const INSTAGRAM_UPLOAD_ORIGIN = "https://i.instagram.com"; const INSTAGRAM_WEB_APP_ID = "936619743392459"; const INSTAGRAM_ASBD_ID = "359341"; +const MAX_THREADS_TRANSCODE_POLLS = 15; +const THREADS_TRANSCODE_POLL_MS = 2_000; +const defaultSleep = (milliseconds: number): Promise => + new Promise((resolve) => setTimeout(resolve, milliseconds)); export type MetaWebRuntimeDependencies = Partial & { readonly createInstagramProfileBrowserTransport?: typeof createInstagramProfileBrowserTransport; readonly now?: () => number; + readonly sleep?: (milliseconds: number) => Promise; }; function metaWebSessionDependencies( @@ -1032,6 +1038,13 @@ type ThreadsUploadedMedia = Readonly<{ width: number; }>; +/** Threads embeds drift between the bare post pk and its "_" id; compare the canonical bare form. */ +function threadsBareMediaId(mediaId: string, viewerId: string): string { + return mediaId.endsWith(`_${viewerId}`) + ? mediaId.slice(0, mediaId.length - viewerId.length - 1) + : mediaId; +} + function assertThreadsUploadAcknowledgement( value: unknown, uploadId: string, @@ -1044,6 +1057,32 @@ function assertThreadsUploadAcknowledgement( ) throw new Error("Threads upload acknowledgement did not bind the exact upload ID"); } +/** The video rupload now answers {status:"ok", media_id:} instead of + * echoing the upload id; bind either exact shape and keep upload_id in the + * configure form since the entity name carries the upload session. */ +function assertThreadsVideoUploadAcknowledgement( + value: unknown, + uploadId: string, +): void { + if (!isRecord(value) || value.status !== "ok") { + throw new Error("Threads video upload acknowledgement did not complete"); + } + if (hasExactKeys(value, ["status", "upload_id"])) { + if (value.upload_id !== uploadId) { + throw new Error("Threads video upload acknowledgement did not bind the exact upload ID"); + } + return; + } + const mediaId = value.media_id; + if ( + !hasExactKeys(value, ["status", "media_id"]) + || !( + (typeof mediaId === "number" && Number.isInteger(mediaId) && mediaId > 0) + || (typeof mediaId === "string" && /^[0-9]{1,32}$/u.test(mediaId)) + ) + ) throw new Error("Threads video upload acknowledgement did not bind the exact upload ID"); +} + async function uploadThreadsImage( client: WebSessionClient, image: ThreadsImage, @@ -1121,7 +1160,7 @@ async function uploadThreadsVideo( expectedContentTypes: ["application/json"], maxBytes: MAX_THREADS_UPLOAD_ACKNOWLEDGEMENT_BYTES, }); - assertThreadsUploadAcknowledgement(acknowledgement, uploadId); + assertThreadsVideoUploadAcknowledgement(acknowledgement, uploadId); return Object.freeze({ height: video.height, id: uploadId, @@ -1213,39 +1252,65 @@ function threadsCreatedPost( "Threads create response did not match the reviewed success shape", ); } + let responseMedia: Readonly<{ height: number; width: number }> | null = null; const minimalLocator = hasExactKeys(value.media, ["code", "permalink", "pk"]); - if (!minimalLocator && uploaded?.mediaType !== 2) { - throw new ThreadsCreateResponseError( - "success-shape", - "Threads create response did not match the reviewed success shape", - ); - } if (!minimalLocator) { + const upload = uploaded; try { - const projected = projectThreadsPublishVideo( - value.media, - "Threads video create response media", - ); - const projectedUser = isRecord(projected.user) ? projected.user : null; - if ( - projected.caption !== expectedBody - || projected.user === null - || projectedUser?.id !== viewerId - || projected.video === null - || projected.video.mediaId !== projected.id - || uploaded === null - || projected.video.mediaType !== uploaded.mediaType - || projected.video.width !== uploaded.width - || projected.video.height !== uploaded.height - || (projected.canonical_url !== null - && projected.canonical_url !== value.media.permalink) - ) { - throw new Error("unbound rich video response"); + if (upload !== null && upload.mediaType === 2) { + const video = projectThreadsPublishVideo( + value.media, + "Threads video create response media", + ); + const videoUser = isRecord(video.user) ? video.user : null; + // Threads transcodes MP4 uploads, so the response carries the stored + // rendition's dimensions rather than the source file's. Bind the + // response-reported rendition and let the permalink readback prove the + // same dimensions on the published post. + if ( + video.caption !== expectedBody + || videoUser?.id !== viewerId + || video.video === null + || video.video.mediaId !== video.id + || video.video.mediaType !== upload.mediaType + || video.video.width < 1 + || video.video.height < 1 + || (video.canonical_url !== null + && video.canonical_url !== value.media.permalink) + ) { + throw new Error("unbound rich video response"); + } + responseMedia = Object.freeze({ + height: video.video.height, + width: video.video.width, + }); + } else { + const projected = projectThreadsPublishPost( + value.media, + "Threads create response media", + ); + const projectedUser = isRecord(projected.user) ? projected.user : null; + const imageBound = upload === null + ? projected.image === null + : projected.image !== null + && projected.image.mediaId === projected.id + && projected.image.mediaType === upload.mediaType + && projected.image.width === upload.width + && projected.image.height === upload.height; + if ( + projected.caption !== expectedBody + || projectedUser?.id !== viewerId + || !imageBound + || (projected.canonical_url !== null + && projected.canonical_url !== value.media.permalink) + ) { + throw new Error("unbound rich post response"); + } } } catch { throw new ThreadsCreateResponseError( "success-shape", - "Threads video create response did not bind the confirmed actor, text, and media", + "Threads create response did not bind the confirmed actor, text, and media", ); } } @@ -1300,10 +1365,10 @@ function threadsCreatedPost( media: uploaded === null ? null : Object.freeze({ - height: uploaded.height, + height: responseMedia?.height ?? uploaded.height, mediaId: pk, mediaType: uploaded.mediaType, - width: uploaded.width, + width: responseMedia?.width ?? uploaded.width, }), }); } @@ -1328,6 +1393,10 @@ async function createThreadsPost( uploaded: ThreadsUploadedMedia | null, config: ThreadsRequestConfig, uploadId: string, + options: { + readonly operationDeadline?: WebSessionOperationDeadline; + readonly sleep: (milliseconds: number) => Promise; + }, ): Promise { const csrfToken = webSessionCookie(client.cookies, "csrftoken"); const webSessionId = threadsWebSessionId(uploadId); @@ -1346,7 +1415,12 @@ async function createThreadsPost( ); let response: unknown; try { - response = await client.requestJson({ + // Video uploads transcode asynchronously; the create endpoint answers a + // bounded 202 {"status":"fail","message":"Transcode not finished yet."} + // until the transcode completes. That exact response is not a published + // post, so the identical form may be retried a bounded number of times. + // Every other status or body stays fail-closed. + const createRequest = () => client.requestJsonResponse({ url: new URL("/api/v1/media/configure_text_post_app_feed/", ORIGINS.threads), method: "POST", headers: threadsApiHeaders( @@ -1356,11 +1430,35 @@ async function createThreadsPost( "application/x-www-form-urlencoded;charset=UTF-8", ), body: form.toString(), - expectedStatuses: [200], + expectedStatuses: [200, 202], expectedContentTypes: ["application/json", "text/plain"], maxBytes: 256 * 1024, }); + const transcodePending = (result: { readonly status: number; readonly value: unknown }) => + result.status === 202 + && isRecord(result.value) + && hasExactKeys(result.value, ["message", "status"]) + && result.value.status === "fail" + && result.value.message === "Transcode not finished yet."; + let createResult = await createRequest(); + for ( + let attempt = 0; + attempt < MAX_THREADS_TRANSCODE_POLLS && transcodePending(createResult); + attempt += 1 + ) { + options.operationDeadline?.throwIfUnavailable("authenticated web operation deadline"); + await options.sleep(THREADS_TRANSCODE_POLL_MS); + createResult = await createRequest(); + } + if (createResult.status !== 200) { + throw new ThreadsCreateResponseError( + "success-shape", + "Threads create response did not match the reviewed success shape", + ); + } + response = createResult.value; } catch (error) { + if (error instanceof ThreadsCreateResponseError) throw error; throw new ThreadsCreateResponseError( threadsCreateRequestFailureCategory(error), "Threads create request did not return one reviewed response", @@ -1391,6 +1489,7 @@ async function executeThreadsPost( ) => Promise; readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise; readonly now: () => number; + readonly sleep: (milliseconds: number) => Promise; }, ): Promise { const image = prepared.attachment === undefined @@ -1433,6 +1532,12 @@ async function executeThreadsPost( uploaded, config, uploadId, + { + ...(options.operationDeadline === undefined + ? {} + : { operationDeadline: options.operationDeadline }), + sleep: options.sleep, + }, ); const createdImage = created.media; failureStage = "accepted target retention"; @@ -1485,7 +1590,8 @@ async function executeThreadsPost( const remoteImage = post.image; if ( remoteImage === null - || remoteImage.mediaId !== createdImage.mediaId + || threadsBareMediaId(remoteImage.mediaId, dispatchViewer.id) + !== threadsBareMediaId(createdImage.mediaId, dispatchViewer.id) || remoteImage.mediaType !== createdImage.mediaType || remoteImage.width !== createdImage.width || remoteImage.height !== createdImage.height @@ -1546,6 +1652,7 @@ async function executeThreadsVideo( ) => Promise; readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise; readonly now: () => number; + readonly sleep: (milliseconds: number) => Promise; }, ): Promise { const video = await materializeThreadsVideo( @@ -1578,6 +1685,12 @@ async function executeThreadsVideo( uploaded, config, uploadId, + { + ...(options.operationDeadline === undefined + ? {} + : { operationDeadline: options.operationDeadline }), + sleep: options.sleep, + }, ); const createdVideo = created.media; if (createdVideo === null || createdVideo.mediaType !== 2) { @@ -1615,11 +1728,12 @@ async function executeThreadsVideo( created.locator.code, created.locator.url, prepared.body, - video, + { height: createdVideo.height, width: createdVideo.width }, ); const remoteVideo = post.video as ThreadsVideoProjection; if ( - remoteVideo.mediaId !== createdVideo.mediaId + threadsBareMediaId(remoteVideo.mediaId, dispatchViewer.id) + !== threadsBareMediaId(createdVideo.mediaId, dispatchViewer.id) || remoteVideo.mediaType !== createdVideo.mediaType || remoteVideo.width !== createdVideo.width || remoteVideo.height !== createdVideo.height @@ -1860,7 +1974,8 @@ export async function readThreadsWebPublishedMutationTarget( const media = post.video; if ( media === null - || media.mediaId !== mediaTarget.remoteMediaId + || threadsBareMediaId(media.mediaId, viewerId) + !== threadsBareMediaId(mediaTarget.remoteMediaId, viewerId) || media.mediaType !== mediaTarget.mediaType || media.width !== mediaTarget.width || media.height !== mediaTarget.height @@ -1881,7 +1996,8 @@ export async function readThreadsWebPublishedMutationTarget( if (mediaTarget !== null) { if ( media === null - || media.mediaId !== mediaTarget.remoteMediaId + || threadsBareMediaId(media.mediaId, viewerId) + !== threadsBareMediaId(mediaTarget.remoteMediaId, viewerId) || media.mediaType !== mediaTarget.mediaType || media.width !== mediaTarget.width || media.height !== mediaTarget.height @@ -3279,6 +3395,7 @@ async function executeMetaWebOperationInternal( ? {} : { afterDispatchVerified: options.afterDispatchVerified }), now: options.dependencies?.now ?? Date.now, + sleep: options.dependencies?.sleep ?? defaultSleep, }); } if (prepared.kind === "threads-video") { @@ -3300,6 +3417,7 @@ async function executeMetaWebOperationInternal( ? {} : { afterDispatchVerified: options.afterDispatchVerified }), now: options.dependencies?.now ?? Date.now, + sleep: options.dependencies?.sleep ?? defaultSleep, }); } let output: unknown; diff --git a/src/providers/meta-web.ts b/src/providers/meta-web.ts index a6b09d42..64e8b8e5 100644 --- a/src/providers/meta-web.ts +++ b/src/providers/meta-web.ts @@ -2049,22 +2049,30 @@ function threadsVideo( || post.video_versions.length < 1 || post.video_versions.length > 20 ) throw new Error(`${label}.video_versions must contain 1 to 20 videos`); + // The permalink embed keeps per-rendition urls but may omit their dimensions; + // the media-level original_* fields remain the bound rendition. let originalCandidateFound = false; for (const [index, candidateValue] of post.video_versions.entries()) { const candidate = record(candidateValue, `${label}.video_versions[${index}]`); - const candidateWidth = positiveThreadsImageDimension( - candidate.width, - `${label}.video_versions[${index}].width`, - ); - const candidateHeight = positiveThreadsImageDimension( - candidate.height, - `${label}.video_versions[${index}].height`, - ); + const candidateWidth = candidate.width === undefined + ? undefined + : positiveThreadsImageDimension( + candidate.width, + `${label}.video_versions[${index}].width`, + ); + const candidateHeight = candidate.height === undefined + ? undefined + : positiveThreadsImageDimension( + candidate.height, + `${label}.video_versions[${index}].height`, + ); threadsImageCandidateUrl( candidate.url, `${label}.video_versions[${index}].url`, ); - if (candidateWidth === width && candidateHeight === height) { + if (candidateWidth === undefined && candidateHeight === undefined) { + originalCandidateFound = true; + } else if (candidateWidth === width && candidateHeight === height) { originalCandidateFound = true; } } @@ -2198,11 +2206,16 @@ export function normalizeThreadsPostHtml( if (parseThreadsViewerId(html) !== viewerId) { throw new Error("Threads post readback changed its bound viewer"); } + // Threads permalink embeds drift between a bare post pk and the + // actor-suffixed "_" id, and currently omit canonical_url; + // the fetched locator path already binds code, so bind it when present. + const acceptedPostIds = new Set([expectedPostId, `${expectedPostId}_${viewerId}`]); const matches: Readonly>[] = []; walk(parseMetaJsonScripts(html), (value) => { if ( !isRecord(value) - || (value.id !== expectedPostId && value.pk !== expectedPostId) + || (!acceptedPostIds.has(typeof value.id === "string" ? value.id : "") + && !acceptedPostIds.has(typeof value.pk === "string" ? value.pk : "")) || value.caption === undefined || value.user === undefined ) return; @@ -2211,13 +2224,14 @@ export function normalizeThreadsPostHtml( const imageMatches = expectedImage === null ? projected.image === null : projected.image !== null - && projected.image.mediaId === expectedPostId + && acceptedPostIds.has(projected.image.mediaId) && projected.image.width === expectedImage.width && projected.image.height === expectedImage.height; if ( - projected.caption === expectedCaption + acceptedPostIds.has(projected.id) + && projected.caption === expectedCaption && projected.code === expectedCode - && projected.canonical_url === locator.href + && (projected.canonical_url === null || projected.canonical_url === locator.href) && user?.id === viewerId && imageMatches ) { @@ -2274,22 +2288,25 @@ export function normalizeThreadsVideoPostHtml( throw new Error("Threads video post readback changed its bound viewer"); } const matches: ThreadsVideoPostProjection[] = []; + const acceptedPostIds = new Set([expectedPostId, `${expectedPostId}_${viewerId}`]); walk(parseMetaJsonScripts(html), (value) => { if ( !isRecord(value) - || (value.id !== expectedPostId && value.pk !== expectedPostId) + || (!acceptedPostIds.has(typeof value.id === "string" ? value.id : "") + && !acceptedPostIds.has(typeof value.pk === "string" ? value.pk : "")) || value.caption === undefined || value.user === undefined ) return; const projected = projectThreadsPublishVideo(value, "Threads video post readback"); const user = isRecord(projected.user) ? projected.user : null; if ( - projected.caption === expectedCaption + acceptedPostIds.has(projected.id) + && projected.caption === expectedCaption && projected.code === expectedCode - && projected.canonical_url === locator.href + && (projected.canonical_url === null || projected.canonical_url === locator.href) && user?.id === viewerId && projected.video !== null - && projected.video.mediaId === expectedPostId + && acceptedPostIds.has(projected.video.mediaId) && projected.video.width === expectedVideo.width && projected.video.height === expectedVideo.height ) matches.push(projected); diff --git a/src/providers/substack-web-runtime.internal.test.ts b/src/providers/substack-web-runtime.internal.test.ts index 372ae577..46c3e832 100644 --- a/src/providers/substack-web-runtime.internal.test.ts +++ b/src/providers/substack-web-runtime.internal.test.ts @@ -2361,6 +2361,488 @@ describe("Substack authenticated internal API runtime", () => { )).rejects.toThrow("canonical JSON"); }); + test("publishes one MP4 Note through the reviewed upload lifecycle and verifies readback", async () => { + const root = mkdtempSync(join(tmpdir(), "wrench-substack-video-publish-")); + const videoPath = join(root, "fixture.mp4"); + const bytes = mp4Fixture(); + writeFileSync(videoPath, bytes, { mode: 0o600 }); + const mediaUploadId = "33333333-3333-4333-8333-333333333333"; + const multipartUploadId = "multipart-upload-bound-id"; + const signedUrl = new URL( + `https://substack-video.s3-accelerate.amazonaws.com/video_upload/user/${USER_ID}/${mediaUploadId}/original`, + ); + signedUrl.searchParams.set("partNumber", "1"); + signedUrl.searchParams.set("uploadId", multipartUploadId); + signedUrl.searchParams.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + signedUrl.searchParams.set("X-Amz-Date", "20261005T000000Z"); + signedUrl.searchParams.set("X-Amz-Expires", "900"); + signedUrl.searchParams.set("X-Amz-Signature", "a".repeat(64)); + signedUrl.searchParams.set("X-Amz-SignedHeaders", "host"); + signedUrl.searchParams.set("x-id", "PutObject"); + const rawPuts: { url: URL; body: Uint8Array }[] = []; + const calls: CapturedRequest[] = []; + const dispatches: string[] = []; + const targets: string[] = []; + try { + const result = await executeSubstackWebOperation( + recipe("media.publish"), + { body: NOTE_BODY, media: { kind: "file", reference: "fixture" } }, + boundAuth, + { + fileResolver: () => Promise.resolve([videoPath]), + beforeDispatch: (event) => { + dispatches.push(`before:${event.id}:${event.progress.started}`); + return Promise.resolve(); + }, + afterProviderAcceptedMutationTarget: (event) => { + targets.push(event.target.identifier); + return Promise.resolve(); + }, + afterDispatchVerified: (event) => { + dispatches.push(`after:${event.id}:${event.progress.verified}`); + return Promise.resolve(); + }, + dependencies: { + ...dependencies(calls, (request) => { + const bootstrap = bootstrapResponse(request); + if (bootstrap !== null) return bootstrap; + if ( + request.method === "POST" + && request.url.pathname === "/api/v1/video/upload" + ) { + expect(request.url.searchParams.get("filetype")).toBe("video/mp4"); + expect(request.url.searchParams.get("fileSize")) + .toBe(String(bytes.byteLength)); + return jsonResponse({ + mediaUpload: { + id: mediaUploadId, + multipart_upload_id: multipartUploadId, + state: "created", + media_type: "video", + is_mux: true, + user_id: USER_ID, + primary_file_size: bytes.byteLength, + publication_id: null, + post_id: null, + name: "wrench-video.mp4", + parts: [], + }, + multipartUploadId, + multipartUploadUrls: [signedUrl.href], + }); + } + if ( + request.method === "POST" + && request.url.pathname === `/api/v1/video/upload/${mediaUploadId}/transcode` + ) { + const transcode = JSON.parse(request.body ?? "null") as { + multipart_upload_etags?: readonly string[]; + multipart_upload_id?: string; + } | null; + expect(transcode?.multipart_upload_id).toBe(multipartUploadId); + expect(transcode?.multipart_upload_etags).toEqual(['"etag-one"']); + return jsonResponse({ + mediaUpload: { id: mediaUploadId, state: "uploaded" }, + }); + } + if ( + request.method === "GET" + && request.url.pathname === `/api/v1/video/upload/${mediaUploadId}` + ) { + return jsonResponse({ id: mediaUploadId, state: "transcoded" }); + } + if ( + request.method === "POST" + && request.url.pathname === "/api/v1/comment/attachment" + ) { + expect(JSON.parse(request.body ?? "null")).toEqual({ + type: "video", + mediaUploadId, + }); + return jsonResponse({ + comment_id: null, + id: ATTACHMENT_UUID, + media_upload_id: mediaUploadId, + mediaUpload: { id: mediaUploadId, state: "transcoded" }, + type: "video", + user_id: USER_ID, + }); + } + if ( + request.method === "POST" + && request.url.pathname === "/api/v1/comment/feed" + ) { + const create = JSON.parse(request.body ?? "null") as { + attachmentIds?: readonly string[]; + } | null; + expect(create?.attachmentIds).toEqual([ATTACHMENT_UUID]); + return jsonResponse({ + ...(createdNote("unused") as Record), + attachments: [{ + id: ATTACHMENT_UUID, + type: "video", + media_upload_id: mediaUploadId, + }], + }); + } + if ( + request.method === "GET" + && request.url.pathname === `/api/v1/reader/comment/${CREATED_NOTE_ID}` + ) { + const readback = noteReadback("unused") as { + readonly item: Readonly> & { + readonly comment: Readonly>; + }; + }; + return jsonResponse({ + item: { + ...readback.item, + comment: { + ...readback.item.comment, + attachments: [{ + id: ATTACHMENT_UUID, + type: "video", + media_upload_id: mediaUploadId, + mediaUpload: { + id: mediaUploadId, + state: "transcoded", + duration: 12.345, + width: 640, + height: 360, + }, + }], + }, + }, + }); + } + throw new Error(`unexpected ${request.method} ${request.url.pathname}`); + }), + videoUploadFetch: (request) => { + rawPuts.push({ url: request.url, body: request.body }); + return Promise.resolve({ status: 200, etag: '"etag-one"' }); + }, + sleep: () => Promise.resolve(), + }, + }, + ); + expect(rawPuts.map((put) => [put.url.href, put.body.byteLength])).toEqual([ + [signedUrl.href, bytes.byteLength], + ]); + expect(new Uint8Array(rawPuts[0]!.body)).toEqual(new Uint8Array(bytes)); + expect(dispatches).toEqual([ + "before:media.publish:0", + "after:media.publish:1", + ]); + expect(result.status).toBe("succeeded"); + expect(result.dispatch).toEqual({ planned: 1, started: 1, verified: 1 }); + expect(result.finalUrl).toBe( + `https://substack.com/@wrench-reader/note/c-${CREATED_NOTE_ID}`, + ); + expect(targets).toEqual([canonicalJson({ + noteId: CREATED_NOTE_ID, + attachment: { + id: ATTACHMENT_UUID, + mediaUploadId, + durationSeconds: 12.345, + height: 360, + width: 640, + mediaType: "video/mp4", + }, + })]); + expect(calls.map((call) => [call.method, call.url.pathname])).toEqual([ + ["GET", "/api/v1/am_i_logged_in"], + ["GET", "/"], + ["GET", "/api/v1/am_i_logged_in"], + ["GET", "/"], + ["POST", "/api/v1/video/upload"], + ["POST", `/api/v1/video/upload/${mediaUploadId}/transcode`], + ["GET", `/api/v1/video/upload/${mediaUploadId}`], + ["POST", "/api/v1/comment/attachment"], + ["POST", "/api/v1/comment/feed"], + ["GET", `/api/v1/reader/comment/${CREATED_NOTE_ID}`], + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails a video Note before dispatch when the signed URL leaves the upload origin", async () => { + const root = mkdtempSync(join(tmpdir(), "wrench-substack-video-origin-")); + const videoPath = join(root, "fixture.mp4"); + writeFileSync(videoPath, mp4Fixture(), { mode: 0o600 }); + const calls: CapturedRequest[] = []; + const puts: unknown[] = []; + try { + const result = await executeSubstackWebOperation( + recipe("media.publish"), + { body: NOTE_BODY, media: { kind: "file", reference: "fixture" } }, + boundAuth, + { + fileResolver: () => Promise.resolve([videoPath]), + beforeDispatch: () => { + throw new Error("dispatch fence must not run"); + }, + dependencies: { + ...dependencies(calls, (request) => { + const bootstrap = bootstrapResponse(request); + if (bootstrap !== null) return bootstrap; + if ( + request.method === "POST" + && request.url.pathname === "/api/v1/video/upload" + ) { + const escaped = new URL( + `https://example.invalid/video_upload/user/${USER_ID}/33333333-3333-4333-8333-333333333333/original`, + ); + escaped.searchParams.set("partNumber", "1"); + escaped.searchParams.set("uploadId", "multipart-id"); + escaped.searchParams.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + escaped.searchParams.set("X-Amz-Date", "20261005T000000Z"); + escaped.searchParams.set("X-Amz-Expires", "900"); + escaped.searchParams.set("X-Amz-Signature", "a".repeat(64)); + escaped.searchParams.set("X-Amz-SignedHeaders", "host"); + escaped.searchParams.set("x-id", "PutObject"); + return jsonResponse({ + mediaUpload: { + id: "33333333-3333-4333-8333-333333333333", + multipart_upload_id: "multipart-id", + state: "created", + media_type: "video", + is_mux: true, + user_id: USER_ID, + primary_file_size: mp4Fixture().byteLength, + publication_id: null, + post_id: null, + name: "wrench-video.mp4", + }, + multipartUploadId: "multipart-id", + multipartUploadUrls: [escaped.href], + }); + } + throw new Error(`unexpected ${request.method} ${request.url.pathname}`); + }), + videoUploadFetch: () => { + puts.push(1); + return Promise.resolve({ status: 200, etag: '"etag-one"' }); + }, + sleep: () => Promise.resolve(), + }, + }, + ); + expect(result.status).toBe("failed"); + expect(result.dispatchStarted).toBe(false); + expect(result.dispatch).toEqual({ planned: 1, started: 0, verified: 0 }); + expect(result.error).toContain("video-upload-init"); + expect(puts.length).toBe(0); + expect(calls.map((call) => call.url.pathname)).not.toContain( + "/api/v1/comment/feed", + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails a video Note before dispatch when a part PUT returns a weak ETag", async () => { + const root = mkdtempSync(join(tmpdir(), "wrench-substack-video-etag-")); + const videoPath = join(root, "fixture.mp4"); + const bytes = mp4Fixture(); + writeFileSync(videoPath, bytes, { mode: 0o600 }); + const mediaUploadId = "33333333-3333-4333-8333-333333333333"; + const signedUrl = new URL( + `https://substack-video.s3-accelerate.amazonaws.com/video_upload/user/${USER_ID}/${mediaUploadId}/original`, + ); + signedUrl.searchParams.set("partNumber", "1"); + signedUrl.searchParams.set("uploadId", "multipart-id"); + signedUrl.searchParams.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + signedUrl.searchParams.set("X-Amz-Date", "20261005T000000Z"); + signedUrl.searchParams.set("X-Amz-Expires", "900"); + signedUrl.searchParams.set("X-Amz-Signature", "a".repeat(64)); + signedUrl.searchParams.set("X-Amz-SignedHeaders", "host"); + signedUrl.searchParams.set("x-id", "PutObject"); + try { + const result = await executeSubstackWebOperation( + recipe("media.publish"), + { body: NOTE_BODY, media: { kind: "file", reference: "fixture" } }, + boundAuth, + { + fileResolver: () => Promise.resolve([videoPath]), + dependencies: { + ...dependencies([], (request) => { + const bootstrap = bootstrapResponse(request); + if (bootstrap !== null) return bootstrap; + if ( + request.method === "POST" + && request.url.pathname === "/api/v1/video/upload" + ) { + return jsonResponse({ + mediaUpload: { + id: mediaUploadId, + multipart_upload_id: "multipart-id", + state: "created", + media_type: "video", + is_mux: true, + user_id: USER_ID, + primary_file_size: bytes.byteLength, + publication_id: null, + post_id: null, + name: "wrench-video.mp4", + }, + multipartUploadId: "multipart-id", + multipartUploadUrls: [signedUrl.href], + }); + } + throw new Error(`unexpected ${request.method} ${request.url.pathname}`); + }), + videoUploadFetch: () => + Promise.resolve({ status: 200, etag: 'W/"weak"' }), + sleep: () => Promise.resolve(), + }, + }, + ); + expect(result.status).toBe("failed"); + expect(result.dispatchStarted).toBe(false); + expect(result.error).toContain("video-part-transfer"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("fails a video Note before dispatch when processing ends in a terminal state", async () => { + const root = mkdtempSync(join(tmpdir(), "wrench-substack-video-status-")); + const videoPath = join(root, "fixture.mp4"); + const bytes = mp4Fixture(); + writeFileSync(videoPath, bytes, { mode: 0o600 }); + const mediaUploadId = "33333333-3333-4333-8333-333333333333"; + const signedUrl = new URL( + `https://substack-video.s3-accelerate.amazonaws.com/video_upload/user/${USER_ID}/${mediaUploadId}/original`, + ); + signedUrl.searchParams.set("partNumber", "1"); + signedUrl.searchParams.set("uploadId", "multipart-id"); + signedUrl.searchParams.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256"); + signedUrl.searchParams.set("X-Amz-Date", "20261005T000000Z"); + signedUrl.searchParams.set("X-Amz-Expires", "900"); + signedUrl.searchParams.set("X-Amz-Signature", "a".repeat(64)); + signedUrl.searchParams.set("X-Amz-SignedHeaders", "host"); + signedUrl.searchParams.set("x-id", "PutObject"); + try { + const result = await executeSubstackWebOperation( + recipe("media.publish"), + { body: NOTE_BODY, media: { kind: "file", reference: "fixture" } }, + boundAuth, + { + fileResolver: () => Promise.resolve([videoPath]), + dependencies: { + ...dependencies([], (request) => { + const bootstrap = bootstrapResponse(request); + if (bootstrap !== null) return bootstrap; + if ( + request.method === "POST" + && request.url.pathname === "/api/v1/video/upload" + ) { + return jsonResponse({ + mediaUpload: { + id: mediaUploadId, + multipart_upload_id: "multipart-id", + state: "created", + media_type: "video", + is_mux: true, + user_id: USER_ID, + primary_file_size: bytes.byteLength, + publication_id: null, + post_id: null, + name: "wrench-video.mp4", + }, + multipartUploadId: "multipart-id", + multipartUploadUrls: [signedUrl.href], + }); + } + if ( + request.method === "POST" + && request.url.pathname === `/api/v1/video/upload/${mediaUploadId}/transcode` + ) { + return jsonResponse({ + mediaUpload: { id: mediaUploadId, state: "uploaded" }, + }); + } + if ( + request.method === "GET" + && request.url.pathname === `/api/v1/video/upload/${mediaUploadId}` + ) { + return jsonResponse({ id: mediaUploadId, state: "error" }); + } + throw new Error(`unexpected ${request.method} ${request.url.pathname}`); + }), + videoUploadFetch: () => + Promise.resolve({ status: 200, etag: '"etag-one"' }), + sleep: () => Promise.resolve(), + }, + }, + ); + expect(result.status).toBe("failed"); + expect(result.dispatchStarted).toBe(false); + expect(result.error).toContain("video-processing"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("reads only the exact accepted video Note target for later presence reconciliation", async () => { + const calls: CapturedRequest[] = []; + const mediaUploadId = "33333333-3333-4333-8333-333333333333"; + const acceptedIdentifier = canonicalJson({ + noteId: CREATED_NOTE_ID, + attachment: { + id: ATTACHMENT_UUID, + mediaUploadId, + durationSeconds: 12.345, + height: 360, + width: 640, + mediaType: "video/mp4", + }, + }); + const readback = noteReadback("unused") as { + readonly item: Readonly> & { + readonly comment: Readonly>; + }; + }; + expect(await readSubstackWebAcceptedNoteTargetPresence( + recipe("media.publish"), + { body: NOTE_BODY, media: { kind: "file", reference: "fixture" } }, + boundAuth, + acceptedIdentifier, + { + dependencies: dependencies(calls, (request) => { + const bootstrap = bootstrapResponse(request); + if (bootstrap !== null) return bootstrap; + if ( + request.method === "GET" + && request.url.pathname === `/api/v1/reader/comment/${CREATED_NOTE_ID}` + ) { + return jsonResponse({ + item: { + ...readback.item, + comment: { + ...readback.item.comment, + attachments: [{ + id: ATTACHMENT_UUID, + type: "video", + media_upload_id: mediaUploadId, + mediaUpload: { id: mediaUploadId, state: "transcoded" }, + }], + }, + }, + }); + } + throw new Error(`unexpected ${request.method} ${request.url.pathname}`); + }), + }, + )).toEqual({ present: true, noteId: CREATED_NOTE_ID }); + expect(calls.map((call) => [call.method, call.url.pathname])).toEqual([ + ["GET", "/api/v1/am_i_logged_in"], + ["GET", "/"], + ["GET", `/api/v1/reader/comment/${CREATED_NOTE_ID}`], + ]); + }); + test("rejects capture-required operations before cookies or network are touched", () => { for (const action of [ "messaging.read", @@ -2368,7 +2850,6 @@ describe("Substack authenticated internal API runtime", () => { "content.save", "messaging.send", "articles.publish", - "media.publish", ] as const) { let acquisitions = 0; expect(executeSubstackWebOperation( diff --git a/src/providers/substack-web-runtime.ts b/src/providers/substack-web-runtime.ts index 07c6b3bb..e906b8ab 100644 --- a/src/providers/substack-web-runtime.ts +++ b/src/providers/substack-web-runtime.ts @@ -15,7 +15,7 @@ import { } from "../canonical-json"; import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model"; import { OperationDeadline } from "../operation-deadline"; -import { pinnedHttpsFetch } from "../pinned-https"; +import { createPinnedHttpsFetchScope, pinnedHttpsFetch } from "../pinned-https"; import { createWebSessionClient, webSessionAuthSubject, @@ -108,6 +108,14 @@ type SubstackWebSleep = ( signal?: AbortSignal, ) => Promise; +/** One byte-range PUT to a provider-issued Substack video multipart URL. */ +export type SubstackVideoUploadFetch = (request: { + readonly url: URL; + readonly body: Uint8Array; + readonly signal: AbortSignal; + readonly timeoutMs: number; +}) => Promise>; + export type SubstackWebRuntimeDependencies = Partial & { readonly now?: () => number; readonly sleep?: SubstackWebSleep; @@ -115,6 +123,8 @@ export type SubstackWebRuntimeDependencies = Partial string; /** Private cursor state; tests supply a separate state home. */ readonly cursorEnvironment?: Readonly>; + /** Exact-origin multipart byte transport; tests inject a stub. */ + readonly videoUploadFetch?: SubstackVideoUploadFetch; }; function sleepForSubstackReadback( @@ -825,7 +835,7 @@ export function substackVideoTranscodeRequest( mediaUploadId, "Substack media upload ID", ); - const multipartId = substackResponseBoundIdentifier( + const multipartId = substackMultipartUploadIdentifier( multipartUploadId, "Substack multipart upload ID", ); @@ -1532,6 +1542,357 @@ async function uploadSubstackImage( }), image, uploaded.url); } +// --------------------------------------------------------------------------- +// Substack Note video upload (media.publish@1) +// +// Reviewed against the live capture recorded on 2026-10-05: initialization +// POST /api/v1/video/upload returns the media upload record, one multipart +// upload id, and ordered presigned byte-transfer URLs on the reviewed +// substack-video S3 accelerate host. Each part PUT is accepted with 200 and a +// strong ETag response header. POST /api/v1/video/upload/{id}/transcode binds +// duration, the multipart upload id, and the ordered ETags; GET +// /api/v1/video/upload/{id} reports the lifecycle until "transcoded". +// POST /api/v1/comment/attachment binds type "video" and mediaUploadId and +// returns the attachment identifier admitted to the Note create call. +// --------------------------------------------------------------------------- + +const SUBSTACK_VIDEO_UPLOAD_ORIGIN = + "https://substack-video.s3-accelerate.amazonaws.com"; +const SUBSTACK_VIDEO_STATUS_POLL_DELAYS_MS = Object.freeze([ + 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 10_000, 10_000, 10_000, + 10_000, 10_000, +]); +const SUBSTACK_VIDEO_PART_PUT_TIMEOUT_MS = 120_000; + +type SubstackVideoUploadSession = Readonly<{ + readonly mediaUploadId: string; + readonly multipartUploadId: string; + readonly signedUrls: readonly URL[]; +}>; + +function substackMediaUploadIdentifier(value: unknown, label: string): string { + if ( + typeof value !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value) + ) throw new Error(`${label} must be one exact UUID`); + return value; +} + +function substackMultipartUploadIdentifier(value: unknown, label: string): string { + if ( + typeof value !== "string" + || value.length < 1 + || value.length > 512 + || !/^[A-Za-z0-9_.\-/+=]{1,512}$/u.test(value) + ) throw new Error(`${label} must be one bounded multipart upload identifier`); + return value; +} + +function bindSubstackVideoSignedPartUrl( + value: unknown, + partNumber: number, + viewerId: number, + mediaUploadId: string, + multipartUploadId: string, +): URL { + if (typeof value !== "string" || value.length > 8_192) { + throw new Error("Substack video multipart URL must be one bounded string"); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Substack video multipart URL must be absolute HTTPS"); + } + if ( + url.origin !== SUBSTACK_VIDEO_UPLOAD_ORIGIN + || url.username !== "" + || url.password !== "" + || url.hash !== "" + ) throw new Error("Substack video multipart URL left the reviewed upload origin"); + if ( + url.pathname !== `/video_upload/user/${viewerId}/${mediaUploadId}/original` + ) throw new Error("Substack video multipart URL did not bind the upload session"); + if (url.searchParams.get("partNumber") !== String(partNumber)) { + throw new Error("Substack video multipart URL did not bind its part number"); + } + if (url.searchParams.get("uploadId") !== multipartUploadId) { + throw new Error("Substack video multipart URL did not bind the multipart upload"); + } + for (const required of [ + "X-Amz-Algorithm", + "X-Amz-Date", + "X-Amz-Expires", + "X-Amz-Signature", + "X-Amz-SignedHeaders", + "x-id", + ]) { + if (url.searchParams.get(required) === null) { + throw new Error("Substack video multipart URL is missing a signed parameter"); + } + } + return url; +} + +/** Bind the live-reviewed initialization response to the exact local MP4. */ +function parseSubstackVideoUploadSession( + value: unknown, + video: SubstackVideo, + viewerId: number, +): SubstackVideoUploadSession { + if (!isRecord(value)) { + throw new Error("Substack video upload initialization response must be an object"); + } + requireExactKeys( + value, + ["mediaUpload", "multipartUploadId", "multipartUploadUrls"], + "Substack video upload initialization response", + ); + const upload = value.mediaUpload; + if (!isRecord(upload)) { + throw new Error("Substack video upload initialization mediaUpload must be an object"); + } + requireAllowedKeys(upload, [ + "created_at", + "duration", + "hash", + "id", + "is_mux", + "media_type", + "multipart_upload_id", + "name", + "parts", + "post_id", + "primary_file_size", + "publication_id", + "state", + "user_id", + ], "Substack video upload initialization mediaUpload"); + const mediaUploadId = substackMediaUploadIdentifier( + upload.id, + "Substack video upload initialization mediaUpload.id", + ); + const multipartUploadId = substackMultipartUploadIdentifier( + upload.multipart_upload_id, + "Substack video upload initialization mediaUpload.multipart_upload_id", + ); + if ( + upload.state !== "created" + || upload.media_type !== "video" + || upload.user_id !== viewerId + || upload.primary_file_size !== video.byteLength + || upload.publication_id !== null + || upload.post_id !== null + || upload.is_mux !== true + || (upload.parts !== undefined + && (!Array.isArray(upload.parts) || upload.parts.length !== 0)) + || typeof upload.name !== "string" + || upload.name.length < 1 + || upload.name.length > 256 + ) throw new Error("Substack video upload initialization did not bind the exact MP4 session"); + if (value.multipartUploadId !== multipartUploadId) { + throw new Error("Substack video upload initialization multipart identifiers diverged"); + } + const urls = value.multipartUploadUrls; + if (!Array.isArray(urls)) { + throw new Error("Substack video upload initialization URLs must be an array"); + } + const parts = planSubstackVideoMultipartParts(video.byteLength, urls.length); + const signedUrls = urls.map((entry, index) => bindSubstackVideoSignedPartUrl( + entry, + parts[index]!.partNumber, + viewerId, + mediaUploadId, + multipartUploadId, + )); + return Object.freeze({ + mediaUploadId, + multipartUploadId, + signedUrls: Object.freeze(signedUrls), + }); +} + +function parseSubstackVideoTranscodeResponse( + value: unknown, + mediaUploadId: string, +): void { + if (!isRecord(value) || !isRecord(value.mediaUpload)) { + throw new Error("Substack video transcode response must bind the media upload"); + } + const upload = value.mediaUpload; + if (upload.id !== mediaUploadId || upload.state !== "uploaded") { + throw new Error("Substack video transcode response did not bind the exact upload"); + } +} + +function parseSubstackVideoStatusResponse( + value: unknown, + mediaUploadId: string, +): SubstackVideoUploadSettlement { + if (!isRecord(value) || value.id !== mediaUploadId) { + throw new Error("Substack video status response did not bind the exact upload"); + } + return classifySubstackVideoUploadState(value.state); +} + +type SubstackVideoAttachment = Readonly<{ + readonly id: string; + readonly mediaUploadId: string; +}>; + +function parseSubstackVideoAttachment( + value: unknown, + mediaUploadId: string, +): SubstackVideoAttachment { + if (!isRecord(value)) { + throw new Error("Substack video attachment response must be an object"); + } + requireExactKeys( + value, + ["comment_id", "id", "media_upload_id", "mediaUpload", "type", "user_id"], + "Substack video attachment response", + ); + if ( + value.type !== "video" + || value.media_upload_id !== mediaUploadId + || value.comment_id !== null + || !isRecord(value.mediaUpload) + || value.mediaUpload.id !== mediaUploadId + || value.mediaUpload.state !== "transcoded" + ) throw new Error("Substack video attachment response did not bind the exact upload"); + return Object.freeze({ + id: attachmentUuid(value.id, "Substack video attachment response.id"), + mediaUploadId, + }); +} + +/** Run the reviewed upload lifecycle; returns the attachment for Note create. */ +async function uploadSubstackVideo( + client: WebSessionClient, + video: SubstackVideo, + viewerId: number, + videoUploadFetch: SubstackVideoUploadFetch, + options: { + readonly signal?: AbortSignal; + readonly operationDeadline?: WebSessionOperationDeadline; + readonly sleep: SubstackWebSleep; + readonly setFailureStage?: ( + stage: "video-attachment" | "video-part-transfer" | "video-processing" + | "video-transcode" | "video-upload-init", + ) => void; + }, +): Promise { + options.setFailureStage?.("video-upload-init"); + const initialization = substackVideoUploadInitializationRequestForBinding(video); + const session = parseSubstackVideoUploadSession(await client.requestJson({ + url: new URL(initialization.url), + method: "POST", + headers: jsonPostHeaders(), + expectedStatuses: [200], + expectedContentTypes: ["application/json"], + maxBytes: 256 * 1024, + }), video, viewerId); + const snapshot = revalidateAndSnapshotSubstackVideoMultipartDispatch( + video, + createSubstackVideoMultipartDispatchCheckpoint(video, session.signedUrls.length), + ); + const etags: string[] = []; + options.setFailureStage?.("video-part-transfer"); + for (const part of snapshot.parts) { + options.operationDeadline?.throwIfUnavailable( + "authenticated web operation deadline", + ); + if (options.signal?.aborted === true) { + throw new Error("Substack video upload was cancelled"); + } + const url = session.signedUrls[part.partNumber - 1]!; + const bytes = new Uint8Array( + await snapshot.parts[part.partNumber - 1]!.body.arrayBuffer(), + ); + if (bytes.byteLength !== part.byteLength) { + throw new Error("Substack video multipart snapshot changed shape"); + } + const timeoutMs = Math.min( + SUBSTACK_VIDEO_PART_PUT_TIMEOUT_MS, + Math.max( + MIN_PINNED_HTTPS_TIMEOUT_MS, + options.operationDeadline?.remainingTimeMs() ?? SUBSTACK_VIDEO_PART_PUT_TIMEOUT_MS, + ), + ); + const response = await videoUploadFetch({ + url, + body: bytes, + signal: options.signal ?? AbortSignal.timeout(timeoutMs), + timeoutMs, + }); + if (response.status !== 200) { + throw new Error("Substack video multipart byte transfer was not accepted"); + } + etags.push( + parseSubstackVideoMultipartEtags([response.etag], 1)[0]!, + ); + } + options.setFailureStage?.("video-transcode"); + const transcodeRequest = substackVideoTranscodeRequestForBinding( + session.mediaUploadId, + session.multipartUploadId, + video, + session.signedUrls.length, + etags, + ); + parseSubstackVideoTranscodeResponse(await client.requestJson({ + url: new URL(transcodeRequest.url), + method: "POST", + headers: jsonPostHeaders(), + body: JSON.stringify(transcodeRequest.body), + expectedStatuses: [200], + expectedContentTypes: ["application/json"], + maxBytes: 256 * 1024, + }), session.mediaUploadId); + options.setFailureStage?.("video-processing"); + const statusRequest = substackVideoStatusRequest(session.mediaUploadId); + let settled: SubstackVideoUploadSettlement | null = null; + for ( + let attempt = 0; + attempt <= SUBSTACK_VIDEO_STATUS_POLL_DELAYS_MS.length; + attempt += 1 + ) { + if (attempt > 0) { + await waitForSubstackNoteReadback( + SUBSTACK_VIDEO_STATUS_POLL_DELAYS_MS[attempt - 1]!, + options.sleep, + options.signal, + options.operationDeadline, + ); + } + settled = parseSubstackVideoStatusResponse(await client.requestJson({ + url: new URL(statusRequest.url), + method: "GET", + headers: jsonHeaders(), + expectedStatuses: [200], + expectedContentTypes: ["application/json"], + maxBytes: 256 * 1024, + }), session.mediaUploadId); + if (settled.status === "complete") break; + if (settled.status === "terminal-failure") { + throw new Error(`Substack video processing ended in ${settled.state}`); + } + } + if (settled === null || settled.status !== "complete") { + throw new Error("Substack video processing exhausted its reviewed window"); + } + options.setFailureStage?.("video-attachment"); + return parseSubstackVideoAttachment(await client.requestJson({ + url: new URL("/api/v1/comment/attachment", SUBSTACK_ORIGIN), + method: "POST", + headers: jsonPostHeaders(), + body: JSON.stringify({ type: "video", mediaUploadId: session.mediaUploadId }), + expectedStatuses: [200], + expectedContentTypes: ["application/json"], + maxBytes: 256 * 1024, + }), session.mediaUploadId); +} + type SubstackBodyJson = Readonly<{ type: "doc"; attrs: Readonly<{ schemaVersion: "v1"; title: null }>; @@ -1665,12 +2026,25 @@ function substackNoteCreateRequestFailureStage( return "note-create-transport"; } +/** Exact confirmed Note attachment, keyed by the reviewed media kind. */ +type SubstackNoteAttachmentBinding = + | Readonly<{ + kind: "image"; + id: string; + url: string; + }> + | Readonly<{ + kind: "video"; + id: string; + mediaUploadId: string; + }>; + function parseCreatedSubstackNote( value: unknown, viewer: SubstackWebViewer, body: string, bodyJson: SubstackBodyJson, - attachment: SubstackImageAttachment | null, + attachment: SubstackNoteAttachmentBinding | null, ): number { if (!isRecord(value)) { noteCreateBindingFailure( @@ -1769,37 +2143,49 @@ function parseCreatedSubstackNote( "Substack Note create attachment must be an object", ); } - try { - requireExactKeys(item, [ - "explicit", - "id", - "imageHeight", - "imageUrl", - "imageWidth", - "type", - ], "Substack Note create attachment"); - } catch { - noteCreateBindingFailure( - "note-create-attachment-fields", - "Substack Note create attachment fields changed", - ); + if (attachment.kind === "image") { + try { + requireExactKeys(item, [ + "explicit", + "id", + "imageHeight", + "imageUrl", + "imageWidth", + "type", + ], "Substack Note create attachment"); + } catch { + noteCreateBindingFailure( + "note-create-attachment-fields", + "Substack Note create attachment fields changed", + ); + } + if (item.imageUrl !== attachment.url) { + noteCreateBindingFailure( + "note-create-attachment-url", + "Substack Note create response attachment did not bind the uploaded image URL", + ); + } } if (item.id !== attachment.id) { noteCreateBindingFailure( "note-create-attachment-id", - "Substack Note create response attachment did not bind the uploaded image identifier", + "Substack Note create response attachment did not bind the uploaded media identifier", ); } - if (item.imageUrl !== attachment.url) { + if (item.type !== attachment.kind) { noteCreateBindingFailure( - "note-create-attachment-url", - "Substack Note create response attachment did not bind the uploaded image URL", + "note-create-attachment-kind", + "Substack Note create response attachment did not bind the uploaded media kind", ); } - if (item.type !== "image") { + if ( + attachment.kind === "video" + && item.media_upload_id !== undefined + && item.media_upload_id !== attachment.mediaUploadId + ) { noteCreateBindingFailure( - "note-create-attachment-kind", - "Substack Note create response attachment did not bind the uploaded image kind", + "note-create-attachment-id", + "Substack Note create response attachment did not bind the uploaded video identifier", ); } } @@ -1825,7 +2211,14 @@ type ProjectedSubstackNote = Readonly<{ attachments: readonly Readonly<{ id: string | null; type: string | null; + url: string | null; imageUrl: string | null; + videoUrl: string | null; + mediaUploadId: string | null; + mediaUpload: Readonly<{ + id: string | null; + state: string | null; + }> | null; width: number | null; height: number | null; }>[]; @@ -2073,18 +2466,25 @@ async function readSubstackPersonalNoteDeletionPresence( }); } -type SubstackNoteImageExpectation = Readonly<{ - readonly height: number; - readonly width: number; -}>; +/** Exact confirmed media expectation for an independent Note readback. */ +type SubstackNoteMediaExpectation = + | Readonly<{ + kind: "image"; + attachment: Readonly<{ id: string; url: string }>; + height: number; + width: number; + }> + | Readonly<{ + kind: "video"; + attachment: Readonly<{ id: string; mediaUploadId: string }>; + }>; function assertSubstackNoteReadback( note: ProjectedSubstackNote, noteId: number, viewer: SubstackWebViewer, body: string, - image: SubstackNoteImageExpectation | null, - attachment: SubstackImageAttachment | null, + media: SubstackNoteMediaExpectation | null, ): void { if ( note.entityKey !== `c-${noteId}` @@ -2096,28 +2496,43 @@ function assertSubstackNoteReadback( || note.comment.type !== "feed" || note.post !== null ) throw new Error("Substack Note readback did not bind the confirmed Note"); - if (image === null || attachment === null) { + if (media === null) { if (note.comment.attachments.length !== 0) { throw new Error("Substack Note readback contained an unexpected attachment"); } return; } + const item = note.comment.attachments[0]; if ( note.comment.attachments.length !== 1 - || note.comment.attachments[0]?.id !== attachment.id - || note.comment.attachments[0]?.type !== "image" - || note.comment.attachments[0]?.imageUrl !== attachment.url - || note.comment.attachments[0]?.width !== image.width - || note.comment.attachments[0]?.height !== image.height + || item === undefined + || item.id !== media.attachment.id + || item.type !== media.kind + ) throw new Error("Substack Note readback did not bind the confirmed media"); + if ( + media.kind === "image" + && ( + item.imageUrl !== media.attachment.url + || item.width !== media.width + || item.height !== media.height + ) ) throw new Error("Substack Note readback did not bind the confirmed image"); + if ( + media.kind === "video" + && ( + item.mediaUploadId !== media.attachment.mediaUploadId + || (item.mediaUpload !== null && item.mediaUpload.id !== media.attachment.mediaUploadId) + ) + ) throw new Error("Substack Note readback did not bind the confirmed video"); } function substackDispatchEvent( + id: "media.publish" | "posts.publish", started: number, verified: number, ): WebSessionDispatchEvent { return { - id: "posts.publish", + id, index: 1, progress: { planned: 1, started, verified }, }; @@ -2169,8 +2584,7 @@ async function readExactSubstackNoteAfterPublish( noteId: number, viewer: SubstackWebViewer, body: string, - image: SubstackImage | null, - attachment: SubstackImageAttachment | null, + media: SubstackNoteMediaExpectation | null, options: { readonly signal?: AbortSignal; readonly operationDeadline?: WebSessionOperationDeadline; @@ -2200,7 +2614,7 @@ async function readExactSubstackNoteAfterPublish( headers: jsonHeaders(), maxBytes: boundedMaximum(recipe), }), noteId) as ProjectedSubstackNote; - assertSubstackNoteReadback(note, noteId, viewer, body, image, attachment); + assertSubstackNoteReadback(note, noteId, viewer, body, media); return note; } catch { options.operationDeadline?.throwIfUnavailable( @@ -2217,13 +2631,24 @@ async function readExactSubstackNoteAfterPublish( throw new Error("Substack exact Note readback exhausted its reviewed window"); } -type SubstackAcceptedNoteAttachment = Readonly<{ - id: string; - url: string; - height: number; - width: number; - mediaType: "image/png"; -}>; +type SubstackAcceptedNoteAttachment = + | Readonly<{ + id: string; + kind: "image"; + url: string; + height: number; + width: number; + mediaType: "image/png"; + }> + | Readonly<{ + id: string; + kind: "video"; + mediaUploadId: string; + durationSeconds: number; + height: number; + width: number; + mediaType: "video/mp4"; + }>; type SubstackAcceptedNoteTarget = Readonly<{ noteId: number; @@ -2255,6 +2680,46 @@ function parseSubstackAcceptedNoteTarget( if (!isRecord(value.attachment)) { throw new Error("Substack accepted Note attachment changed shape"); } + const id = attachmentUuid(value.attachment.id, "Substack accepted Note attachment.id"); + if (value.attachment.mediaType === "video/mp4") { + requireExactKeys( + value.attachment, + ["durationSeconds", "height", "id", "mediaType", "mediaUploadId", "width"], + "Substack accepted Note attachment", + ); + const width = positiveInteger( + value.attachment.width, + "Substack accepted Note attachment.width", + ); + const height = positiveInteger( + value.attachment.height, + "Substack accepted Note attachment.height", + ); + const durationSeconds = value.attachment.durationSeconds; + if ( + width > 20_000 + || height > 20_000 + || typeof durationSeconds !== "number" + || !Number.isFinite(durationSeconds) + || durationSeconds <= 0 + || durationSeconds > 4 * 60 * 60 + ) throw new Error("Substack accepted Note attachment changed shape"); + return Object.freeze({ + noteId, + attachment: Object.freeze({ + id, + kind: "video" as const, + mediaUploadId: substackMediaUploadIdentifier( + value.attachment.mediaUploadId, + "Substack accepted Note attachment.mediaUploadId", + ), + durationSeconds, + height, + width, + mediaType: "video/mp4" as const, + }), + }); + } requireExactKeys( value.attachment, ["height", "id", "mediaType", "url", "width"], @@ -2274,7 +2739,8 @@ function parseSubstackAcceptedNoteTarget( return Object.freeze({ noteId, attachment: Object.freeze({ - id: attachmentUuid(value.attachment.id, "Substack accepted Note attachment.id"), + id, + kind: "image" as const, url: exactSubstackImageUrl( value.attachment.url, width, @@ -2302,9 +2768,13 @@ export async function readSubstackWebAcceptedNoteTargetPresence( ): Promise> { if ( recipe.site !== "substack" - || recipe.action !== "posts.publish" - || recipe.contractVersion !== 3 - ) throw new Error("Substack accepted Note readback supports only posts.publish@3"); + || !( + (recipe.action === "posts.publish" && recipe.contractVersion === 3) + || (recipe.action === "media.publish" && recipe.contractVersion === 1) + ) + ) throw new Error( + "Substack accepted Note readback supports only posts.publish@3 or media.publish@1", + ); requireExactInputKeys(input, ["body", "media"]); const body = noteBodyInput(input); const media = input.media === undefined ? null : fileInput(input.media); @@ -2342,8 +2812,22 @@ export async function readSubstackWebAcceptedNoteTargetPresence( target.noteId, viewer, body, - target.attachment, - target.attachment, + target.attachment === null + ? null + : target.attachment.kind === "image" + ? { + kind: "image" as const, + attachment: { id: target.attachment.id, url: target.attachment.url }, + height: target.attachment.height, + width: target.attachment.width, + } + : { + kind: "video" as const, + attachment: { + id: target.attachment.id, + mediaUploadId: target.attachment.mediaUploadId, + }, + }, ); return Object.freeze({ present: true as const, noteId: target.noteId }); } @@ -2394,7 +2878,7 @@ async function executeSubstackPost( failureStage = "image-upload"; attachment = image === null ? null : await uploadSubstackImage(client, image); failureStage = "dispatch-admission"; - await options.beforeDispatch?.(substackDispatchEvent(started, verified)); + await options.beforeDispatch?.(substackDispatchEvent("posts.publish", started, verified)); started = 1; failureStage = "note-create-transport"; let createdNoteResponse: unknown; @@ -2424,7 +2908,9 @@ async function executeSubstackPost( reboundViewer, body, bodyJson, - attachment, + attachment === null + ? null + : { kind: "image", id: attachment.id, url: attachment.url }, ); } catch (error) { failureStage = error instanceof SubstackNoteCreateBindingError @@ -2459,13 +2945,19 @@ async function executeSubstackPost( noteId, reboundViewer, body, - image, - attachment, + attachment === null || image === null + ? null + : { + kind: "image" as const, + attachment: { id: attachment.id, url: attachment.url }, + height: image.height, + width: image.width, + }, options, ); verified = 1; failureStage = "verification-recording"; - await options.afterDispatchVerified?.(substackDispatchEvent(started, verified)); + await options.afterDispatchVerified?.(substackDispatchEvent("posts.publish", started, verified)); return { status: "succeeded", output: Object.freeze({ @@ -2496,6 +2988,193 @@ async function executeSubstackPost( } } +type SubstackMediaPublishFailureStage = + | "dispatch-admission" + | "video-attachment" + | "video-part-transfer" + | "video-processing" + | "video-transcode" + | "video-upload-init" + | SubstackNoteCreateRequestFailureStage + | SubstackNoteCreateBindingFailureStage + | "accepted-target-recording" + | "note-readback" + | "verification-recording"; + +async function executeSubstackMediaPublish( + client: WebSessionClient, + recipe: WebSessionRecipe, + viewer: SubstackWebViewer, + input: OperationInput, + options: { + readonly fileResolver?: BrowserFileResolver; + readonly signal?: AbortSignal; + readonly operationDeadline?: WebSessionOperationDeadline; + readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise; + readonly afterProviderAcceptedMutationTarget?: ( + event: WebSessionProviderAcceptedMutationTargetEvent, + ) => Promise; + readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise; + readonly sleep: SubstackWebSleep; + readonly videoUploadFetch?: SubstackVideoUploadFetch; + }, +): Promise { + requireExactInputKeys(input, ["body", "media"]); + const body = noteBodyInput(input); + const bodyJson = substackBodyJson(body); + if (viewer.handle === null) { + throw new Error("Substack Note publication requires the bound viewer's public handle"); + } + const video = await materializeSubstackVideo( + fileInput(input.media), + options.fileResolver, + options.operationDeadline, + ); + const reboundViewer = await currentViewer(client, boundedMaximum(recipe)); + if (viewerSubject(reboundViewer) !== viewerSubject(viewer)) { + throw new Error("Substack current viewer changed before the Note dispatch"); + } + if (reboundViewer.handle === null) { + throw new Error("Substack Note publication requires the bound viewer's public handle"); + } + const injectedUploadFetch = options.videoUploadFetch; + const uploadScope = injectedUploadFetch === undefined + ? createPinnedHttpsFetchScope(SUBSTACK_VIDEO_UPLOAD_ORIGIN) + : null; + const videoUploadFetch: SubstackVideoUploadFetch = injectedUploadFetch + ?? (async (request) => { + const response = await uploadScope!.fetch(request.url, { + method: "PUT", + redirect: "error", + body: request.body.slice().buffer, + signal: request.signal, + }, request.timeoutMs); + const etag = response.headers.get("etag"); + await response.arrayBuffer(); + return Object.freeze({ status: response.status, etag }); + }); + let started = 0; + let verified = 0; + let noteId: number | null = null; + let attachment: SubstackVideoAttachment | null = null; + let failureStage: SubstackMediaPublishFailureStage = "dispatch-admission"; + try { + failureStage = "video-upload-init"; + attachment = await uploadSubstackVideo( + client, + video, + reboundViewer.id, + videoUploadFetch, + { ...options, setFailureStage: (stage) => { failureStage = stage; } }, + ); + failureStage = "dispatch-admission"; + await options.beforeDispatch?.(substackDispatchEvent("media.publish", started, verified)); + started = 1; + failureStage = "note-create-transport"; + let createdNoteResponse: unknown; + try { + createdNoteResponse = await client.requestJson({ + url: new URL("/api/v1/comment/feed", SUBSTACK_ORIGIN), + method: "POST", + headers: jsonPostHeaders(), + body: JSON.stringify({ + bodyJson, + attachmentIds: [attachment.id], + tabId: "for-you", + surface: "feed", + replyMinimumRole: "everyone", + }), + expectedStatuses: [200], + expectedContentTypes: ["application/json"], + maxBytes: boundedMaximum(recipe), + }); + } catch (error) { + failureStage = substackNoteCreateRequestFailureStage(error); + throw error; + } + try { + noteId = parseCreatedSubstackNote( + createdNoteResponse, + reboundViewer, + body, + bodyJson, + { kind: "video", id: attachment.id, mediaUploadId: attachment.mediaUploadId }, + ); + } catch (error) { + failureStage = error instanceof SubstackNoteCreateBindingError + ? error.stage + : "note-create-response-object"; + throw error; + } + failureStage = "accepted-target-recording"; + await options.afterProviderAcceptedMutationTarget?.({ + id: "media.publish", + index: 1, + target: { + schemaVersion: 1, + identifier: canonicalJson({ + noteId, + attachment: { + id: attachment.id, + mediaUploadId: attachment.mediaUploadId, + durationSeconds: video.durationSeconds, + height: video.height, + width: video.width, + mediaType: video.mediaType, + }, + }), + }, + }); + failureStage = "note-readback"; + const note = await readExactSubstackNoteAfterPublish( + client, + recipe, + noteId, + reboundViewer, + body, + { + kind: "video" as const, + attachment: { + id: attachment.id, + mediaUploadId: attachment.mediaUploadId, + }, + }, + options, + ); + verified = 1; + failureStage = "verification-recording"; + await options.afterDispatchVerified?.(substackDispatchEvent("media.publish", started, verified)); + return { + status: "succeeded", + output: Object.freeze({ + note, + attachment: Object.freeze({ + durationSeconds: video.durationSeconds, + height: video.height, + mediaType: video.mediaType, + width: video.width, + }), + }), + finalUrl: substackNoteUrl(reboundViewer.handle, noteId), + dispatchStarted: true, + dispatch: { planned: 1, started, verified }, + }; + } catch { + return { + status: started > 0 ? "indeterminate" : "failed", + output: null, + finalUrl: noteId === null ? SUBSTACK_ORIGIN : substackNoteUrl(reboundViewer.handle, noteId), + dispatchStarted: started > 0, + dispatch: { planned: 1, started, verified }, + error: started > 0 + ? `Substack may have accepted the video upload or Note but exact actor, text, attachment, and permalink readback was not verified; reconcile before retrying (stage: ${failureStage})` + : `Substack Note dispatch failed before submission (stage: ${failureStage})`, + }; + } finally { + uploadScope?.close(); + } +} + type SubstackDeleteFailureStage = | "accepted-target-recording" | "delete-readback" @@ -3990,6 +4669,7 @@ export async function executeSubstackWebOperation( && recipe.action !== "profiles.read" && recipe.action !== "organizations.read" && recipe.action !== "posts.publish" + && recipe.action !== "media.publish" && recipe.action !== "content.delete" && recipe.action !== "comments.create" && recipe.action !== "replies.create" @@ -4070,6 +4750,15 @@ export async function executeSubstackWebOperation( sleep: options.dependencies?.sleep ?? sleepForSubstackReadback, }); } + if (recipe.action === "media.publish") { + return executeSubstackMediaPublish(client, recipe, viewer, input, { + ...options, + sleep: options.dependencies?.sleep ?? sleepForSubstackReadback, + ...(options.dependencies?.videoUploadFetch === undefined + ? {} + : { videoUploadFetch: options.dependencies.videoUploadFetch }), + }); + } if (recipe.action === "content.delete") { return executeSubstackPersonalNoteDelete(client, recipe, viewer, input, { ...options, diff --git a/src/providers/substack-web.test.ts b/src/providers/substack-web.test.ts index 9e526a00..af90c6b7 100644 --- a/src/providers/substack-web.test.ts +++ b/src/providers/substack-web.test.ts @@ -190,6 +190,7 @@ describe("Substack internal-web operation registry", () => { "comments.read", "content.delete", "feeds.read", + "media.publish", "media.read", "messaging.list", "organizations.read", @@ -489,6 +490,36 @@ describe("Substack account and response projection", () => { }, "notes", 10) as { readonly items: readonly unknown[] }; expect(notes.items).toHaveLength(1); + const mixed = normalizeSubstackFeedResponse({ + items: [ + { + entity_key: `c-${NOTE_ID}`, + type: "comment", + comment: comment(NOTE_ID, { post_id: null, publication_id: null }), + post: null, + publication: null, + canReply: true, + }, + { + type: "userSuggestions", + userSuggestions: [{ id: 1 }], + }, + { + entity_key: "cp-639ca31b-be79-43a2-b145-fc5f727eea21", + type: "chat", + community_post: { id: 123 }, + publication: null, + }, + ], + }, "notes", 10) as { readonly items: readonly { readonly type: string | null; readonly comment: unknown; readonly post: unknown }[] }; + expect(mixed.items).toHaveLength(3); + expect(mixed.items[1]).toMatchObject({ type: "userSuggestions", comment: null, post: null }); + expect(mixed.items[2]).toMatchObject({ entityKey: "cp-639ca31b-be79-43a2-b145-fc5f727eea21", type: "chat", comment: null, post: null }); + + expect(() => normalizeSubstackFeedResponse({ + items: [{ entity_key: `c-${NOTE_ID}`, type: "comment" }], + }, "notes", 10)).toThrow("omitted both Note and article entities"); + const articleList = normalizeSubstackFeedResponse({ posts: [post()], cursor: "next", diff --git a/src/providers/substack-web.ts b/src/providers/substack-web.ts index e1a4287e..d911f5fb 100644 --- a/src/providers/substack-web.ts +++ b/src/providers/substack-web.ts @@ -107,10 +107,8 @@ export const SUBSTACK_WEB_OPERATIONS = Object.freeze({ "media.read": observedRead( "bounded cover, podcast, video-upload, API audio-item, and exact same-publication inline audio metadata projected from an exact entitled article response", ), - "media.publish": captureRequired( - "R3", - "first-party-bundle", - "Note video upload initialization, byte transfer, processing, viewer binding, returned attachment, and exact public Note readback need an authorized fixture", + "media.publish": observedWrite( + "authorized Note composer capture proving MP4 upload initialization, ordered presigned byte transfer with returned ETags, transcode and processing polling, viewer-bound video attachment, exact public create payload, durable accepted-Note targeting, and independent exact Note readback", ), "messaging.list": observedRead( "acknowledgement-free inbox listing for all, people, and unread tabs", @@ -1833,6 +1831,25 @@ function projectedAttachment(value: unknown, label: string): unknown { imageUrl: exactHttpsUrl(source.imageUrl ?? source.image_url, `${label}.imageUrl`), videoUrl: exactHttpsUrl(source.videoUrl ?? source.video_url, `${label}.videoUrl`), audioUrl: exactHttpsUrl(source.audioUrl ?? source.audio_url, `${label}.audioUrl`), + mediaUploadId: optionalString( + source.media_upload_id ?? source.mediaUploadId, + `${label}.mediaUploadId`, + 256, + ), + mediaUpload: isRecord(source.mediaUpload) + ? Object.freeze({ + id: optionalString( + record(source.mediaUpload, `${label}.mediaUpload`).id, + `${label}.mediaUpload.id`, + 256, + ), + state: optionalString( + record(source.mediaUpload, `${label}.mediaUpload`).state, + `${label}.mediaUpload.state`, + 128, + ), + }) + : null, altText: optionalString(source.altText ?? source.alt_text, `${label}.altText`, 4_096), width: optionalFiniteNumber(source.width ?? source.imageWidth, `${label}.width`), height: optionalFiniteNumber(source.height ?? source.imageHeight, `${label}.height`), @@ -1895,10 +1912,15 @@ export function normalizeSubstackFeedResponse( ? null : projectedPost(entry.post, `Substack reader feed items[${index}].post`, false); if (comment === null && post === null) { - throw new Error("Substack reader feed item omitted both Note and article entities"); + // Non-content units (userSuggestions, chat/community posts) carry + // neither entity; keep them as typed items so the page stays exact. + const type = optionalString(entry.type, `Substack reader feed items[${index}].type`, 128); + if (type !== "userSuggestions" && type !== "chat") { + throw new Error("Substack reader feed item omitted both Note and article entities"); + } } return Object.freeze({ - entityKey: boundedString(entry.entity_key, `Substack reader feed items[${index}].entity_key`, 512), + entityKey: optionalString(entry.entity_key, `Substack reader feed items[${index}].entity_key`, 512), type: boundedString(entry.type, `Substack reader feed items[${index}].type`, 128), comment, post, diff --git a/src/providers/x-web-runtime.internal.test.ts b/src/providers/x-web-runtime.internal.test.ts index 78baedfc..1fae2346 100644 --- a/src/providers/x-web-runtime.internal.test.ts +++ b/src/providers/x-web-runtime.internal.test.ts @@ -381,15 +381,19 @@ function publishedTweetResult(options: { readonly extra?: Readonly>; }): unknown { const media = options.mediaId === undefined || options.mediaId === null - ? {} + ? { fullText: options.text, legacyEntities: {} } : { - entities: { media: [{ id_str: options.mediaId, type: options.mediaType ?? "photo" }] }, - extended_entities: { media: [{ id_str: options.mediaId, type: options.mediaType ?? "photo" }] }, + // X appends each attached media entity's t.co url to legacy.full_text. + fullText: `${options.text} https://t.co/fakemedia1`, + legacyEntities: { + entities: { media: [{ id_str: options.mediaId, type: options.mediaType ?? "photo", url: "https://t.co/fakemedia1" }] }, + extended_entities: { media: [{ id_str: options.mediaId, type: options.mediaType ?? "photo", url: "https://t.co/fakemedia1" }] }, + }, }; return { rest_id: CREATED_POST_ID, legacy: { - full_text: options.text, + full_text: media.fullText, user_id_str: options.authorId ?? VIEWER_ID, ...(options.replyTo === undefined || options.replyTo === null ? {} @@ -397,7 +401,7 @@ function publishedTweetResult(options: { ...(options.quote === undefined || options.quote === null ? {} : { quoted_status_id_str: options.quote }), - ...media, + ...media.legacyEntities, }, ...(options.extra ?? {}), }; @@ -3691,6 +3695,39 @@ describe("X authenticated internal-API runtime", () => { }, )).toEqual({ present: true, postId: CREATED_POST_ID }); expect(videoCalls.every((request) => request.method === "GET")).toBeTrue(); + const tamperedIdentifier = canonicalJson({ postId: CREATED_POST_ID, mediaId: "67890" }); + const tamperedCalls: CapturedRequest[] = []; + await expect(readXWebPublishedMutationTarget( + xRecipe("posts.publish", 4), + { body: videoBody, media: { kind: "file", reference: "fixture" }, media_type: "video/mp4" }, + xAuth, + tamperedIdentifier, + { + dependencies: dependencies(tamperedCalls, (request) => { + if (request.url.href === "https://x.com/home") { + return new Response(homeHtml(), { headers: { "content-type": "text/html" } }); + } + if (request.url.href === MAIN_URL) { + return new Response(mainBundle( + descriptor("Viewer", "u4ni7JqpqdAQxWQfkLsdUQ", "query"), + descriptor("TweetResultByRestId", "LbQZrAWyKPvExi8di3-EoA", "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + if (request.url.pathname.endsWith("/TweetResultByRestId")) { + // A media t.co suffix must not let a different body pass text binding. + return jsonResponse({ + data: { + tweetResult: { + result: publishedTweetResult({ text: "tampered body", mediaId: "67890", mediaType: "video" }), + }, + }, + }); + } + throw new Error(`unexpected X tampered reconciliation request ${request.url.href}`); + }), + }, + )).rejects.toThrow("did not bind the confirmed text"); const longBody = "L".repeat(1158); const longIdentifier = canonicalJson({ postId: CREATED_POST_ID, mediaId: null }); const longCalls: CapturedRequest[] = []; diff --git a/src/providers/x-web-runtime.ts b/src/providers/x-web-runtime.ts index 7cf0073e..a41e0051 100644 --- a/src/providers/x-web-runtime.ts +++ b/src/providers/x-web-runtime.ts @@ -2318,6 +2318,21 @@ function tweetMediaIds(legacy: JsonRecord, expectedReadbackType: "photo" | "vide return Object.freeze(first); } +function tweetMediaUrls(legacy: JsonRecord): readonly string[] { + for (const name of ["entities", "extended_entities"] as const) { + const container = legacy[name]; + if (!isRecord(container) || !Array.isArray(container.media)) continue; + return Object.freeze(container.media.map((item, index) => { + const media = record(item, `X post ${name}.media[${index}]`); + if (typeof media.url !== "string" || !media.url.startsWith("https://t.co/")) { + throw new Error("X post media entity omitted its t.co url"); + } + return media.url; + })); + } + return Object.freeze([]); +} + /** * Bind confirmed CreateTweet text from the long-form note_tweet record when * present. Live long posts keep a short preview in legacy.full_text; that @@ -2351,13 +2366,6 @@ function assertTweetBinding( const result = unwrapTweet(value, `${label}.result`); const id = postId(result.rest_id, "X created post rest_id"); const legacy = record(result.legacy, `${label}.legacy`); - const returnedText = boundCreateTweetText(result, legacy); - if (returnedText !== expectedText) throw new Error("X created post response did not bind the confirmed text"); - if (legacy.user_id_str !== expectedAuthorId) throw new Error("X created post response did not bind the confirmed viewer"); - const returnedReply = typeof legacy.in_reply_to_status_id_str === "string" ? legacy.in_reply_to_status_id_str : null; - if (returnedReply !== replyTo) throw new Error("X created post response did not bind the confirmed reply target"); - const returnedQuote = typeof legacy.quoted_status_id_str === "string" ? legacy.quoted_status_id_str : null; - if (returnedQuote !== quote) throw new Error("X created post response did not bind the confirmed quote target"); const mediaIds = expectedMediaType === null ? tweetMediaIds(legacy, "photo") : tweetMediaIds(legacy, xMediaReadbackType(expectedMediaType)); @@ -2365,6 +2373,20 @@ function assertTweetBinding( (expectedMediaId === null && mediaIds.length !== 0) || (expectedMediaId !== null && (mediaIds.length !== 1 || mediaIds[0] !== expectedMediaId)) ) throw new Error("X created post response did not bind the confirmed media upload"); + let returnedText = boundCreateTweetText(result, legacy); + if (returnedText !== null && mediaIds.length > 0) { + // X appends each attached media entity's t.co url to legacy.full_text. + const suffix = tweetMediaUrls(legacy).map((url) => ` ${url}`).join(""); + if (suffix.length > 0 && returnedText.endsWith(suffix)) { + returnedText = returnedText.slice(0, returnedText.length - suffix.length); + } + } + if (returnedText !== expectedText) throw new Error("X created post response did not bind the confirmed text"); + if (legacy.user_id_str !== expectedAuthorId) throw new Error("X created post response did not bind the confirmed viewer"); + const returnedReply = typeof legacy.in_reply_to_status_id_str === "string" ? legacy.in_reply_to_status_id_str : null; + if (returnedReply !== replyTo) throw new Error("X created post response did not bind the confirmed reply target"); + const returnedQuote = typeof legacy.quoted_status_id_str === "string" ? legacy.quoted_status_id_str : null; + if (returnedQuote !== quote) throw new Error("X created post response did not bind the confirmed quote target"); const bound = { id, url: `${X_ORIGIN}/i/status/${id}` }; rejectXTweetMadeWithAiLabel(result, bound); return bound; diff --git a/src/read-projections.ts b/src/read-projections.ts index c3d68b8d..94201217 100644 --- a/src/read-projections.ts +++ b/src/read-projections.ts @@ -29,6 +29,7 @@ import { MAX_PRIVATE_STATE_BATCH_FILE_BYTES, createPrivateJsonIfAbsent, ensurePrivateStateDirectory, + isStateHelperArtifactName, listPrivateStateDirectory, readPrivateStateChildFilesBatched, readPrivateStateFileBytesIfPresent, @@ -92,13 +93,6 @@ const immutableManifestNamePattern = /^manifest--([a-f0-9]{32})\.json$/u; const immutableChunkNamePattern = /^chunk--([a-f0-9]{32})--([0-9]{3})\.json$/u; const queryDirectoryNamePattern = /^[a-f0-9]{64}$/u; const authIdPattern = /^[a-z][a-z0-9-]{0,47}$/u; -const stateHelperArtifactNamePatterns = Object.freeze([ - /^\.io-write-[1-9][0-9]{0,9}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u, - /^\.io-mutation-[a-f0-9]{64}-(?:waiting|candidate|held)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.lock$/u, - /^\.io-mutation-stage-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}-[1-9][0-9]{0,9}\.tmp$/u, - /^\.io-remove-file-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.quarantine$/u, - /^\.io-remove(?:-tree)?-[1-9][0-9]{0,9}-[1-9][0-9]{0,15}-[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}\.quarantine$/u, -]); type ProjectionKey = { readonly id: string; @@ -2287,9 +2281,7 @@ function readRealmQueryUsage( identity, { recoverOrphanedMutationClaims: true }, ); - const busy = entries.some((entry) => - stateHelperArtifactNamePatterns.some((pattern) => - pattern.test(entry.name))); + const busy = entries.some((entry) => isStateHelperArtifactName(entry.name)); if ( entries.length > MAX_QUERY_DIRECTORY_ENTRIES || entries.some((entry) => entry.kind !== "file") @@ -3015,11 +3007,7 @@ function reclaimAfterDurableRepair( directoryIdentity, { recoverOrphanedMutationClaims: true }, ); - if ( - entries.some((entry) => - stateHelperArtifactNamePatterns.some((pattern) => - pattern.test(entry.name))) - ) { + if (entries.some((entry) => isStateHelperArtifactName(entry.name))) { throw new Error( "read projection repaired durably but an in-flight state-helper artifact prevents reclamation", ); diff --git a/src/session-secrets.ts b/src/session-secrets.ts index 44d33d30..e738b53c 100644 --- a/src/session-secrets.ts +++ b/src/session-secrets.ts @@ -18,6 +18,7 @@ import { import { createPrivateJsonIfAbsent, ensurePrivateStateDirectory, + isStateHelperArtifactName, listPrivateStateDirectory, readPrivateStateFileIfPresent, readRegularFile, @@ -448,8 +449,9 @@ function encryptedStoreHasState(environment: Environment): boolean { environment, identity, ).some((entry) => - entry.name !== SESSION_SECRET_COORDINATE_DIRECTORY - || entry.kind !== "directory"); + (entry.name !== SESSION_SECRET_COORDINATE_DIRECTORY + || entry.kind !== "directory") + && (entry.kind !== "file" || !isStateHelperArtifactName(entry.name))); } function parseSessionKey(text: string): SessionKey { @@ -1060,6 +1062,8 @@ function assertSessionKeyOwnsEncryptedStore( if (entry.kind !== "file") { throw new Error("encrypted session store is malformed"); } + // An orphaned state-helper claim or staging artifact is not ciphertext. + if (isStateHelperArtifactName(entry.name)) continue; const text = readPrivateStateFileIfPresent( join(sessionDirectory, entry.name), MAX_ENCRYPTED_BYTES, diff --git a/src/social-video-capabilities.test.ts b/src/social-video-capabilities.test.ts index 04fff8cc..0f5ecc75 100644 --- a/src/social-video-capabilities.test.ts +++ b/src/social-video-capabilities.test.ts @@ -56,8 +56,6 @@ describe("social video provider contracts", () => { test("reserves one bounded MP4 route for every remaining requested web surface", () => { for (const [plugin, surfaceId] of [ - [linkedinWebPlugin, "linkedin"], - [substackWebPlugin, "substack"], [tiktokWebPlugin, "tiktok"], [metaWebPlugin, "instagram"], [youtubeWebPlugin, "youtube"], @@ -133,6 +131,35 @@ describe("social video provider contracts", () => { expect(publish.input.required).toEqual(["body", "media", "audience"]); }); + test("graduates exact LinkedIn MP4 publishing without widening image posts", () => { + const publish = operation(linkedinWebPlugin, "linkedin", "media.publish"); + expect(publish).toMatchObject({ + risk: "R3", + state: "observed", + contractVersion: 1, + dispatch: "single", + reconciliation: { kind: "provider-accepted-target-presence" }, + }); + expect(fileMediaTypes(publish.input.properties.media)).toEqual(["video/mp4"]); + expect(publish.input.required).toEqual(["body", "visibility", "media"]); + expect(publish.input.properties.visibility).toMatchObject({ + enum: ["public", "connections"], + }); + }); + + test("graduates exact Substack MP4 publishing without widening image posts", () => { + const publish = operation(substackWebPlugin, "substack", "media.publish"); + expect(publish).toMatchObject({ + risk: "R3", + state: "observed", + contractVersion: 1, + dispatch: "single", + reconciliation: { kind: "provider-accepted-target-presence" }, + }); + expect(fileMediaTypes(publish.input.properties.media)).toEqual(["video/mp4"]); + expect(publish.input.required).toEqual(["body", "media"]); + }); + test("graduates Reddit native MP4 publishing with an explicit poster and declarations", () => { const publish = operation(redditWebPlugin, "reddit", "media.publish"); expect(publish).toMatchObject({ diff --git a/src/state-crash-harness.test.ts b/src/state-crash-harness.test.ts index f8c951c1..4959f508 100644 --- a/src/state-crash-harness.test.ts +++ b/src/state-crash-harness.test.ts @@ -35,7 +35,13 @@ import { type StateCrashMutant, type StateCrashPlan, } from "./state-crash-port.test-support"; -import { installManifest, readJsonFile, writePrivateJson } from "./storage"; +import { + ensurePrivateStateDirectory, + ghostgetStateHome, + installManifest, + readJsonFile, + writePrivateJson, +} from "./storage"; import { assertAsyncProperty, fc, type AsyncCommand } from "./test-support"; // Laws, checked after every injected crash. A crash is a SIGKILL of the @@ -702,7 +708,50 @@ describe("crash-injected session secrets", () => { }, ), { numRuns: STATE_RUNS, interruptAfterTimeLimit: PROPERTY_TIME_LIMIT_MS }, + "state-crash-harness/session-secrets-boundary", + ); + }); + + test("an orphaned state-helper claim does not wedge a keyless session store", () => { + const space = workspace(); + const sessionDirectory = join( + ghostgetStateHome(space.environment), + "session-secrets", + ); + ensurePrivateStateDirectory(sessionDirectory, space.environment); + // The exact artifact a killed session-remove leaves behind. + writeFileSync( + join( + sessionDirectory, + ".io-mutation-b796038b9fa6dd5d27a938ea595cd46b8fc185d7981b41f8802985d64241bc0f-waiting-a872393f-cad7-4f7c-82be-5395226feb3f.lock", + ), + `${JSON.stringify({ + kind: "io-state-mutation-claim", + schemaVersion: 1, + targetSha256: "b796038b9fa6dd5d27a938ea595cd46b8fc185d7981b41f8802985d64241bc0f", + claimId: "a872393f-cad7-4f7c-82be-5395226feb3f", + pid: 4_000_000_001, + bootId: "6".repeat(64), + processStartId: "7".repeat(64), + })}\n`, + { mode: 0o600 }, + ); + + writeSessionSecret( + SESSION_NAMESPACE, + SESSION_AUTH_ID, + SESSION_AUTH_HASH, + { token: "first" }, + space.environment, + ); + writeSessionSecret( + SESSION_NAMESPACE, + SESSION_AUTH_ID, + SESSION_AUTH_HASH, + { token: "second" }, + space.environment, ); + expect(readSession(space)).toEqual({ token: "second" }); }); }); diff --git a/src/storage.ts b/src/storage.ts index c3c0305d..e425343a 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -2152,6 +2152,25 @@ export function removePrivateStateFileIfUnchanged( return response.removed === true; } +/** + * Names the state helper itself may leave behind: atomic-write temporaries, + * mutation-claim locks, staged mutation bytes, and remove quarantines. These + * are coordination artifacts, not stored payload — callers that need to know + * whether a directory holds real state must not count them. + */ +const stateHelperArtifactNamePatterns = Object.freeze([ + /^\.io-write-[1-9][0-9]{0,9}-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u, + /^\.io-mutation-[a-f0-9]{64}-(?:waiting|candidate|held)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.lock$/u, + /^\.io-mutation-stage-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}-[1-9][0-9]{0,9}\.tmp$/u, + /^\.io-remove-file-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.quarantine$/u, + /^\.io-remove(?:-tree)?-[1-9][0-9]{0,9}-[1-9][0-9]{0,15}-[0-9a-f]{12}4[0-9a-f]{3}[89ab][0-9a-f]{15}\.quarantine$/u, +]); + +/** True only for an inert state-helper coordination artifact name. */ +export function isStateHelperArtifactName(name: string): boolean { + return stateHelperArtifactNamePatterns.some((pattern) => pattern.test(name)); +} + export function listPrivateStateDirectory( path: string, environment: Readonly> = process.env, diff --git a/src/web-session-contract-definitions.ts b/src/web-session-contract-definitions.ts index 0acb68b4..5b9d61a1 100644 --- a/src/web-session-contract-definitions.ts +++ b/src/web-session-contract-definitions.ts @@ -380,6 +380,7 @@ const LINKEDIN_WEB_OPERATIONS = operationPolicies("linkedin", [ "comments.read", "contacts.read", "feeds.read", + "media.publish", "organizations.read", "posts.publish", "profiles.read", @@ -434,6 +435,7 @@ const SUBSTACK_WEB_OPERATIONS = operationPolicies("substack", [ "comments.read", "content.delete", "feeds.read", + "media.publish", "media.read", "messaging.list", "organizations.read", @@ -629,7 +631,14 @@ const linkedin = { LINKEDIN_WEB_OPERATIONS["comments.read"].contractVersion, ), "messaging.send": contract("linkedin", "messaging.send", "R3", "capture-required", "createMessage mutation requires a reviewed capture and response binding"), - "media.publish": contract("linkedin", "media.publish", "R3", "capture-required", "member video upload registration, contiguous signed transfer, processing/finalization, audience and actor binding, created-share response, and independent exact-share readback require an authorized fixture"), + "media.publish": contract( + "linkedin", + "media.publish", + LINKEDIN_WEB_OPERATIONS["media.publish"].risk, + LINKEDIN_WEB_OPERATIONS["media.publish"].state, + "reviewed contained-Chrome member video post: VIDEO_SHARING registration, bounded in-page staging, contiguous signed PUT transfers and multipart completion, member-bound create variables with VIDEO media category and optional title, normalized-included create response binding, and independent author/text/media permalink readback", + LINKEDIN_WEB_OPERATIONS["media.publish"].contractVersion, + ), "posts.publish": contract( "linkedin", "posts.publish", diff --git a/verification/seeds/corpus.json b/verification/seeds/corpus.json index 77579110..9b5a8a80 100644 --- a/verification/seeds/corpus.json +++ b/verification/seeds/corpus.json @@ -58,6 +58,17 @@ } ] }, + "state-crash-harness/session-secrets-boundary": { + "file": "src/state-crash-harness.test.ts", + "entries": [ + { + "kind": "counterexample", + "seed": -84709046, + "origin": "Local full-gate unit shard shrank two crashed session removals whose orphaned state-helper mutation-claim lock counted as encrypted state, wedging the keyless store.", + "regression": "an orphaned state-helper claim does not wedge a keyless session store" + } + ] + }, "verification-claims/guideline-additions": { "file": "scripts/verification-claims.test.ts", "entries": [ From d6af69ae9b9212f5ee9c89d85cd44a52fdf62d84 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:15:36 -0400 Subject: [PATCH 2/4] Release v0.18.86 Bump the package version, refresh install coordinates and changelog, rebuild dist, and re-measure the canonical package budget on the merged tree. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++ README.md | 10 ++++---- dist/apple-photos-client.js | 2 +- dist/beeper-client.js | 2 +- dist/{index-vtpm6ga0.js => index-0c7jvbdw.js} | 2 +- docs/publishing.md | 2 +- kb/launch/social-kit.md | 14 +++++------ package.json | 2 +- scripts/npm-release-workflow.test.ts | 24 +++++++++---------- scripts/package-budget.ts | 16 +++++++++---- skills/ghostget/references/install.md | 4 ++-- src/beeper-client-types.ts | 2 +- src/media/manifest.test.ts | 2 +- src/version.ts | 2 +- website/build.ts | 2 +- 15 files changed, 58 insertions(+), 40 deletions(-) rename dist/{index-vtpm6ga0.js => index-0c7jvbdw.js} (62%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4149be8a..92b8303a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ Historical entries retain their original delivery coordinates. ## Unreleased +## 0.18.86 + +Publish MP4 video on LinkedIn, Threads, and Substack and repair the post-create response bindings on four providers. + +- `linkedin-web` `media.publish@1` observes one plan-bound MP4: member video upload registration and byte transfer, the normalized create response with the nested share URN, durable accepted-target retention, and exact permalink readback. +- `threads` `media.publish@1` observes one plan-bound MP4: container create, transcode polling with rendition-dimension binding, and permalink readback. +- `substack-web` `media.publish@1` observes one plan-bound MP4: upload initialization, ordered presigned S3 multipart transfer, transcode and status polling, video-attachment binding, exact Note create, and independent readback of the bound `media_upload_id` and `mediaUpload` state. +- The X, Threads, LinkedIn, and Substack `posts.publish` create responses bind the shapes providers actually return, and the Substack Note create body and feed entity projection match the observed payloads. +- Derivation network-guard readiness now accepts wildcard domains: the sibling-negative probe sits outside the wildcard subtree instead of inside it. +- A crash-interrupted session removal no longer wedges the keyless session store: orphaned state-helper coordination artifacts no longer count as encrypted state, and the artifact-name predicate is shared with read projections. +- `substack-web` keeps its shipped `1.7.0` reader identity as a route-scoped predecessor distribution. + ## 0.18.85 Import the Beeper SDK without initializing the provider command definitions. diff --git a/README.md b/README.md index 5600872e..f3891057 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Install [Bun 1.3.14](https://bun.sh/docs/installation) if needed, then install GhostGet and read a public page: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.85/hraness-ghostget-0.18.85.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz ghostget read https://example.com ``` @@ -48,9 +48,9 @@ which always names the latest published release. Upgrading from Wrench? Read the The optional Agent Skill teaches your agent when and how to use GhostGet: ```sh -npx skills add hraness/ghostget#v0.18.85 +npx skills add hraness/ghostget#v0.18.86 # With Bun instead: -bunx skills add hraness/ghostget#v0.18.85 +bunx skills add hraness/ghostget#v0.18.86 ``` Start a new agent session, then ask: “Use GhostGet to read https://example.com @@ -182,7 +182,7 @@ firewall. ## Built-in provider catalog -This v0.18.85 source tree supports executable actions for 21 services: Beeper, +This v0.18.86 source tree supports executable actions for 21 services: Beeper, Bluesky, ClasificadosOnline, Facebook, Facebook Groups, Facebook Marketplace, GitHub, Gmail, Hacker News, Instagram, iMessage, LinkedIn, Reddit, Substack, Threads, TikTok, Twitch, WebMCP Registry, WhatsApp, X, and YouTube. LinkedIn @@ -266,7 +266,7 @@ For that same released coordinate, install GhostGet in an agent or application that owns its own model, planning, tool loop, approvals, and interface: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.85/hraness-ghostget-0.18.85.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz ``` ```ts diff --git a/dist/apple-photos-client.js b/dist/apple-photos-client.js index 1a43c7a6..caa8eddf 100644 --- a/dist/apple-photos-client.js +++ b/dist/apple-photos-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-vtpm6ga0.js"; +} from "./index-0c7jvbdw.js"; import { canonicalJson, sha256 diff --git a/dist/beeper-client.js b/dist/beeper-client.js index a54b7d9b..fd1392c0 100644 --- a/dist/beeper-client.js +++ b/dist/beeper-client.js @@ -1,7 +1,7 @@ // @bun import { GHOSTGET_VERSION -} from "./index-vtpm6ga0.js"; +} from "./index-0c7jvbdw.js"; import { canonicalJson, canonicalJsonSha256Matches, diff --git a/dist/index-vtpm6ga0.js b/dist/index-0c7jvbdw.js similarity index 62% rename from dist/index-vtpm6ga0.js rename to dist/index-0c7jvbdw.js index 3d94c20d..cda99862 100644 --- a/dist/index-vtpm6ga0.js +++ b/dist/index-0c7jvbdw.js @@ -1,5 +1,5 @@ // @bun // src/version.ts -var GHOSTGET_VERSION = "0.18.85"; +var GHOSTGET_VERSION = "0.18.86"; export { GHOSTGET_VERSION }; diff --git a/docs/publishing.md b/docs/publishing.md index f4e524de..2a72e14e 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -337,7 +337,7 @@ These commands require the matching published immutable v0.18.85 release. For the CLI: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.85/hraness-ghostget-0.18.85.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz ghostget --version ghostget doctor --json ``` diff --git a/kb/launch/social-kit.md b/kb/launch/social-kit.md index 5385172a..d70b8bfd 100644 --- a/kb/launch/social-kit.md +++ b/kb/launch/social-kit.md @@ -57,7 +57,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.85. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -115,7 +115,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.85. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -173,7 +173,7 @@ The plan is for GhostGet to stay small. Your agent does the thinking, and GhostG Post 9 of 9, 192 characters ```text -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.85. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -197,7 +197,7 @@ GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands o The plan is for GhostGet to stay small. Your agent does the thinking, and GhostGet runs only actions someone has reviewed. Each new service arrives as reviewed actions with their own previews. -GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.85. +GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. https://ghostget.com/blog/introducing-ghostget/ ``` @@ -224,8 +224,8 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - Sometimes a post goes through but the answer gets lost on the way back. GhostGet writes down every send before it leaves and never sends it again on its own until it knows what happened. - Anything beyond a read starts as a preview that shows the service, the account, and exactly what will be sent. Your agent can prepare it. Nothing is sent until someone confirms that exact preview. - GhostGet is for Claude Code, Codex, Cursor, and other agents that run commands on your Mac or Linux machine and need to read the web and use the accounts you already have. -- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.85. -- Latest release: v0.18.85. https://ghostget.com/blog/introducing-ghostget/ +- GhostGet is free, MIT licensed, and runs on macOS and Linux. The first step reads a public page and needs no account. Latest release: v0.18.86. +- Latest release: v0.18.86. https://ghostget.com/blog/introducing-ghostget/ ## Beats @@ -251,4 +251,4 @@ Topics: Developer Tools, Artificial Intelligence, Open Source - claimsTotal: 211. verification/claims.json, every claim - claimsEvidenced: 198. verification/claims.json, status evidenced - claimsConfigReadback: 9. verification/claims.json, layer configuration-readback -- status: Latest release: v0.18.85. package.json version +- status: Latest release: v0.18.86. package.json version diff --git a/package.json b/package.json index 934f8dee..f04b66fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hraness/ghostget", - "version": "0.18.85", + "version": "0.18.86", "description": "GhostGet gives your AI agent named web actions: read a page, archive one media item, or use a connected account, without credentials or a browser to steer.", "license": "MIT", "type": "module", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 6ccaee55..7f5c6256 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -359,7 +359,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(25_233_408); + expect(MAX_PACKAGE_TAR_BYTES).toBe(25_070_080); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -569,10 +569,10 @@ describe("npm publication contract", () => { expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - scope: "0.18.84 media.publish video lanes and create-response binding repairs", + scope: "0.18.86 media.publish video lanes and create-response binding repairs", npmVersion: "11.19.0", nodeVersion: "24.20.0", zlibVersion: "1.3.2.1-motley-42c2f19", - archiveSha256: "602e1d625e808b72fd8ed58134adc8434198a6c435ba63d881045069cf6f4870", - packedBytes: 12_259_609, unpackedBytes: 24_579_206, entryCount: 638, + archiveSha256: "78df7ff4f1934aeef092ad55cdbfcb8c8b5d6cf3543561044653afe14d95766d", + packedBytes: 12_231_057, unpackedBytes: 24_414_834, entryCount: 639, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -588,8 +588,8 @@ describe("npm publication contract", () => { expect(budget).toContain("24,024,705 + 353 + 65 = 24,025,123 unpacked"); expect(budget).toContain("12,152,562 + 12,387 + 4,096 = 12,169,045 packed"); expect(budget).toContain("24,093,786 + 353 + 65 = 24,094,204 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_276_092); - expect(MAX_PACKED_BYTES).toBe(12_259_609 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_247_540); + expect(MAX_PACKED_BYTES).toBe(12_231_057 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -710,7 +710,7 @@ describe("npm publication contract", () => { expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); expect(budget).toContain("23,688,277 + 353 + 65 = 23,688,695"); expect(budget).toContain("23,759,283 + 353 + 65 = 23,759,701"); - expect(MAX_UNPACKED_BYTES).toBe(24_579_624); + expect(MAX_UNPACKED_BYTES).toBe(24_415_252); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -743,7 +743,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(24_579_206 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(24_414_834 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -777,10 +777,10 @@ describe("npm publication contract", () => { expect(Object.isFrozen(range)).toBe(true); } expect(packageArtifactBudget).toEqual({ - entryCount: { min: 638, max: 638 }, - fileCount: { min: 638, max: 638 }, - packedBytes: { min: 1_600_000, max: 12_276_092 }, - unpackedBytes: { min: 9_000_000, max: 24_579_624 }, + entryCount: { min: 639, max: 639 }, + fileCount: { min: 639, max: 639 }, + packedBytes: { min: 1_600_000, max: 12_247_540 }, + unpackedBytes: { min: 9_000_000, max: 24_415_252 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 0705a1a0..2b6510e5 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2511,7 +2511,13 @@ // media.publish lanes, their accepted-target reconciliations, the X, // Threads, LinkedIn and Substack create-response binding repairs and the // derivation network-guard wildcard readiness repair over the reviewed -// 0.18.85 release. RE-MEASURED-PLACEHOLDER +// 0.18.85 release. Two clean npm 11.19.0 packs on Node 24.20.0 +// (zlib 1.3.2.1-motley-42c2f19) with --ignore-scripts on darwin arm64 were +// byte-identical at 639 files/entries, 12,231,057 packed bytes and +// 24,414,834 unpacked bytes; archive SHA-256 +// 78df7ff4f1934aeef092ad55cdbfcb8c8b5d6cf3543561044653afe14d95766d. Retain the +// reviewed allowances: 12,231,057 + 12,387 + 4,096 = 12,247,540 packed; +// 24,414,834 + 353 + 65 = 24,415,252 unpacked. export const repairPackageMeasurement = Object.freeze({ scope: "0.18.86 media.publish video lanes and create-response binding repairs", command: "npm pack --ignore-scripts", @@ -2519,10 +2525,10 @@ export const repairPackageMeasurement = Object.freeze({ nodeVersion: "24.20.0", zlibVersion: "1.3.2.1-motley-42c2f19", platform: "darwin-arm64", - archiveSha256: "602e1d625e808b72fd8ed58134adc8434198a6c435ba63d881045069cf6f4870", - packedBytes: 12_259_609, - unpackedBytes: 24_579_206, - entryCount: 638, + archiveSha256: "78df7ff4f1934aeef092ad55cdbfcb8c8b5d6cf3543561044653afe14d95766d", + packedBytes: 12_231_057, + unpackedBytes: 24_414_834, + entryCount: 639, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, diff --git a/skills/ghostget/references/install.md b/skills/ghostget/references/install.md index f7fb283d..e06f039a 100644 --- a/skills/ghostget/references/install.md +++ b/skills/ghostget/references/install.md @@ -24,7 +24,7 @@ of substituting `main`, another tag, or a different package version. Install that exact release and verify a public-page read: ```sh -bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.85/hraness-ghostget-0.18.85.tgz +bun add --global https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz ghostget read https://example.com ``` @@ -61,7 +61,7 @@ Do not clone the repository merely to run the CLI. Importing the SDK is a separate project dependency and does not install a global command: ```sh -bun add https://github.com/hraness/ghostget/releases/download/v0.18.85/hraness-ghostget-0.18.85.tgz +bun add https://github.com/hraness/ghostget/releases/download/v0.18.86/hraness-ghostget-0.18.86.tgz ``` `ghostget adapter sync-bundled` upgrades exact bundled baselines, including an diff --git a/src/beeper-client-types.ts b/src/beeper-client-types.ts index cf02489f..9ea44810 100644 --- a/src/beeper-client-types.ts +++ b/src/beeper-client-types.ts @@ -96,7 +96,7 @@ export type BeeperContactInteractionExportReceipt = Readonly<{ implementation: Readonly<{ producer: Readonly<{ package: "@hraness/ghostget"; - version: "0.18.85"; + version: "0.18.86"; }>; officialCli: Readonly<{ implementation: "github.com/beeper/cli"; diff --git a/src/media/manifest.test.ts b/src/media/manifest.test.ts index a41903ab..05963713 100644 --- a/src/media/manifest.test.ts +++ b/src/media/manifest.test.ts @@ -465,7 +465,7 @@ function trackedYtDlpManifest( describe("Ghostget media manifest", () => { test("uses one Ghostget-owned schema and transcriber identity", () => { expect(GHOSTGET_MEDIA_SCHEMA_VERSION).toBe(1); - expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.85"); + expect(GHOSTGET_MEDIA_VERSION).toBe("0.18.86"); expect(localTranscriptVariantSegments(localIdentity)).toEqual([ "transcript", "local", diff --git a/src/version.ts b/src/version.ts index 99c043ac..c08731f3 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Canonical immutable Ghostget package release identity. */ -export const GHOSTGET_VERSION = "0.18.85" as const; +export const GHOSTGET_VERSION = "0.18.86" as const; diff --git a/website/build.ts b/website/build.ts index 14cdbe93..a07770f0 100644 --- a/website/build.ts +++ b/website/build.ts @@ -137,7 +137,7 @@ export const HRANESS_LOGO_URL = "https://hraness.com/icon.png" as const; export const HRANESS_LINKEDIN_URL = "https://www.linkedin.com/company/hraness" as const; export const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@hraness/ghostget" as const; export const SKILL_REPOSITORY = "hraness/ghostget" as const; -export const CONTENT_REVIEWED_RELEASE = "v0.18.85" as const; +export const CONTENT_REVIEWED_RELEASE = "v0.18.86" as const; export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" as const; export const DEMO_PUBLIC_FILES = [ "wrench-first-capture.gif", From 0a853e44f10f5db0ed445c26d5cbf09b6e5831d8 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:01:31 -0400 Subject: [PATCH 3/4] Count observed LinkedIn video publishing in doctor readiness The linkedin-web media.publish promotion moves the operation out of the capture-required readiness bucket. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/ghostget.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ghostget.test.ts b/src/ghostget.test.ts index 18d8349e..3013bfc6 100644 --- a/src/ghostget.test.ts +++ b/src/ghostget.test.ts @@ -2264,6 +2264,7 @@ describe("doctor authenticated API readiness", () => { "comments.read", "contacts.read", "feeds.read", + "media.publish", "organizations.read", "posts.publish", "profiles.read", @@ -2273,7 +2274,6 @@ describe("doctor authenticated API readiness", () => { "articles.publish", "articles.read", "contacts.list", - "media.publish", "messaging.list", "messaging.read", "messaging.send", @@ -2341,6 +2341,7 @@ describe("doctor authenticated API readiness", () => { "comments.read", "contacts.read", "feeds.read", + "media.publish", "organizations.read", "posts.publish", "profiles.read", From 80aa735b4fc3caa88665538c9939c1bda5a7235a Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:18:48 -0400 Subject: [PATCH 4/4] Escape every HTML-sensitive character in the LinkedIn permalink fixture Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/providers/linkedin-web-post-browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/linkedin-web-post-browser.test.ts b/src/providers/linkedin-web-post-browser.test.ts index f0a0c6de..911a7a0c 100644 --- a/src/providers/linkedin-web-post-browser.test.ts +++ b/src/providers/linkedin-web-post-browser.test.ts @@ -355,7 +355,7 @@ describe("LinkedIn native post contained-browser transport", () => { const permalinkHtml = [ ``, `"entityUrn":"urn:li:activity:${activityId}"`, - `"commentary":{"text":"${postText.replace(/&/u, "&")}"}`, + `"commentary":{"text":"${postText.replace(/&/gu, "&").replace(//gu, ">").replace(/"/gu, """).replace(/'/gu, "'")}"}`, `href="https://www.linkedin.com/in/hraness/"`, `Ben Guo`, mediaUrn,