From d095048042b18072eaa27d171e1319fbeda17c53 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:32:00 -0400 Subject: [PATCH 1/2] x-web: qualify contacts.list for the viewer's follow collections Adds an observed contacts.list contract to x-web covering the signed-in viewer's own following and followers collections. The runtime reads the current first-party Following (GET) and Followers (POST) GraphQL queries, projects exact user identities plus both relationship-perspective flags, and forwards opaque cursors. A page that over-delivers relative to the requested limit exposes no continuation cursor, so consumers cannot mistake truncation for exhaustion. Adapter manifest moves to 1.15.0 with the 1.14.0 predecessor archived for packaged replay, the plugin binds the shared contact-projection sources, and the live qualification record documents descriptor, method, binding, pagination, and failure evidence. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 + README.md | 1 + docs/x-contacts-qualification.md | 74 ++ package.json | 1 + scripts/npm-release-workflow.test.ts | 26 +- scripts/package-budget.ts | 25 +- skills/ghostget/references/x-adapter.md | 5 + src/assets/adapters/x/wrench-web-adapter.json | 43 +- .../x/wrench-web-adapter.v1.14.0.json | 712 ++++++++++++++++++ src/ghostget.test.ts | 1 + src/model.test.ts | 2 +- src/platform-catalog.ts | 2 +- src/plugins/x-web/plugin.ts | 3 +- src/provider-contract-inventory.test.ts | 7 +- src/provider-plugin-registry.test.ts | 1 + src/providers/x-web-runtime.internal.test.ts | 372 +++++++++ src/providers/x-web-runtime.property.test.ts | 109 +++ src/providers/x-web-runtime.ts | 98 +++ src/providers/x-web.test.ts | 190 ++++- src/providers/x-web.ts | 223 +++++- src/scripts/sync-bundled-adapters.test.ts | 1 + src/web-session-contract-definitions.ts | 1 + 22 files changed, 1873 insertions(+), 32 deletions(-) create mode 100644 docs/x-contacts-qualification.md create mode 100644 src/assets/adapters/x/wrench-web-adapter.v1.14.0.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf307cc..56b3dc16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Historical entries retain their original delivery coordinates. ## Unreleased +- X `contacts.list` runs for the signed-in viewer's own following and + followers collections through the current first-party GraphQL queries + (`Following` over GET, `Followers` over POST). Each page returns user ID, + handle, display name, and both relationship directions — whether you follow + the listed account and whether it follows you — plus a continuation cursor. + Pages are bound to the authenticated viewer, non-user rows are excluded, and + a truncated page never exposes an unusable cursor. + ## 0.18.45 This release keeps the shipped type sources compiling under a consumer's diff --git a/README.md b/README.md index 441ad248..2651b443 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,7 @@ not turn missing message history into zero activity. | LinkedIn official API | First-degree connections with locale-selection evidence | Unavailable; the Connections API does not expose ordinary inbox history | | Instagram authenticated web | Unique non-viewer participants from the reviewed first Direct inbox summary page, with explicit first-page and pagination incompleteness | Unavailable until acknowledgement-free message-history paging is reviewed | | WhatsApp linked device | One page of the authenticated account owner's private, quiescent Whatsmeow contact store | Unavailable; Ghostget does not treat a linked-device message cache as account-owned history | +| X authenticated web | Viewer-bound pages of the signed-in account's own following and followers collections, with each row's user ID, handle, display name, and both relationship directions | Unavailable; the contract returns identity and relationship flags only | | Facebook authenticated web | Capture-required reservation for friends or Messenger participants | Capture-required | | Telegram | Not installed | Requires a reviewed TDLib user-session lifecycle; Ghostget does not substitute the Bot API or claim contact access | diff --git a/docs/x-contacts-qualification.md b/docs/x-contacts-qualification.md new file mode 100644 index 00000000..0487b5f4 --- /dev/null +++ b/docs/x-contacts-qualification.md @@ -0,0 +1,74 @@ +# X contacts qualification + +`contacts.list` is an observed contract. The `following` and `followers` +collections were qualified through the checkout's runtime on 2026-09-28 with an +authorized signed-in browser session (`cookie_source` locator; no cookie values +were printed or retained). No handles, display names, or user IDs appear in +this record. + +## Observed operations + +Descriptor evidence was extracted live from the web client's current main +bundle (`main.a9c37180a4c75840a.js`, observed 2026-09-28): + +| Logical operation | Query ID | Method | +| --- | --- | --- | +| `Following` | `uwmIAx89XrXNuGY-Y7WFLg` | `GET` | +| `Followers` | `mrqxgX8JzwlL6pvYiC5CPA` | `POST` | +| `FollowersYouKnow` | `kSjQs8VV3c9WKxUoutJcrw` | discovered, not routed | + +Both routed operations declare the same reviewed feature-switch and +field-toggle sets, all already mapped by the runtime. `FollowersYouKnow` is +recorded as evidence only; the contract keeps two semantic collections. + +## Method evidence + +`Following` returns 200 over `GET`. `Followers` returns an empty-body 404 over +`GET` and a populated 200 over `POST` with `{variables, queryId}` in the body. +The runtime pins each method exactly and fails closed on the other. + +## Response shape and binding + +Both responses root at `data.user.result.timeline.timeline`. The `User` owner +node echoes no identity fields in the observed responses, so binding is +structural: the request carries the authenticated viewer's `userId`, the +response must contain the reviewed `User` result node and timeline, and any +echoed identity fields must equal the viewer's ID. An explicitly mismatched +echoed identity is rejected. + +Timeline entries normalize through the reviewed URT instruction set +(add/replace/pin/remove/clear/terminate). `TimelineUser` items project +`providerId`, `handle`, `displayName`, `followsViewer`, and `followedByViewer` +plus the shared directional-statistics shape (marked unavailable; the contract +returns identity and relationship flags only). Unavailable or non-user rows +are excluded from the contact projection. + +## Relationship-perspective evidence + +`legacy.relationship_perspectives` is the viewer's perspective on the listed +account, verified live: on the viewer's own `following` page every sampled row +carried `following: true` (the viewer follows them) and a subset carried +`followed_by: true` (they follow the viewer). On the `followers` page sampled +rows carried `followed_by: true`. The projection maps +`followedByViewer ← following` and `followsViewer ← followed_by`. + +## Page-size and pagination evidence + +- `limit=20` returned exactly 20 projected users per collection. +- The provider returns about 50 user entries per page regardless of the + requested `count` (observed for `count` 20 and 100). The projection trims to + the caller's bound and exposes no cursor when it truncates, per the + over-limit cursor contract. +- Bottom cursors were present on non-truncated pages, forwarded verbatim on + continuation, and produced a second page with zero ID overlap against the + first (following page 2: 50 users; followers page 2: 49 users). +- Every sampled user row carried `rest_id`, screen name, and display name. + +## Failure evidence + +- A mismatched descriptor query ID fails before dispatch without adopting the + drifted value. +- `TimelineUser` rows whose `user_results.result` is `UserUnavailable` or + missing are projected as unavailable and excluded from contacts. +- An `echoed` owner identity that disagrees with the authenticated viewer is + rejected as account mismatch. diff --git a/package.json b/package.json index b3c8cf34..c56c6735 100644 --- a/package.json +++ b/package.json @@ -244,6 +244,7 @@ "src/assets/adapters/x/wrench-web-adapter.v1.11.0.json", "src/assets/adapters/x/wrench-web-adapter.v1.12.0.json", "src/assets/adapters/x/wrench-web-adapter.v1.13.0.json", + "src/assets/adapters/x/wrench-web-adapter.v1.14.0.json", "src/assets/adapters/youtube/wrench-web-adapter.json", "src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json", "src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json", diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 68129819..4df09dcf 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(24_574_976); + expect(MAX_PACKAGE_TAR_BYTES).toBe(24_613_888); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1428,8 +1428,8 @@ describe("npm publication contract", () => { expect(budget).toContain("0d6a1de00fd825d700b1ed0505b6fa34992f11d1deb42a5c255f7cfc295eedbc"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - archiveSha256: "0d6a1de00fd825d700b1ed0505b6fa34992f11d1deb42a5c255f7cfc295eedbc", - packedBytes: 12_125_761, unpackedBytes: 23_940_938, entryCount: 618, + archiveSha256: "4643e92ba1ac2f5a38c8bd7ba5bce47a2eb746e005930c2d9d577115206bc5d0", + packedBytes: 12_131_547, unpackedBytes: 23_979_139, entryCount: 619, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -1439,8 +1439,8 @@ describe("npm publication contract", () => { expect(budget).toContain("12,141,169 packed; 23,937,025 + 353 + 65 = 23,937,443 unpacked"); expect(budget).toContain("23,930,250 + 353 + 65 = 23,930,668 unpacked"); expect(budget).toContain("12,141,373 packed; 23,937,545 + 353 + 65 = 23,937,963 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_142_244); - expect(MAX_PACKED_BYTES).toBe(12_125_761 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_148_030); + expect(MAX_PACKED_BYTES).toBe(12_131_547 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -1535,8 +1535,8 @@ describe("npm publication contract", () => { expect(budget).toContain("11,696,091 + 4,096 = 11,700,187"); expect(budget).toContain("35449445752 attempt 1, package job 105913938839"); expect(budget).toContain("exactly 596 files"); - expect(MAX_PACKED_ENTRIES).toBe(618); - expect(MAX_PACKED_FILES).toBe(618); + expect(MAX_PACKED_ENTRIES).toBe(619); + expect(MAX_PACKED_FILES).toBe(619); expect(budget).toContain("Ghostget 0.18.6 same-boot setup-cleanup candidate over main edbe567"); expect(budget).toContain("11,656,173"); expect(budget).toContain("22,513,450 payload bytes across exactly 557 files"); @@ -1561,7 +1561,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(23_941_356); + expect(MAX_UNPACKED_BYTES).toBe(23_979_557); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1594,7 +1594,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(23_940_938 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(23_979_139 + 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"); @@ -1628,10 +1628,10 @@ describe("npm publication contract", () => { expect(Object.isFrozen(range)).toBe(true); } expect(packageArtifactBudget).toEqual({ - entryCount: { min: 618, max: 618 }, - fileCount: { min: 618, max: 618 }, - packedBytes: { min: 1_600_000, max: 12_142_244 }, - unpackedBytes: { min: 9_000_000, max: 23_941_356 }, + entryCount: { min: 619, max: 619 }, + fileCount: { min: 619, max: 619 }, + packedBytes: { min: 1_600_000, max: 12_148_030 }, + unpackedBytes: { min: 9_000_000, max: 23_979_557 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 9f0f3476..647b5720 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2094,15 +2094,28 @@ // Retain the same platform projections and allowances: // 12,125,761 + 12,387 + 4,096 = 12,142,244 packed; // 23,940,938 + 353 + 65 = 23,941,356 unpacked. +// +// The X contacts.list qualification adds the viewer-bound Following and +// Followers GraphQL collection reads, the TimelineUser normalizer, the +// contacts page projection on the shared directional-statistics shape, the +// archived x-web 1.14.0 adapter snapshot, and the qualification record over +// main 50f3ef99 (Ghostget 0.18.45). Registering the new archive snapshot in +// the package manifest grows the inventory to 619 entries: a clean npm +// 11.16.0 pack --ignore-scripts with Node 24.18.1 on darwin arm64 measured +// 12,131,547 packed bytes and 23,979,139 unpacked bytes; archive SHA-256 +// 4643e92ba1ac2f5a38c8bd7ba5bce47a2eb746e005930c2d9d577115206bc5d0. +// Retain the same platform projections and portability allowances: +// 12,131,547 + 12,387 + 4,096 = 12,148,030 packed; +// 23,979,139 + 353 + 65 = 23,979,557 unpacked. export const repairPackageMeasurement = Object.freeze({ - scope: "Ghostget 0.18.45 release over merged main 2ed33bb", + scope: "X contacts.list follow-collection qualification over Ghostget 0.18.45 main 50f3ef99", command: "npm pack --ignore-scripts", - npmVersion: "11.19.0", + npmVersion: "11.16.0", platform: "darwin-arm64", - archiveSha256: "0d6a1de00fd825d700b1ed0505b6fa34992f11d1deb42a5c255f7cfc295eedbc", - packedBytes: 12_125_761, - unpackedBytes: 23_940_938, - entryCount: 618, + archiveSha256: "4643e92ba1ac2f5a38c8bd7ba5bce47a2eb746e005930c2d9d577115206bc5d0", + packedBytes: 12_131_547, + unpackedBytes: 23_979_139, + entryCount: 619, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, diff --git a/skills/ghostget/references/x-adapter.md b/skills/ghostget/references/x-adapter.md index 4540fba8..43ec823a 100644 --- a/skills/ghostget/references/x-adapter.md +++ b/skills/ghostget/references/x-adapter.md @@ -141,6 +141,7 @@ Before private reads or mutations, resolve the current stable X user ID through The current registry marks these code-owned reads observed: - `feeds.read`: one bounded For You, Following, user, List, search, or bookmarks page; bookmarks pages also emit stable `items` keyed by `post_id`; +- `contacts.list`: one bounded viewer-bound page of the signed-in account's own `following` or `followers` collection through the current first-party GraphQL queries; - `posts.read`: one exact post through the current TweetDetail query; - `comments.read`: one bounded TweetDetail conversation/reply page; - `articles.read@2`: one exact current-viewer-owned private Article Draft. @@ -171,6 +172,10 @@ ghostget x-web feeds.read \ --input '{"feed":"bookmarks","limit":25}' \ --auth x-main --json +ghostget x-web contacts.list \ + --input '{"collection":"following","limit":50}' \ + --auth x-main --json + ghostget x-web posts.read \ --input '{"post_id":"POST_ID"}' \ --auth x-main --json diff --git a/src/assets/adapters/x/wrench-web-adapter.json b/src/assets/adapters/x/wrench-web-adapter.json index 4b31ac38..bc42438b 100644 --- a/src/assets/adapters/x/wrench-web-adapter.json +++ b/src/assets/adapters/x/wrench-web-adapter.json @@ -1,7 +1,7 @@ { "schemaVersion": 4, "id": "x-web", - "version": "1.14.0", + "version": "1.15.0", "displayName": "X (Authenticated Web API)", "surfaceId": "x", "origins": [ @@ -38,6 +38,47 @@ "maxOutputBytes": 2097152 } }, + "contacts.list": { + "description": "Observed contract: read one bounded page of the signed-in viewer's own X following or followers collection through the current captured first-party Following (GET) or Followers (POST) GraphQL query; each page returns exact user identities (user ID, handle, display name) plus provider relationship-perspective flags and a next-page cursor, and an over-limit page fails without exposing its end cursor", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "collection": { + "type": "string", + "description": "Viewer follow collection", + "enum": [ + "following", + "followers" + ] + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum user entries to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "collection" + ] + }, + "webSession": { + "site": "x", + "action": "contacts.list", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, "feeds.read": { "description": "Observed contract: read one complete bounded X For You, Following, user, List, search, or bookmarks provider page through its current captured first-party GraphQL query; bookmarks pages emit stable export items keyed by post_id plus a next-page cursor; user/List responses must echo the requested identity and an over-limit page fails without exposing its end cursor.", "risk": "R1", diff --git a/src/assets/adapters/x/wrench-web-adapter.v1.14.0.json b/src/assets/adapters/x/wrench-web-adapter.v1.14.0.json new file mode 100644 index 00000000..4b31ac38 --- /dev/null +++ b/src/assets/adapters/x/wrench-web-adapter.v1.14.0.json @@ -0,0 +1,712 @@ +{ + "schemaVersion": 4, + "id": "x-web", + "version": "1.14.0", + "displayName": "X (Authenticated Web API)", + "surfaceId": "x", + "origins": [ + "https://x.com" + ], + "browserDomains": [ + "x.com" + ], + "operations": { + "profiles.read": { + "description": "Observed contract: read one exact X profile through the current captured first-party UserByScreenName GraphQL query with target-bound exact follower and following counts.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "handle": { + "type": "string", + "description": "Exact X handle without a leading @", + "minLength": 1, + "maxLength": 15 + } + }, + "required": [ + "handle" + ] + }, + "webSession": { + "site": "x", + "action": "profiles.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "feeds.read": { + "description": "Observed contract: read one complete bounded X For You, Following, user, List, search, or bookmarks provider page through its current captured first-party GraphQL query; bookmarks pages emit stable export items keyed by post_id plus a next-page cursor; user/List responses must echo the requested identity and an over-limit page fails without exposing its end cursor.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "feed": { + "type": "string", + "description": "Exact X timeline or discovery surface", + "enum": [ + "for-you", + "following", + "user", + "list", + "search", + "bookmarks" + ] + }, + "user_id": { + "type": "string", + "description": "Exact X user ID for a user feed", + "minLength": 1, + "maxLength": 32 + }, + "list_id": { + "type": "string", + "description": "Exact X List ID", + "minLength": 1, + "maxLength": 32 + }, + "query": { + "type": "string", + "description": "Exact X search query", + "minLength": 1, + "maxLength": 512 + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum timeline entries to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "feed" + ] + }, + "webSession": { + "site": "x", + "action": "feeds.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "posts.read": { + "description": "Observed contract: read one X post through the current captured TweetDetail first-party GraphQL query with a bounded normalized projection.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact X post ID", + "minLength": 1, + "maxLength": 32 + } + }, + "required": [ + "post_id" + ] + }, + "webSession": { + "site": "x", + "action": "posts.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "comments.read": { + "description": "Observed contract: read one complete bounded X conversation page through the current captured TweetDetail query; an over-limit reply page fails without exposing its end cursor.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact root X post ID", + "minLength": 1, + "maxLength": 32 + }, + "cursor": { + "type": "string", + "description": "Opaque conversation cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum conversation entries to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "post_id" + ] + }, + "webSession": { + "site": "x", + "action": "comments.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "messaging.list": { + "description": "Capture-required contract reservation: current X DMs use encrypted X Chat events, so plaintext inbox listing remains disabled until verified key recovery is installed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "folder": { + "type": "string", + "description": "Exact X direct-message folder", + "enum": [ + "inbox", + "requests", + "additional" + ] + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum conversations to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "folder" + ] + }, + "webSession": { + "site": "x", + "action": "messaging.list", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "messaging.read": { + "description": "Capture-required contract reservation: current X Chat events are encrypted, so plaintext conversation reads remain disabled until verified key recovery and acknowledgement-free handling are installed.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "conversation_id": { + "type": "string", + "description": "Exact opaque conversation identifier returned by messaging.list", + "minLength": 1, + "maxLength": 512 + }, + "cursor": { + "type": "string", + "description": "Opaque cursor returned by the preceding page", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "number", + "description": "Maximum messages to project", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "conversation_id" + ] + }, + "webSession": { + "site": "x", + "action": "messaging.read", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "articles.read": { + "description": "Observed contract: read one exact current-viewer-owned private X Article draft through the current ArticleEntityResultByRestId query. The closed output binds the exact article ID, viewer-owner ID, Draft lifecycle, unpublished state, one-line title, and bounded rich content; published Articles remain outside this contract.", + "risk": "R1", + "sideEffect": "none", + "idempotency": "none", + "dedupeWindowMs": 0, + "input": { + "properties": { + "article_id": { + "type": "string", + "description": "Exact 1-19 digit private X Article draft ID owned by the current viewer", + "minLength": 1, + "maxLength": 19 + } + }, + "required": [ + "article_id" + ] + }, + "webSession": { + "site": "x", + "action": "articles.read", + "contractVersion": 2, + "timeoutMs": 60000, + "maxOutputBytes": 4194304 + } + }, + "messaging.send": { + "description": "Capture-required contract reservation: send one confirmed X DM, optionally with one reviewed attachment, after the current mutation, target binding, and media path are captured and reviewed.", + "risk": "R3", + "sideEffect": "Sends one externally visible X direct message to the exact confirmed user or conversation.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "target_kind": { + "type": "string", + "description": "Send to an existing conversation or one exact X user", + "enum": [ + "conversation", + "user" + ] + }, + "target_id": { + "type": "string", + "description": "Exact conversation or X user ID selected by target_kind", + "minLength": 1, + "maxLength": 512 + }, + "body": { + "type": "string", + "description": "Exact direct-message text; an optional reviewed attachment may accompany it", + "minLength": 1, + "maxLength": 1000 + }, + "media": { + "type": "file", + "description": "Optional reviewed direct-message image, GIF, or video", + "maxBytes": 536870912, + "mediaTypes": [ + "image/jpeg", + "image/png", + "image/gif", + "video/mp4" + ] + }, + "media_alt_text": { + "type": "string", + "description": "Optional accessibility text for reviewed image media", + "minLength": 1, + "maxLength": 1000 + } + }, + "required": [ + "target_kind", + "target_id", + "body" + ] + }, + "webSession": { + "site": "x", + "action": "messaging.send", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "posts.publish": { + "description": "Observed contract: publish one confirmed X post of up to 25000 UTF-16 units through pixels-only optional PNG or MP4 upload scrub, CreateTweet response, exact text bind including note_tweet long-form, and fail-closed TweetResultByRestId Made with AI readback.", + "risk": "R3", + "sideEffect": "Publishes one externally visible X post with the exact confirmed content and reply audience.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "body": { + "type": "string", + "description": "Exact post content; accepts current X long posts up to the reviewed CreateTweet 25000-unit bound and never truncates", + "minLength": 1, + "maxLength": 25000 + }, + "media": { + "type": "file", + "description": "Optional single plan-bound PNG or MP4", + "maxBytes": 536870912, + "mediaTypes": [ + "image/png", + "video/mp4" + ] + }, + "media_type": { + "type": "string", + "description": "Exact media type when one PNG or MP4 is attached", + "enum": [ + "image/png", + "video/mp4" + ] + } + }, + "required": [ + "body" + ] + }, + "webSession": { + "site": "x", + "action": "posts.publish", + "contractVersion": 5, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "threads.publish": { + "description": "Capture-required contract reservation: publish one confirmed text-only X thread only after an authorized live fixture proves each current CreateTweet dispatch. Per-item maxLength stays 280 because reply composition and thread-split still use the short-item bound; prefer one long posts.publish for a single long post.", + "risk": "R3", + "sideEffect": "Publishes one externally visible ordered X thread containing every exact confirmed item.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "items": { + "type": "array", + "description": "Exact ordered post texts, beginning with the thread root", + "items": { + "type": "string", + "description": "One exact X thread item", + "minLength": 1, + "maxLength": 280 + }, + "minItems": 1, + "maxItems": 25 + } + }, + "required": [ + "items" + ] + }, + "webSession": { + "site": "x", + "action": "threads.publish", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "replies.create": { + "description": "Capture-required contract reservation: publish one confirmed text-only reply only after an authorized live fixture proves current transaction-header and parent binding behavior.", + "risk": "R3", + "sideEffect": "Publishes one externally visible X reply to the exact confirmed parent post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact parent X post ID", + "minLength": 1, + "maxLength": 32 + }, + "body": { + "type": "string", + "description": "Exact text-only reply content", + "minLength": 1, + "maxLength": 280 + } + }, + "required": [ + "post_id", + "body" + ] + }, + "webSession": { + "site": "x", + "action": "replies.create", + "contractVersion": 1, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "posts.repost": { + "description": "Capture-required contract reservation: set repost state only after an authorized live fixture proves current transaction-header and readback behavior.", + "risk": "R3", + "sideEffect": "Creates or removes the authenticated account's repost of the exact confirmed X post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact X post ID", + "minLength": 1, + "maxLength": 32 + }, + "reposted": { + "type": "boolean", + "description": "Exact desired repost state" + } + }, + "required": [ + "post_id", + "reposted" + ] + }, + "webSession": { + "site": "x", + "action": "posts.repost", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "posts.quote": { + "description": "Capture-required contract reservation: publish one confirmed text-only quote only after an authorized live fixture proves current transaction-header and quote binding behavior.", + "risk": "R3", + "sideEffect": "Publishes one externally visible X quote post for the exact confirmed source post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact quoted X post ID", + "minLength": 1, + "maxLength": 32 + }, + "body": { + "type": "string", + "description": "Exact text-only quote content; same reviewed CreateTweet 25000-unit bound as posts.publish", + "minLength": 1, + "maxLength": 25000 + } + }, + "required": [ + "post_id", + "body" + ] + }, + "webSession": { + "site": "x", + "action": "posts.quote", + "contractVersion": 2, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "likes.set": { + "description": "Observed contract: set or clear one exact X like through the current FavoriteTweet or UnfavoriteTweet mutation, then verify the desired state through an independent TweetResultByRestId readback.", + "risk": "R2", + "sideEffect": "Sets or clears the authenticated account's like on the exact confirmed X post.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact X post ID", + "minLength": 1, + "maxLength": 32 + }, + "liked": { + "type": "boolean", + "description": "Exact desired like state" + } + }, + "required": [ + "post_id", + "liked" + ] + }, + "webSession": { + "site": "x", + "action": "likes.set", + "contractVersion": 2, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "content.save": { + "description": "Observed contract: set or clear one exact X bookmark through the current first-party mutation, then verify the desired state through an independent post readback.", + "risk": "R2", + "sideEffect": "Saves or removes the exact confirmed X post in the authenticated account's bookmarks.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact X post ID", + "minLength": 1, + "maxLength": 32 + }, + "saved": { + "type": "boolean", + "description": "Exact desired bookmark state" + } + }, + "required": [ + "post_id", + "saved" + ] + }, + "webSession": { + "site": "x", + "action": "content.save", + "contractVersion": 1, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "content.delete": { + "description": "Capture-required contract reservation: delete one exact current-account X post only after the current DeleteTweet request, accepted response, author binding, and exact not-found readback are live-proven.", + "risk": "R3", + "sideEffect": "Permanently deletes the exact confirmed authored X post from the authenticated account.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 2592000000, + "input": { + "properties": { + "post_id": { + "type": "string", + "description": "Exact X post ID", + "minLength": 1, + "maxLength": 32 + }, + "expected_text": { + "type": "string", + "description": "Exact current post text used for pre-delete confirmation; same reviewed CreateTweet 25000-unit bound as posts.publish", + "minLength": 1, + "maxLength": 25000 + } + }, + "required": [ + "post_id", + "expected_text" + ] + }, + "webSession": { + "site": "x", + "action": "content.delete", + "contractVersion": 2, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + }, + "articles.draft.save": { + "description": "Create or replace one private native X Article draft from ArticleDraftDocument schemaVersion 2 with ordered local inline images. This operation has no publish-capable branch.", + "risk": "R2", + "sideEffect": "Creates one private native X Article draft, or replaces the exact confirmed existing private draft, with the confirmed title, structure, styles, native links, inline image bytes, and captions; it never publishes the draft.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "title": { + "type": "string", + "description": "Exact native article title", + "minLength": 1, + "maxLength": 100 + }, + "document": { + "type": "string", + "description": "Canonical JSON for ArticleDraftDocument schemaVersion 2; supports bounded paragraphs, headings, lists, blockquotes, native HTTPS links, text styles, and ordered inline image blocks with optional captions; X Article image alt text remains capture-required", + "minLength": 1, + "maxLength": 524288 + }, + "draft_id": { + "type": "string", + "description": "Optional exact existing private X Article draft to replace in place", + "minLength": 1, + "maxLength": 19 + }, + "inline_images": { + "type": "array", + "description": "Ordered plan-bound local image files referenced by document imageIndex", + "items": { + "type": "file", + "description": "One exact JPEG, PNG, or WebP inline image", + "maxBytes": 5242880, + "mediaTypes": [ + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "minItems": 1, + "maxItems": 20 + } + }, + "required": [ + "title", + "document", + "inline_images" + ] + }, + "webSession": { + "site": "x", + "action": "articles.draft.save", + "contractVersion": 2, + "timeoutMs": 600000, + "maxOutputBytes": 2097152 + } + }, + "articles.publish": { + "description": "Capture-required reservation: publish one exact existing private X Article draft only after the distinct publish mutation and public readback are captured and response-bound.", + "risk": "R3", + "sideEffect": "Publishes one externally visible native X Article from the exact confirmed private draft.", + "idempotency": "local-at-most-once", + "dedupeWindowMs": 86400000, + "input": { + "properties": { + "title": { + "type": "string", + "description": "Exact native article title used for confirmation", + "minLength": 1, + "maxLength": 100 + }, + "body": { + "type": "string", + "description": "Exact native article body used for confirmation", + "minLength": 1, + "maxLength": 20000 + }, + "draft_id": { + "type": "string", + "description": "Exact private X Article draft to publish", + "minLength": 1, + "maxLength": 19 + } + }, + "required": [ + "title", + "body", + "draft_id" + ] + }, + "webSession": { + "site": "x", + "action": "articles.publish", + "contractVersion": 4, + "timeoutMs": 60000, + "maxOutputBytes": 2097152 + } + } + } +} diff --git a/src/ghostget.test.ts b/src/ghostget.test.ts index 6a1f121a..b7c841bd 100644 --- a/src/ghostget.test.ts +++ b/src/ghostget.test.ts @@ -2197,6 +2197,7 @@ describe("doctor authenticated API readiness", () => { "articles.draft.save", "articles.read", "comments.read", + "contacts.list", "content.save", "feeds.read", "likes.set", diff --git a/src/model.test.ts b/src/model.test.ts index 7479ab4c..92ed565a 100644 --- a/src/model.test.ts +++ b/src/model.test.ts @@ -994,7 +994,7 @@ describe("ghostget manifest parsing", () => { const current = parseRuntimeManifest(currentValue); expect(current.ok).toBeTrue(); if (!current.ok) return; - expect(current.value.version).toBe("1.14.0"); + expect(current.value.version).toBe("1.15.0"); const article = current.value.operations["articles.draft.save"]; expect(article !== undefined && isWebSessionOperation(article)).toBeTrue(); if (article === undefined || !isWebSessionOperation(article)) return; diff --git a/src/platform-catalog.ts b/src/platform-catalog.ts index bc487652..363cc0a3 100644 --- a/src/platform-catalog.ts +++ b/src/platform-catalog.ts @@ -634,7 +634,7 @@ export const socialPlatformCatalog = { displayName: "X", originPolicy: exactOrigins("https://x.com"), operations: buildOperationMatrix({ - R1: ["content.read", "content.clip", "profiles.read", "messaging.read", "comments.read", "posts.read", "media.read", "articles.read"], + R1: ["content.read", "content.clip", "profiles.read", "contacts.list", "messaging.read", "comments.read", "posts.read", "media.read", "articles.read"], R2: ["likes.set", "relationships.follow.set", "content.save", "articles.draft.save", "communities.membership.set"], R3: [ "messaging.send", diff --git a/src/plugins/x-web/plugin.ts b/src/plugins/x-web/plugin.ts index 328daba0..044a25ed 100644 --- a/src/plugins/x-web/plugin.ts +++ b/src/plugins/x-web/plugin.ts @@ -153,7 +153,7 @@ function xArticleDraftV2Dispatches( const currentOperations = webSessionContractOperations( Object.values(webSessionContractDefinitions.x), - "c3d649bf6fa94a2f6488fefbafc65e20474993e6c90c99ddf46a82caea6c892d", + "fb212bb81002fc447ff2661a9dc77032de66cfc652b595582aa74b262247dbd4", { "likes.set": [1], }, @@ -411,6 +411,7 @@ export const xWebPlugin = defineProviderPlugin({ sourceKind: "built-in", implementationSources: webImplementationSources(import.meta.url, [ ["providers/read-failure.ts", "../../providers/read-failure.ts"], + ["providers/contact-projection.ts", "../../providers/contact-projection.ts"], ["kernel/browser.ts", "../../browser.ts"], ["kernel/article-draft-document.ts", "../../article-draft-document.ts"], ["kernel/article-draft-images.ts", "../../article-draft-images.ts"], diff --git a/src/provider-contract-inventory.test.ts b/src/provider-contract-inventory.test.ts index b7ace941..17f1ca8a 100644 --- a/src/provider-contract-inventory.test.ts +++ b/src/provider-contract-inventory.test.ts @@ -124,7 +124,8 @@ function isCurrentOnlyRow(row) { || (row[0] === "web-session-api" && row[1] === "reddit" && row[2].startsWith("flair.")) || (row[0] === "web-session-api" && row[1] === "substack" && row[2].startsWith("subscribers.")) || (row[0] === "web-session-api" && row[1] === "twitch") - || (row[0] === "web-session-api" && row[1] === "webmcp"); + || (row[0] === "web-session-api" && row[1] === "webmcp") + || (row[0] === "web-session-api" && row[1] === "x" && row[2] === "contacts.list"); } function appendCurrentRow(row) { if (isCurrentOnlyRow(row)) { @@ -320,8 +321,8 @@ describe("durable provider contract inventory", () => { expect(inventory).toEqual({ rows: 324, sha256: predecessorDefaultInventorySha256, - currentOnlyRows: 86, - currentOnlySha256: "ffaccd11cd17ec726eab2f0782eb01323b69dea3ab8df1a524227b2adfa7c02e", + currentOnlyRows: 87, + currentOnlySha256: "b29cb94d703206f16b9457ed5ad4a45408cd562a48ea290c59289abc70ba2e07", automationRows: [ ["linked-device", "whatsapp", "messaging.automation.read", 1], ["linked-device", "whatsapp", "messaging.automation.send.attachment", 1], diff --git a/src/provider-plugin-registry.test.ts b/src/provider-plugin-registry.test.ts index b47424d3..47a95e90 100644 --- a/src/provider-plugin-registry.test.ts +++ b/src/provider-plugin-registry.test.ts @@ -4111,6 +4111,7 @@ describe("provider plugin definition and registry", () => { "linkedin-official", "meta-web", "whatsapp-linked-device", + "x-web", ]); expect(sharedProjectionProviderIds).toEqual( observedContactProviderIds.filter( diff --git a/src/providers/x-web-runtime.internal.test.ts b/src/providers/x-web-runtime.internal.test.ts index 12715485..b9b0c692 100644 --- a/src/providers/x-web-runtime.internal.test.ts +++ b/src/providers/x-web-runtime.internal.test.ts @@ -33,6 +33,9 @@ const MAIN_URL = "https://abs.twimg.com/responsive-web/client-web/main.abcdef12. const VIEWER_QUERY_ID = "9t128XgFic52jPUEkJMf6w"; const BOOKMARKS_QUERY_ID = "-dgKZ58Dr9YSJYrcgEb5KA"; const USER_TWEETS_QUERY_ID = "jeAA-59Y9FL7FmjgBNIVPw"; +const FOLLOWING_QUERY_ID = "uwmIAx89XrXNuGY-Y7WFLg"; +const FOLLOWERS_QUERY_ID = "mrqxgX8JzwlL6pvYiC5CPA"; +const CONTACT_USER_ID = "998877665544332211"; const SEARCH_TIMELINE_QUERY_ID = "auLkqtmHqYEpRvflfvLhyQ"; const ARTICLE_QUERY_ID = "_rbmb_NKLqKVBr5X_MSoMQ"; const ARTICLE_BUNDLE_URL = "https://abs.twimg.com/responsive-web/client-web/bundle.TwitterArticles.305538ca.js"; @@ -245,6 +248,54 @@ function userFeedResponse(userId: string, ...entries: readonly unknown[]): unkno }; } +function contactEntry( + id: string, + username: string, + name: string, + relationship: { readonly following?: boolean; readonly followedBy?: boolean } | null = null, +): unknown { + return { + entryId: `user-${id}`, + sortIndex: id, + content: { + entryType: "TimelineTimelineItem", + itemContent: { + itemType: "TimelineUser", + user_results: { + result: { + __typename: "User", + rest_id: id, + core: { name, screen_name: username }, + ...(relationship === null ? {} : { + relationship_perspectives: { + ...(relationship.following === undefined ? {} : { following: relationship.following }), + ...(relationship.followedBy === undefined ? {} : { followed_by: relationship.followedBy }), + }, + }), + }, + }, + }, + }, + }; +} + +function contactsFeedResponse(userId: string | null, ...entries: readonly unknown[]): unknown { + return { + data: { + user: { + result: { + __typename: "User", + // The live Following/Followers documents return a bare User timeline + // node with no echoed identity; rest_id appears only when a test + // models an identity-carrying variant. + ...(userId === null ? {} : { rest_id: userId }), + timeline: { timeline: timeline(...entries) }, + }, + }, + }, + }; +} + function userFeedResponseCurrentShape(userId: string, ...entries: readonly unknown[]): unknown { return { data: { @@ -1340,6 +1391,327 @@ describe("X authenticated internal-API runtime", () => { expect(result.error).not.toContain("SXVCYB8XHSS25nzIljNtZA"); }); + test("exports a viewer-bound following page with a continuation cursor", async () => { + const calls: CapturedRequest[] = []; + const runtimeDependencies = dependencies(calls, (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("Following", FOLLOWING_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse( + null, + contactEntry(CONTACT_USER_ID, "friendhandle", "Friend Name", { following: true, followedBy: true }), + cursorEntry("next-following-page"), + )); + }); + const result = await executeXWebOperation( + xRecipe("contacts.list"), + { collection: "following", limit: 10 }, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result.status).toBe("succeeded"); + expect(result.dispatchStarted).toBe(false); + expect(result.dispatch).toEqual({ planned: 0, started: 0, verified: 0 }); + expect(result.finalUrl).toBe("https://x.com/wrench_test/following"); + expect(result.output).toMatchObject({ + collection: "following", + viewerId: VIEWER_ID, + users: [{ + providerId: CONTACT_USER_ID, + handle: "friendhandle", + displayName: "Friend Name", + followsViewer: true, + followedByViewer: true, + }], + cursor: "next-following-page", + }); + const dispatch = calls.find((call) => call.url.pathname.endsWith("/Following")); + expect(dispatch).toBeDefined(); + expect(dispatch?.method).toBe("GET"); + expect(dispatch?.url.pathname).toBe(`/i/api/graphql/${FOLLOWING_QUERY_ID}/Following`); + expect(JSON.parse(dispatch?.url.searchParams.get("variables") ?? "{}")).toEqual({ + userId: VIEWER_ID, + count: 10, + includePromotedContent: false, + }); + }); + + test("exports a viewer-bound followers page with exact relationship flags", async () => { + const calls: CapturedRequest[] = []; + const runtimeDependencies = dependencies(calls, (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("Followers", FOLLOWERS_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse( + null, + contactEntry(CONTACT_USER_ID, "mutualhandle", "Mutual Name", { following: true, followedBy: true }), + contactEntry("887766554433221100", "silenthandle", "Silent Name", { following: false, followedBy: true }), + )); + }); + const result = await executeXWebOperation( + xRecipe("contacts.list"), + { collection: "followers", cursor: "provider-cursor-token" }, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result.status).toBe("succeeded"); + expect(result.finalUrl).toBe("https://x.com/wrench_test/followers"); + expect(result.output).toMatchObject({ + collection: "followers", + viewerId: VIEWER_ID, + users: [ + { + providerId: CONTACT_USER_ID, + handle: "mutualhandle", + displayName: "Mutual Name", + followsViewer: true, + followedByViewer: true, + }, + { + providerId: "887766554433221100", + handle: "silenthandle", + displayName: "Silent Name", + followsViewer: true, + followedByViewer: false, + }, + ], + cursor: null, + }); + const dispatch = calls.find((call) => call.url.pathname.endsWith("/Followers")); + expect(dispatch?.method).toBe("POST"); + expect(dispatch?.url.pathname).toBe(`/i/api/graphql/${FOLLOWERS_QUERY_ID}/Followers`); + expect(JSON.parse(dispatch?.body ?? "{}")).toMatchObject({ + variables: { + userId: VIEWER_ID, + count: 20, + includePromotedContent: false, + cursor: "provider-cursor-token", + }, + queryId: FOLLOWERS_QUERY_ID, + }); + }); + + test("fails closed when the contact timeline binds a different user", async () => { + const runtimeDependencies = dependencies([], (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("Following", FOLLOWING_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse( + CONTACT_USER_ID, + contactEntry(CONTACT_USER_ID, "someone", "Someone"), + )); + }); + const result = await executeXWebOperation( + xRecipe("contacts.list"), + { collection: "following", limit: 10 }, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result).toMatchObject({ + status: "failed", + output: null, + dispatchStarted: false, + }); + expect(result.error).toContain("did not bind the requested user"); + }); + + test("keeps unavailable and non-user rows out of the contacts projection", async () => { + const runtimeDependencies = dependencies([], (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("Following", FOLLOWING_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse( + null, + { + entryId: "user-tombstoned", + sortIndex: "5", + content: { + entryType: "TimelineTimelineItem", + itemContent: { + itemType: "TimelineUser", + user_results: { + result: { __typename: "UserUnavailable", reason: "Suspended" }, + }, + }, + }, + }, + { + entryId: "user-empty", + sortIndex: "4", + content: { + entryType: "TimelineTimelineItem", + itemContent: { itemType: "TimelineUser" }, + }, + }, + contactEntry(CONTACT_USER_ID, "realcontact", "Real Contact"), + )); + }); + const result = await executeXWebOperation( + xRecipe("contacts.list"), + { collection: "following", limit: 10 }, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result.status).toBe("succeeded"); + expect((result.output as { users: readonly { providerId: string }[] }).users.map((user) => user.providerId)).toEqual([ + CONTACT_USER_ID, + ]); + }); + + test("never returns a provider end cursor after truncating unseen contacts", async () => { + const runtimeDependencies = dependencies([], (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("Following", FOLLOWING_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse( + null, + contactEntry(CONTACT_USER_ID, "first", "First"), + contactEntry("887766554433221100", "second", "Second"), + cursorEntry("would-skip-second"), + )); + }); + const result = await executeXWebOperation( + xRecipe("contacts.list"), + { collection: "following", limit: 1 }, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result.status).toBe("succeeded"); + expect(result.output).toMatchObject({ + users: [{ providerId: CONTACT_USER_ID }], + cursor: null, + }); + expect(JSON.stringify(result.output)).not.toContain("would-skip-second"); + }); + + test("rejects an oversized contacts page that exposes no continuation cursor", async () => { + const runtimeDependencies = dependencies([], (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("Following", FOLLOWING_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse( + null, + contactEntry(CONTACT_USER_ID, "first", "First"), + contactEntry("887766554433221100", "second", "Second"), + )); + }); + const message = await rejectionMessage(executeXWebOperation( + xRecipe("contacts.list"), + { collection: "following", limit: 1 }, + xAuth, + { dependencies: runtimeDependencies }, + )); + expect(message).toContain("more entries than the requested limit"); + expect(message).toContain("no continuation cursor was exposed"); + }); + + test("fails closed when the current Following query ID drifted", async () => { + const calls: CapturedRequest[] = []; + const runtimeDependencies = dependencies(calls, (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("Following", "ChangedQueryId_12345", "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + throw new Error(`unexpected Following dispatch ${request.url.href}`); + }); + const result = await executeXWebOperation( + xRecipe("contacts.list"), + { collection: "following", limit: 10 }, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result).toMatchObject({ + status: "failed", + output: null, + dispatchStarted: false, + readFailure: { category: "contract-drift" }, + }); + expect(result.error).toContain("query-ID drift"); + expect(result.error).toContain("Following:query"); + expect(calls.some((call) => call.url.pathname.includes("/i/api/graphql/ChangedQueryId_12345"))).toBe(false); + }); + + test("rejects unsupported collections and out-of-range limits before dispatch", async () => { + const calls: CapturedRequest[] = []; + const runtimeDependencies = dependencies(calls, (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("Following", FOLLOWING_QUERY_ID, "query"), + ), { headers: { "content-type": "application/javascript" } }); + } + if (request.url.pathname.endsWith("/Viewer")) return jsonResponse(viewerResponse()); + return jsonResponse(contactsFeedResponse(null)); + }); + for (const input of [ + { collection: "mutuals" }, + { collection: "following", limit: 0 }, + { collection: "following", limit: 101 }, + {}, + ]) { + const result = await executeXWebOperation( + xRecipe("contacts.list"), + input, + xAuth, + { dependencies: runtimeDependencies }, + ); + expect(result.status).toBe("failed"); + expect(result.dispatchStarted).toBe(false); + } + expect(calls.some((call) => call.url.pathname.includes("/i/api/graphql/") && call.url.pathname.endsWith("/Following"))).toBe(false); + }); + test("dispatches SearchTimeline and pages a search feed with a next cursor", async () => { const calls: CapturedRequest[] = []; const runtimeDependencies = dependencies(calls, (request) => { diff --git a/src/providers/x-web-runtime.property.test.ts b/src/providers/x-web-runtime.property.test.ts index b59f01f9..0520d6ca 100644 --- a/src/providers/x-web-runtime.property.test.ts +++ b/src/providers/x-web-runtime.property.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { assertProperty, fc } from "../test-support"; +import { projectXWebContactPage } from "./x-web"; import { resolveCurrentXWebChunkUrl } from "./x-web-runtime"; const BOOKMARKS_FAMILY = "shared~bundle.BookmarkFolders~bundle.Bookmarks"; @@ -69,3 +70,111 @@ test("revision evidence fails closed for unreviewed asset-name hash widths", () }, )); }); + +const digitId = fc.array(fc.constantFrom(..."0123456789"), { minLength: 1, maxLength: 19 }) + .map((digits) => digits.join("")); + +const timelineEntry = fc.oneof( + digitId.map((id) => ({ + entryId: `user-${id}`, + sortIndex: "100", + content: { + entryType: "TimelineTimelineItem", + itemContent: { + itemType: "TimelineUser", + user_results: { result: { __typename: "User", rest_id: id } }, + }, + }, + })), + digitId.map((id) => ({ + entryId: `user-${id}`, + sortIndex: "100", + content: { + entryType: "TimelineTimelineItem", + itemContent: { + itemType: "TimelineUser", + user_results: { result: { __typename: "UserUnavailable" } }, + }, + }, + })), + fc.constant({ + entryId: "prompt-1", + sortIndex: "50", + content: { + entryType: "TimelineTimelineItem", + itemContent: { itemType: "TimelinePrompt" }, + }, + }), +); + +function contactsResponse( + entries: readonly unknown[], + bottomCursor: string | null, +): unknown { + return { + data: { + user: { + result: { + __typename: "User", + rest_id: "123456789012345678", + timeline: { + timeline: { + instructions: [{ + type: "TimelineAddEntries", + entries: [ + ...entries, + ...(bottomCursor === null ? [] : [{ + entryId: "cursor-bottom", + sortIndex: "1", + content: { + entryType: "TimelineTimelineCursor", + cursorType: "Bottom", + value: bottomCursor, + }, + }]), + ], + }], + }, + }, + }, + }, + }, + }; +} + +test("contact pages never expose a cursor after truncation and project only users", () => { + assertProperty(fc.property( + fc.array(timelineEntry, { minLength: 0, maxLength: 30 }), + fc.integer({ min: 1, max: 40 }), + fc.option(fc.string({ minLength: 1, maxLength: 32 }), { nil: null }), + (entries, limit, bottomCursor) => { + const response = contactsResponse(entries, bottomCursor); + let page: ReturnType | null = null; + let threw = false; + try { + page = projectXWebContactPage("contacts.following", response, limit); + } catch { + threw = true; + } + // Users come only from TimelineUser rows that resolve real User results; + // the normalizer deduplicates repeated entryIds, last write wins. + const deduped = [...new Map(entries.map((entry) => + [(entry as { entryId: string }).entryId, entry] as const)).values()]; + const realUsers = deduped.filter((entry) => { + const content = (entry as { content: { itemContent: { itemType: string; user_results?: { result?: { __typename?: string } } } } }).content.itemContent; + return content.itemType === "TimelineUser" && content.user_results?.result?.__typename === "User"; + }); + if (realUsers.length > limit && bottomCursor === null) { + expect(threw).toBe(true); + return; + } + expect(threw).toBe(false); + expect(page?.users.length).toBe(Math.min(realUsers.length, limit)); + if (realUsers.length > limit) { + expect(page?.cursor).toBeNull(); + } else { + expect(page?.cursor ?? null).toBe(bottomCursor); + } + }, + )); +}); diff --git a/src/providers/x-web-runtime.ts b/src/providers/x-web-runtime.ts index 1de6b409..ebf4310d 100644 --- a/src/providers/x-web-runtime.ts +++ b/src/providers/x-web-runtime.ts @@ -60,6 +60,7 @@ import { } from "./x-transaction-id"; import { assertExactXWebGraphQlBinding, + assertXWebContactsTargetBound, assertXWebUserFeedTargetBound, authorizeXWebMutationRequest, authorizeXWebR1GraphQlRequest, @@ -71,6 +72,7 @@ import { normalizeXWebProfileHandle, normalizeXWebGraphQlTimelineResponse, projectXWebBookmarkExportPage, + projectXWebContactPage, projectXWebFeedPage, projectXWebFeedPost, projectXWebProfileStats, @@ -278,6 +280,24 @@ function failedXFeedRead( }; } +function failedXContactsRead( + error: unknown, + stage: ProviderReadFailureStage, +): WebSessionExecution { + const projected = failedProviderRead("X contacts collection", error, null, { + stage, + authenticated: true, + accountMismatch: xFeedReadAccountMismatch, + authRepairRequired: xFeedReadAuthRepairRequired, + }); + return { + ...projected, + error: error instanceof Error + ? error.message + : "X contacts collection read failed before the dispatch boundary", + }; +} + function record(value: unknown, label: string): JsonRecord { if (!isRecord(value)) throw new Error(`${label} must be an object`); return value; @@ -1127,6 +1147,60 @@ async function readFeed(bootstrap: XBootstrap, input: OperationInput): Promise): Readonly> => + cursor === undefined ? value : { ...value, cursor }; + if (collection === "following") { + return { + operationId: "contacts.following", + operationName: "Following", + method: "GET", + variables: withCursor({ userId: boundViewer.id, count, includePromotedContent: false }), + }; + } + if (collection === "followers") { + return { + operationId: "contacts.followers", + // The current X deployment routes Followers through POST; GET returns 404. + operationName: "Followers", + method: "POST", + variables: withCursor({ userId: boundViewer.id, count, includePromotedContent: false }), + }; + } + throw new Error("input.collection must be \"following\" or \"followers\""); +} + +async function readContacts( + bootstrap: XBootstrap, + input: OperationInput, + boundViewer: Viewer, +): Promise { + const request = contactsRequest(input, boundViewer); + const descriptor = await resolveDescriptor(bootstrap, request.operationName, "query"); + const response = await graphQl(bootstrap, descriptor, request.variables, request.method, request.operationId); + assertXWebContactsTargetBound(response, boundViewer.id); + const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100); + const page = projectXWebContactPage(request.operationId, response, limit); + return Object.freeze({ + collection: request.operationId === "contacts.following" ? "following" : "followers", + viewerId: boundViewer.id, + users: page.users, + cursor: page.cursor, + terminatedDirections: page.terminatedDirections, + }); +} + async function readProfile( bootstrap: XBootstrap, input: OperationInput, @@ -3435,6 +3509,9 @@ export async function executeXWebOperation( if (recipe.action === "feeds.read") { return failedXFeedRead(error, input, "bootstrap"); } + if (recipe.action === "contacts.list") { + return failedXContactsRead(error, "bootstrap"); + } throw error; } if (recipe.action === "feeds.read") { @@ -3487,6 +3564,27 @@ export async function executeXWebOperation( dispatch: { planned: 0, started: 0, verified: 0 }, }; } + if (recipe.action === "contacts.list") { + let boundViewer: Viewer; + try { + boundViewer = await requireBoundViewer(bootstrap, auth); + } catch (error) { + return failedXContactsRead(error, "identity"); + } + try { + const output = await readContacts(bootstrap, input, boundViewer); + const handle = boundViewer.screenName === null ? boundViewer.id : boundViewer.screenName; + return { + status: "succeeded", + output, + finalUrl: `${X_ORIGIN}/${handle}/${stringInput(input, "collection") === "followers" ? "followers" : "following"}`, + dispatchStarted: false, + dispatch: { planned: 0, started: 0, verified: 0 }, + }; + } catch (error) { + return failedXContactsRead(error, "target"); + } + } if (recipe.action === "articles.read") { return executePrivateArticleDraftRead(bootstrap, recipe, input, auth); } diff --git a/src/providers/x-web.test.ts b/src/providers/x-web.test.ts index 8af8c352..f70c63e5 100644 --- a/src/providers/x-web.test.ts +++ b/src/providers/x-web.test.ts @@ -19,6 +19,7 @@ import { parseXWebBookmarkExportRecord, projectXWebBookmarkExportPage, projectXWebBookmarkExportRecord, + projectXWebContactPage, projectXWebFeedPage, projectXWebFeedPost, projectXWebProfileStats, @@ -97,6 +98,24 @@ function cursorEntry(entryId: string, cursorType: string, value: string): unknow }; } +function timelineUserEntry( + id: string, + userResult: unknown, + options: { readonly sortIndex?: string } = {}, +): unknown { + return { + entryId: `user-${id}`, + sortIndex: options.sortIndex ?? "100", + content: { + entryType: "TimelineTimelineItem", + itemContent: { + itemType: "TimelineUser", + user_results: userResult, + }, + }, + }; +} + function timeline(...entries: readonly unknown[]): unknown { return { instructions: [{ type: "TimelineAddEntries", entries }] }; } @@ -1209,7 +1228,7 @@ describe("URT timeline normalization and cursor extraction", () => { "tweet", "tweet", "unavailable", - "other", + "unavailable", "tweet", ]); expect(normalized.items.filter((item) => item.kind === "tweet").map((item) => item.tweetId)).toEqual([ @@ -1219,7 +1238,7 @@ describe("URT timeline normalization and cursor extraction", () => { "104", ]); expect(normalized.items[3]).toMatchObject({ kind: "unavailable", typename: "TweetUnavailable", reason: "Protected" }); - expect(normalized.items[4]).toMatchObject({ kind: "other", itemType: "TimelineUser" }); + expect(normalized.items[4]).toMatchObject({ kind: "unavailable", typename: "MissingUserResult" }); expect(normalized.items[5]).toMatchObject({ moduleEntryId: "module-1", entryId: "module-tweet-4" }); expect(normalized.cursors.top?.value).toBe("top-token"); expect(normalized.cursors.bottom?.value).toBe("bottom-token"); @@ -1893,6 +1912,173 @@ describe("X bookmark export projection", () => { url: "https://x.com/three/status/31", })).toThrow("status permalink"); }); + + test("projects a viewer-bound contact page and keeps non-user rows out", () => { + const contactsResponse = { + data: { + user: { + result: { + __typename: "User", + rest_id: "1401049881070997506", + timeline: { + timeline: { + instructions: [ + { + type: "TimelineAddEntries", + entries: [ + timelineUserEntry("41", { + result: { + __typename: "User", + rest_id: "41", + core: { name: "Ana", screen_name: "anahandle" }, + legacy: { name: "Ana", screen_name: "anahandle" }, + relationship_perspectives: { following: true, followed_by: true }, + }, + }), + timelineUserEntry("42", { + result: { __typename: "UserUnavailable", reason: "Suspended" }, + }), + timelineUserEntry("43", { + result: { + __typename: "User", + rest_id: "43", + legacy: { name: "Legacy Name", screen_name: "legacyhandle" }, + }, + }), + timelineItemEntry("tweet-44", tweetResult("44", { text: "promoted" })), + cursorEntry("cursor-bottom", "Bottom", "contacts-next"), + ], + }, + { type: "TimelineTerminateTimeline", direction: "Top" }, + ], + }, + }, + }, + }, + }, + }; + const page = projectXWebContactPage("contacts.followers", contactsResponse, 10); + expect(page.users).toEqual([ + { + providerId: "41", + handle: "anahandle", + displayName: "Ana", + followsViewer: true, + followedByViewer: true, + sentCount: null, + sentCountComplete: false, + sentCountLowerBound: false, + sentCountTruncated: false, + receivedCount: null, + receivedCountComplete: false, + receivedCountLowerBound: false, + receivedCountTruncated: false, + lastSentAt: null, + lastSentAtComplete: false, + lastSentAtBasis: "unavailable", + sentStatsIncompleteReasons: ["message-history-capture-required"], + lastReceivedAt: null, + lastReceivedAtComplete: false, + lastReceivedAtBasis: "unavailable", + receivedStatsIncompleteReasons: ["message-history-capture-required"], + }, + { + providerId: "43", + handle: "legacyhandle", + displayName: "Legacy Name", + followsViewer: null, + followedByViewer: null, + sentCount: null, + sentCountComplete: false, + sentCountLowerBound: false, + sentCountTruncated: false, + receivedCount: null, + receivedCountComplete: false, + receivedCountLowerBound: false, + receivedCountTruncated: false, + lastSentAt: null, + lastSentAtComplete: false, + lastSentAtBasis: "unavailable", + sentStatsIncompleteReasons: ["message-history-capture-required"], + lastReceivedAt: null, + lastReceivedAtComplete: false, + lastReceivedAtBasis: "unavailable", + receivedStatsIncompleteReasons: ["message-history-capture-required"], + }, + ]); + expect(page.cursor).toBe("contacts-next"); + expect(page.terminatedDirections).toEqual(["Top"]); + const followingPage = projectXWebContactPage("contacts.following", contactsResponse, 10); + expect(followingPage.users).toEqual(page.users); + const truncated = projectXWebContactPage("contacts.followers", contactsResponse, 1); + expect(truncated.users.map((user) => user.providerId)).toEqual(["41"]); + expect(truncated.cursor).toBeNull(); + expect(JSON.stringify(truncated)).not.toContain("contacts-next"); + expect(() => projectXWebContactPage("contacts.followers", { + data: { + user: { + result: { + __typename: "User", + rest_id: "1401049881070997506", + timeline: { + timeline: timeline( + timelineUserEntry("41", { + result: { __typename: "User", rest_id: "41", core: { screen_name: "a" } }, + }), + timelineUserEntry("42", { + result: { __typename: "User", rest_id: "42", core: { screen_name: "b" } }, + }), + ), + }, + }, + }, + }, + }, 1)).toThrow("no continuation cursor was exposed"); + expect(() => projectXWebContactPage("contacts.followers", { + data: { + user: { + result: { + __typename: "User", + rest_id: "1401049881070997506", + timeline: { + timeline: timeline( + timelineUserEntry("45", { + result: { + __typename: "User", + rest_id: "45", + core: { name: "Drifted", screen_name: "one" }, + legacy: { name: "Drifted", screen_name: "two" }, + }, + }), + ), + }, + }, + }, + }, + }, 10)).toThrow("handle disagreed across core and legacy"); + expect(() => projectXWebContactPage("contacts.followers", { + data: { + user: { + result: { + __typename: "User", + rest_id: "1401049881070997506", + timeline: { + timeline: timeline( + timelineUserEntry("46", { + result: { + __typename: "User", + rest_id: "46", + core: { name: "Flag", screen_name: "flag" }, + relationship_perspectives: { following: "yes" }, + }, + }), + ), + }, + }, + }, + }, + }, 10)).toThrow("relationship_perspectives.following"); + }); }); describe("desired-state mutation response validation", () => { diff --git a/src/providers/x-web.ts b/src/providers/x-web.ts index 7f01c431..51b56cba 100644 --- a/src/providers/x-web.ts +++ b/src/providers/x-web.ts @@ -7,6 +7,10 @@ */ import { hasExactKeys } from "../contracts-shape.js"; +import { + projectContactDirectionStats, + type ContactDirectionStatsProjection, +} from "./contact-projection.js"; export type XWebOperationType = "query" | "mutation"; @@ -269,6 +273,8 @@ export const xWebQueryDescriptorEvidenceSnapshot = Object.freeze({ { operationName: "DeleteTweet", operationType: "mutation", queryId: "nxpZCY2K-I6QoFHAHeojFQ", sourceChunk: "main.52fc4dd0aada586aa.js", observedOn: "2026-09-17" }, { operationName: "Viewer", operationType: "query", queryId: "9t128XgFic52jPUEkJMf6w", sourceChunk: "main.52fc4dd0aada586aa.js", observedOn: "2026-09-17" }, { operationName: "UserByScreenName", operationType: "query", queryId: "KybxDj9RrADIITXlGG8kpw", sourceChunk: "main.52fc4dd0aada586aa.js", observedOn: "2026-09-17" }, + { operationName: "Following", operationType: "query", queryId: "uwmIAx89XrXNuGY-Y7WFLg", sourceChunk: "main.a9c37180a4c75840a.js", observedOn: "2026-09-28" }, + { operationName: "Followers", operationType: "query", queryId: "mrqxgX8JzwlL6pvYiC5CPA", sourceChunk: "main.a9c37180a4c75840a.js", observedOn: "2026-09-28" }, { operationName: "ArticleEntityDraftCreate", operationType: "mutation", queryId: "_rbmb_NKLqKVBr5X_MSoMQ", sourceChunk: "bundle.TwitterArticles.b3c21fed7d9db030a.js", observedOn: "2026-09-17" }, { operationName: "ArticleEntityUpdateContent", operationType: "mutation", queryId: "x4Pz2ifYkOD6uSvzxOIUig", sourceChunk: "bundle.TwitterArticles.b3c21fed7d9db030a.js", observedOn: "2026-09-17" }, { operationName: "ArticleEntityUpdateTitle", operationType: "mutation", queryId: "brHFCBTXXg8WOqc7BnXfAw", sourceChunk: "bundle.TwitterArticles.b3c21fed7d9db030a.js", observedOn: "2026-09-17" }, @@ -278,7 +284,11 @@ export const xWebQueryDescriptorEvidenceSnapshot = Object.freeze({ }); type XWebGraphQlReadDefinition = { - readonly semanticOperation: "feeds.read" | "posts.read" | "profiles.read"; + readonly semanticOperation: + | "feeds.read" + | "posts.read" + | "profiles.read" + | "contacts.list"; readonly risk: "R1"; readonly transport: "graphql-query"; readonly operationName: string; @@ -316,6 +326,8 @@ export const xWebSemanticOperationRegistry = Object.freeze({ "feeds.search": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "SearchTimeline", operationType: "query", responseRoot: ["search_by_raw_query", "search_timeline", "timeline"] }, "feeds.notifications": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "NotificationsTimeline", operationType: "query", responseRoot: ["viewer_v2", "user_results", "result", "notification_timeline", "timeline"] }, "profiles.by-handle": { semanticOperation: "profiles.read", risk: "R1", transport: "graphql-query", operationName: "UserByScreenName", operationType: "query", responseRoot: ["user", "result"] }, + "contacts.following": { semanticOperation: "contacts.list", risk: "R1", transport: "graphql-query", operationName: "Following", operationType: "query", responseRoot: ["user", "result", "timeline", "timeline"] }, + "contacts.followers": { semanticOperation: "contacts.list", risk: "R1", transport: "graphql-query", operationName: "Followers", operationType: "query", responseRoot: ["user", "result", "timeline", "timeline"] }, "posts.detail": { semanticOperation: "posts.read", risk: "R1", transport: "graphql-query", operationName: "TweetDetail", operationType: "query", responseRoot: ["threaded_conversation_with_injections_v2"] }, "posts.by-id": { semanticOperation: "posts.read", risk: "R1", transport: "graphql-query", operationName: "TweetResultByRestId", operationType: "query", responseRoot: ["tweetResult", "result"] }, "posts.by-ids": { semanticOperation: "posts.read", risk: "R1", transport: "graphql-query", operationName: "TweetResultsByRestIds", operationType: "query", responseRoot: ["tweetResult"] }, @@ -1343,6 +1355,29 @@ export function assertXWebUserFeedTargetBound( } } +/** + * Bind a viewer-owned Following/Followers collection response to the signed-in + * account. The current documents return a bare `User` timeline node with no + * echoed identity, so binding is structural: the GraphQL userId variable was + * generated from the bound viewer, the response must expose a User node, and + * any identity the node does echo must equal the viewer. + */ +export function assertXWebContactsTargetBound( + response: unknown, + expectedUserId: unknown, +): void { + const expected = exactTweetId(expectedUserId, "input.user_id"); + const data = responseData(response, "X contacts collection response"); + const user = record(data.user, "X contacts collection response.data.user"); + const result = unwrapXWebUserResult(user.result, "X contacts collection response.data.user.result"); + const nodeIds = collectXWebUserIdentities(user, result); + for (const id of nodeIds) { + if (id !== expected) { + throw new Error("X contacts collection response did not bind the requested user"); + } + } +} + function userFeedRootSegment( operationId: XWebSemanticOperationId, parent: JsonRecord, @@ -1361,8 +1396,12 @@ function userFeedRootSegment( throw new Error(`X ${operationId} response omitted reviewed root ${root.join(".")}`); } const next = parent[segment]; - if (operationId === "feeds.user" && segment === "result") { - return unwrapXWebUserResult(next, "X user feed response.data.user.result"); + const userResultBound = + operationId === "feeds.user" + || operationId === "contacts.following" + || operationId === "contacts.followers"; + if (userResultBound && segment === "result") { + return unwrapXWebUserResult(next, `X ${operationId} response.data.user.result`); } return next; } @@ -1394,7 +1433,11 @@ export function normalizeXWebGraphQlTimelineResponse( const definition = xWebSemanticOperationRegistry[operationId]; if ( definition.transport !== "graphql-query" - || (definition.semanticOperation !== "feeds.read" && operationId !== "posts.detail") + || ( + definition.semanticOperation !== "feeds.read" + && definition.semanticOperation !== "contacts.list" + && operationId !== "posts.detail" + ) ) { throw new Error(`${operationId} is not an X URT timeline response contract`); } @@ -1610,6 +1653,18 @@ export type XWebNormalizedUnavailable = { readonly reason: string | null; }; +export type XWebNormalizedUser = { + readonly kind: "user"; + readonly entryId: string; + readonly moduleEntryId: string | null; + readonly sortIndex: string | null; + readonly userId: string; + readonly username: string | null; + readonly name: string | null; + readonly followsViewer: boolean | null; + readonly followedByViewer: boolean | null; +}; + export type XWebNormalizedOther = { readonly kind: "other"; readonly entryId: string; @@ -1620,6 +1675,7 @@ export type XWebNormalizedOther = { export type XWebNormalizedTimelineItem = | XWebNormalizedTweet + | XWebNormalizedUser | XWebNormalizedUnavailable | XWebNormalizedOther; @@ -1759,6 +1815,95 @@ function unwrapTweetResult(value: JsonRecord, label: string): { return { result: null, typename, reason }; } +function optionalRelationshipFlag(value: unknown, label: string): boolean | null { + if (value === undefined || value === null) return null; + if (typeof value !== "boolean") throw new Error(`${label} must be boolean when present`); + return value; +} + +function normalizeTimelineUserItem( + item: JsonRecord, + entryId: string, + moduleEntryId: string | null, + sortIndex: string | null, +): XWebNormalizedTimelineItem { + const label = `X URT user ${entryId}`; + const userResultsValue = item.user_results; + if (!isRecord(userResultsValue)) { + return Object.freeze({ + kind: "unavailable", + entryId, + moduleEntryId, + sortIndex, + typename: "MissingUserResult", + reason: null, + }); + } + const resultValue = userResultsValue.result; + if (resultValue === null || resultValue === undefined) { + return Object.freeze({ + kind: "unavailable", + entryId, + moduleEntryId, + sortIndex, + typename: "MissingUserResult", + reason: null, + }); + } + const result = record(resultValue, `${label}.user_results.result`); + const typename = typeof result.__typename === "string" ? result.__typename : null; + if (typename !== "User") { + const reason = typeof result.reason === "string" ? result.reason : null; + return Object.freeze({ + kind: "unavailable", + entryId, + moduleEntryId, + sortIndex, + typename: typename ?? "UnknownUserResult", + reason, + }); + } + const userId = exactTweetId(result.rest_id, `${label}.rest_id`); + const core = result.core === undefined || result.core === null + ? null + : record(result.core, `${label}.core`); + const legacy = result.legacy === undefined || result.legacy === null + ? null + : record(result.legacy, `${label}.legacy`); + const coreHandle = core === null ? null : optionalAuthorHandle(core.screen_name, `${label}.core.screen_name`); + const legacyHandle = legacy === null ? null : optionalAuthorHandle(legacy.screen_name, `${label}.legacy.screen_name`); + if (coreHandle !== null && legacyHandle !== null && coreHandle !== legacyHandle) { + throw new Error(`${label} handle disagreed across core and legacy`); + } + const coreName = core === null ? null : optionalAuthorName(core.name, `${label}.core.name`); + const legacyName = legacy === null ? null : optionalAuthorName(legacy.name, `${label}.legacy.name`); + if (coreName !== null && legacyName !== null && coreName !== legacyName) { + throw new Error(`${label} name disagreed across core and legacy`); + } + const perspectives = result.relationship_perspectives === undefined || result.relationship_perspectives === null + ? null + : record(result.relationship_perspectives, `${label}.relationship_perspectives`); + return Object.freeze({ + kind: "user", + entryId, + moduleEntryId, + sortIndex, + userId, + username: coreHandle ?? legacyHandle, + name: coreName ?? legacyName, + // relationship_perspectives is the viewer's perspective: `following` means + // the viewer follows this user, `followed_by` means the viewer is followed + // by this user (verified live 2026-09-28: every entry on the viewer's + // Following page carries following:true, every Followers entry followed_by). + followsViewer: perspectives === null + ? null + : optionalRelationshipFlag(perspectives.followed_by, `${label}.relationship_perspectives.followed_by`), + followedByViewer: perspectives === null + ? null + : optionalRelationshipFlag(perspectives.following, `${label}.relationship_perspectives.following`), + }); +} + function normalizeItemContent( itemValue: unknown, entryId: string, @@ -1767,6 +1912,9 @@ function normalizeItemContent( ): XWebNormalizedTimelineItem { const item = record(itemValue, `X URT item ${entryId}`); const itemType = requiredString(item, "itemType", `X URT item ${entryId}`); + if (itemType === "TimelineUser") { + return normalizeTimelineUserItem(item, entryId, moduleEntryId, sortIndex); + } if (itemType !== "TimelineTweet") { return Object.freeze({ kind: "other", entryId, moduleEntryId, sortIndex, itemType }); } @@ -2134,6 +2282,73 @@ export function projectXWebFeedPage( }); } +const unavailableXContactStats = Object.freeze({ + count: null, + complete: false, + lowerBound: false, + truncated: false, + lastAt: null, + lastAtComplete: false, + lastAtBasis: "unavailable", + incompleteReasons: Object.freeze(["message-history-capture-required"]), +} as const); + +const unavailableXContactStatsProjection = projectContactDirectionStats( + unavailableXContactStats, + unavailableXContactStats, +); + +export type XWebProjectedUser = ContactDirectionStatsProjection & { + readonly providerId: string; + readonly handle: string | null; + readonly displayName: string | null; + readonly followsViewer: boolean | null; + readonly followedByViewer: boolean | null; +}; + +export type XWebContactPage = { + readonly users: readonly XWebProjectedUser[]; + readonly cursor: string | null; + readonly terminatedDirections: readonly string[]; +}; + +function projectXWebContactUser( + item: XWebNormalizedTimelineItem, +): XWebProjectedUser | null { + if (item.kind !== "user") return null; + return Object.freeze({ + providerId: item.userId, + handle: item.username, + displayName: item.name, + followsViewer: item.followsViewer, + followedByViewer: item.followedByViewer, + ...unavailableXContactStatsProjection, + }); +} + +/** Project one viewer-bound following/followers page; non-user rows stay out. */ +export function projectXWebContactPage( + operationId: XWebSemanticOperationId, + response: unknown, + limit: number, +): XWebContactPage { + const normalized = normalizeXWebGraphQlTimelineResponse(operationId, response); + const users = normalized.items + .map(projectXWebContactUser) + .filter((row): row is XWebProjectedUser => row !== null); + const page = boundXWebProviderPage( + users, + limit, + normalized.cursors.bottom !== null, + "X contact collection page", + ); + return Object.freeze({ + users: Object.freeze(page.items), + cursor: page.truncated ? null : normalized.cursors.bottom?.value ?? null, + terminatedDirections: normalized.terminatedDirections, + }); +} + export function projectXWebBookmarkExportPage( response: unknown, limit: number, diff --git a/src/scripts/sync-bundled-adapters.test.ts b/src/scripts/sync-bundled-adapters.test.ts index 809970af..f32b53bc 100644 --- a/src/scripts/sync-bundled-adapters.test.ts +++ b/src/scripts/sync-bundled-adapters.test.ts @@ -253,6 +253,7 @@ describe("single-process bundled adapter generation sync", () => { "x-web@1.11.0", "x-web@1.12.0", "x-web@1.13.0", + "x-web@1.14.0", "x-web@1.2.0", "x-web@1.3.0", "x-web@1.4.0", diff --git a/src/web-session-contract-definitions.ts b/src/web-session-contract-definitions.ts index 8c0aede9..057fb91e 100644 --- a/src/web-session-contract-definitions.ts +++ b/src/web-session-contract-definitions.ts @@ -661,6 +661,7 @@ const github = { const x = { "feeds.read": contract("x", "feeds.read", "R1", "observed", "current first-party GraphQL timeline/list/search/bookmark query"), "profiles.read": contract("x", "profiles.read", "R1", "observed", "current target-bound UserByScreenName first-party GraphQL query with exact follower and following counts"), + "contacts.list": contract("x", "contacts.list", "R1", "observed", "current viewer-bound first-party Following (GET) and Followers (POST) GraphQL collection queries with exact relationship-perspective projection"), "posts.read": contract("x", "posts.read", "R1", "observed", "current TweetDetail/UserTweets first-party GraphQL query"), "comments.read": contract("x", "comments.read", "R1", "observed", "current TweetDetail conversation entries"), "messaging.list": contract("x", "messaging.list", "R1", "capture-required", "current X Chat inbox events are encrypted and require the reviewed key-recovery runtime before plaintext listing"), From 5b9b9398b448e823256f5815e8fa3c65e6786a6c Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:10:24 -0400 Subject: [PATCH 2/2] Remeasure the package budget over merged 0.18.46 main Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/npm-release-workflow.test.ts | 18 +++++++-------- scripts/package-budget.ts | 34 +++++++++++++++++++--------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 59fd2ac8..fc04f129 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(24_613_888); + expect(MAX_PACKAGE_TAR_BYTES).toBe(24_614_400); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1429,8 +1429,8 @@ describe("npm publication contract", () => { expect(budget).toContain("25cfe9120e4cf43087a79e5bf302cb0683719a7db8267ed97503e75da5883185"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - archiveSha256: "4643e92ba1ac2f5a38c8bd7ba5bce47a2eb746e005930c2d9d577115206bc5d0", - packedBytes: 12_131_547, unpackedBytes: 23_979_139, entryCount: 619, + archiveSha256: "1c08503c495cc3e2c16d7eb1628c91d1ceeac9dc0d26de4c27ee75b240e4f9d4", + packedBytes: 12_131_739, unpackedBytes: 23_979_567, entryCount: 619, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); @@ -1440,8 +1440,8 @@ describe("npm publication contract", () => { expect(budget).toContain("12,141,169 packed; 23,937,025 + 353 + 65 = 23,937,443 unpacked"); expect(budget).toContain("23,930,250 + 353 + 65 = 23,930,668 unpacked"); expect(budget).toContain("12,141,373 packed; 23,937,545 + 353 + 65 = 23,937,963 unpacked"); - expect(MAX_PACKED_BYTES).toBe(12_148_030); - expect(MAX_PACKED_BYTES).toBe(12_131_547 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_148_222); + expect(MAX_PACKED_BYTES).toBe(12_131_739 + 12_387 + 4_096); expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39"); expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2"); expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1"); @@ -1562,7 +1562,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(23_979_557); + expect(MAX_UNPACKED_BYTES).toBe(23_979_985); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1595,7 +1595,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(23_979_139 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(23_979_567 + 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"); @@ -1631,8 +1631,8 @@ describe("npm publication contract", () => { expect(packageArtifactBudget).toEqual({ entryCount: { min: 619, max: 619 }, fileCount: { min: 619, max: 619 }, - packedBytes: { min: 1_600_000, max: 12_148_030 }, - unpackedBytes: { min: 9_000_000, max: 23_979_557 }, + packedBytes: { min: 1_600_000, max: 12_148_222 }, + unpackedBytes: { min: 9_000_000, max: 23_979_985 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 647b5720..94a2ff2d 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -2095,26 +2095,38 @@ // 12,125,761 + 12,387 + 4,096 = 12,142,244 packed; // 23,940,938 + 353 + 65 = 23,941,356 unpacked. // +// The 0.18.46 release carries the `ghostget` support handoff id (#450), +// bumps the version pins, adds its changelog section over merged main +// 95c6a2c, and rebuilds the version chunk; no adapter file changes. After +// `bun run build`, a clean npm 11.19.0 pack --ignore-scripts with Node +// 24.18.1 on darwin arm64 measured 618 entries, 12,125,923 packed bytes, +// and 23,941,366 unpacked bytes; archive SHA-256 +// 25cfe9120e4cf43087a79e5bf302cb0683719a7db8267ed97503e75da5883185. +// Retain the same platform projections and allowances: +// 12,125,923 + 12,387 + 4,096 = 12,142,406 packed; +// 23,941,366 + 353 + 65 = 23,941,784 unpacked. +// // The X contacts.list qualification adds the viewer-bound Following and // Followers GraphQL collection reads, the TimelineUser normalizer, the // contacts page projection on the shared directional-statistics shape, the // archived x-web 1.14.0 adapter snapshot, and the qualification record over -// main 50f3ef99 (Ghostget 0.18.45). Registering the new archive snapshot in -// the package manifest grows the inventory to 619 entries: a clean npm -// 11.16.0 pack --ignore-scripts with Node 24.18.1 on darwin arm64 measured -// 12,131,547 packed bytes and 23,979,139 unpacked bytes; archive SHA-256 -// 4643e92ba1ac2f5a38c8bd7ba5bce47a2eb746e005930c2d9d577115206bc5d0. +// merged main d426704d (Ghostget 0.18.46). Registering the new archive +// snapshot in the package manifest grows the inventory to 619 entries: a +// clean npm 11.16.0 pack --ignore-scripts with Node 24.18.1 on darwin +// arm64 measured 12,131,739 packed bytes and 23,979,567 unpacked bytes; +// archive SHA-256 +// 1c08503c495cc3e2c16d7eb1628c91d1ceeac9dc0d26de4c27ee75b240e4f9d4. // Retain the same platform projections and portability allowances: -// 12,131,547 + 12,387 + 4,096 = 12,148,030 packed; -// 23,979,139 + 353 + 65 = 23,979,557 unpacked. +// 12,131,739 + 12,387 + 4,096 = 12,148,222 packed; +// 23,979,567 + 353 + 65 = 23,979,985 unpacked. export const repairPackageMeasurement = Object.freeze({ - scope: "X contacts.list follow-collection qualification over Ghostget 0.18.45 main 50f3ef99", + scope: "X contacts.list follow-collection qualification over merged main d426704d (Ghostget 0.18.46)", command: "npm pack --ignore-scripts", npmVersion: "11.16.0", platform: "darwin-arm64", - archiveSha256: "4643e92ba1ac2f5a38c8bd7ba5bce47a2eb746e005930c2d9d577115206bc5d0", - packedBytes: 12_131_547, - unpackedBytes: 23_979_139, + archiveSha256: "1c08503c495cc3e2c16d7eb1628c91d1ceeac9dc0d26de4c27ee75b240e4f9d4", + packedBytes: 12_131_739, + unpackedBytes: 23_979_567, entryCount: 619, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096,