Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.46

This release points Ghostget's support links at the product's current
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,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 |

Expand Down
74 changes: 74 additions & 0 deletions docs/x-contacts-qualification.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 13 additions & 13 deletions scripts/npm-release-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_576_512);
expect(MAX_PACKAGE_TAR_BYTES).toBe(24_615_424);
expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0);
expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES");
expect(artifact).not.toContain("const maximumTarBytes");
Expand Down Expand Up @@ -1430,8 +1430,8 @@ describe("npm publication contract", () => {
expect(budget).toContain("785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6");
expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue();
expect(repairPackageMeasurement).toMatchObject({
archiveSha256: "785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6",
packedBytes: 12_126_287, unpackedBytes: 23_942_384, entryCount: 618,
archiveSha256: "9641f93ab7dd2c52174cf2ddf3e41f407ebca562c46e0dd0af2e6cd01447c2de",
packedBytes: 12_132_235, unpackedBytes: 23_980_585, entryCount: 619,
packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096,
payloadPlatformProjection: 353, payloadAllowance: 65,
});
Expand All @@ -1441,8 +1441,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_770);
expect(MAX_PACKED_BYTES).toBe(12_126_287 + 12_387 + 4_096);
expect(MAX_PACKED_BYTES).toBe(12_148_718);
expect(MAX_PACKED_BYTES).toBe(12_132_235 + 12_387 + 4_096);
expect(budget).toContain("aa127b3193c9bb3b0cb5deece5927be60ccb7111a50169320d322ffdeaa13f39");
expect(budget).toContain("0c331bab3ab3df69a108e18f5f29845b0db90c281cbd6455c0d90fa0b24081e2");
expect(budget).toContain("873cad8139fda303e2d19c6afd61cf549cf9b4d1d76b2a1d6d632a6afe6bd0d1");
Expand Down Expand Up @@ -1537,8 +1537,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");
Expand All @@ -1563,7 +1563,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_942_802);
expect(MAX_UNPACKED_BYTES).toBe(23_981_003);
expect(budget).toContain("23,037,873 + 65 = 23,037,938");
expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f");
expect(budget).toContain("23,038,557 + 65 = 23,038,622");
Expand Down Expand Up @@ -1596,7 +1596,7 @@ describe("npm publication contract", () => {
expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c");
expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937");
expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d");
expect(MAX_UNPACKED_BYTES).toBe(23_942_384 + 353 + 65);
expect(MAX_UNPACKED_BYTES).toBe(23_980_585 + 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");
Expand Down Expand Up @@ -1630,10 +1630,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_770 },
unpackedBytes: { min: 9_000_000, max: 23_942_802 },
entryCount: { min: 619, max: 619 },
fileCount: { min: 619, max: 619 },
packedBytes: { min: 1_600_000, max: 12_148_718 },
unpackedBytes: { min: 9_000_000, max: 23_981_003 },
});
});

Expand Down
26 changes: 20 additions & 6 deletions scripts/package-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2116,15 +2116,29 @@
// Retain the same platform projections and allowances:
// 12,126,287 + 12,387 + 4,096 = 12,142,770 packed;
// 23,942,384 + 353 + 65 = 23,942,802 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
// merged main 46e31838 (Ghostget 0.18.46 plus the README alternatives
// table). 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,132,235
// packed bytes and 23,980,585 unpacked bytes; archive SHA-256
// 9641f93ab7dd2c52174cf2ddf3e41f407ebca562c46e0dd0af2e6cd01447c2de.
// Retain the same platform projections and portability allowances:
// 12,132,235 + 12,387 + 4,096 = 12,148,718 packed;
// 23,980,585 + 353 + 65 = 23,981,003 unpacked.
export const repairPackageMeasurement = Object.freeze({
scope: "README alternatives table over main d426704",
scope: "X contacts.list follow-collection qualification over merged main 46e31838 (Ghostget 0.18.46)",
command: "npm pack --ignore-scripts",
npmVersion: "11.19.0",
npmVersion: "11.16.0",
platform: "darwin-arm64",
archiveSha256: "785b8fa60c329d7ac46bc8fcf4d959b5fa9d96455bba9e7cdea63f6a3827c4f6",
packedBytes: 12_126_287,
unpackedBytes: 23_942_384,
entryCount: 618,
archiveSha256: "9641f93ab7dd2c52174cf2ddf3e41f407ebca562c46e0dd0af2e6cd01447c2de",
packedBytes: 12_132_235,
unpackedBytes: 23_980_585,
entryCount: 619,
packedPlatformProjection: 12_387,
packedPortabilityAllowance: 4_096,
payloadPlatformProjection: 353,
Expand Down
5 changes: 5 additions & 0 deletions skills/ghostget/references/x-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion src/assets/adapters/x/wrench-web-adapter.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading