@volter/twin-x 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +138 -0
- package/dist/client/x-mirror.bundle.js +321 -0
- package/dist/client/x-mirror.d.ts +45 -0
- package/dist/client/x-mirror.js +417 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +29 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +68 -0
- package/dist/src/x-budget.d.ts +54 -0
- package/dist/src/x-budget.js +123 -0
- package/dist/src/x-capabilities.d.ts +3 -0
- package/dist/src/x-capabilities.js +1106 -0
- package/dist/src/x-conformance.d.ts +8 -0
- package/dist/src/x-conformance.js +91 -0
- package/dist/src/x-connector.d.ts +125 -0
- package/dist/src/x-connector.js +546 -0
- package/dist/src/x-media.d.ts +87 -0
- package/dist/src/x-media.js +275 -0
- package/dist/src/x-mirror-ui.d.ts +61 -0
- package/dist/src/x-mirror-ui.js +253 -0
- package/dist/src/x-problems.d.ts +38 -0
- package/dist/src/x-problems.js +130 -0
- package/dist/src/x-scopes.d.ts +7 -0
- package/dist/src/x-scopes.js +62 -0
- package/dist/src/x-server.d.ts +14 -0
- package/dist/src/x-server.js +127 -0
- package/dist/src/x-twin.d.ts +21 -0
- package/dist/src/x-twin.js +1534 -0
- package/package.json +58 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +132 -0
- package/src/x-budget.ts +150 -0
- package/src/x-capabilities.ts +1161 -0
- package/src/x-conformance.ts +113 -0
- package/src/x-connector.ts +546 -0
- package/src/x-media.ts +295 -0
- package/src/x-mirror-ui.ts +263 -0
- package/src/x-problems.ts +143 -0
- package/src/x-scopes.ts +67 -0
- package/src/x-server.ts +126 -0
- package/src/x-twin.ts +1545 -0
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
// THE X PACK'S CAPABILITY MANIFEST — the real vendor surface as the DENOMINATOR, not a list of
|
|
2
|
+
// what got built. The denominator here is X API v2's POST OBJECT and the per-user timelines and
|
|
3
|
+
// engagement verbs over it, plus user lookup by id and by username (the curated boundary
|
|
4
|
+
// `x-spec-census.json` declares in `scopePaths`), plus the media a post carries — image and video
|
|
5
|
+
// upload. Follows, lists, spaces, communities, direct messages, compliance and usage
|
|
6
|
+
// are X surface this manifest deliberately does NOT enumerate — they are outside the census's
|
|
7
|
+
// committed boundary, and claiming them here would inflate a denominator nothing measures.
|
|
8
|
+
// (`packages/twin/xidentity` owns X's OAuth + identity surface and its own denominator.)
|
|
9
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
13
|
+
import { pushXAction, xRequestForAction, syncXFromReal, type XExecute } from './x-connector.ts';
|
|
14
|
+
import { X_BUDGET_CEILING, X_CALL_WEIGHTS, X_RATE_BUDGET, XBudget, XBudgetError, xCallWeight } from './x-budget.ts';
|
|
15
|
+
import { handleXTwinRequest } from './x-twin.ts';
|
|
16
|
+
import { createElement } from 'react';
|
|
17
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
18
|
+
import { PostRow, ProfileHeader } from '../client/x-mirror.tsx';
|
|
19
|
+
import { createXMirrorServer, joinedLabel, POST_READ_QUERY, PROFILE_READ_QUERY, tweetsById, usersById, type XRow } from './x-mirror-ui.ts';
|
|
20
|
+
|
|
21
|
+
type Body = Record<string, any>;
|
|
22
|
+
type Step = { m: string; p: string; b?: unknown; headers?: Record<string, string>; readOnly?: boolean };
|
|
23
|
+
|
|
24
|
+
const ORG = '1000';
|
|
25
|
+
const MEMBER = '2000';
|
|
26
|
+
const TOKEN = 'org-voice-token';
|
|
27
|
+
const FULL_SCOPES = ['tweet.read', 'tweet.write', 'users.read'];
|
|
28
|
+
const AUTH = { authorization: `Bearer ${TOKEN}` };
|
|
29
|
+
// X returns id/text/edit_history_tweet_ids and NOTHING ELSE unless the caller asks. Every
|
|
30
|
+
// verify below that asserts a threading field therefore REQUESTS it — which is itself half the
|
|
31
|
+
// proof of `x.fields.tweet_fields`: drop the projection and these reads stop carrying the
|
|
32
|
+
// fields they assert on.
|
|
33
|
+
const THREAD_FIELDS = 'tweet.fields=author_id,conversation_id,created_at,in_reply_to_user_id,referenced_tweets';
|
|
34
|
+
|
|
35
|
+
type Handle = (step: Step) => Promise<{ status: number; body: unknown; headers?: Record<string, string> }>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A throwaway world with the org's account, a member's account, and a bearer the way xidentity's
|
|
39
|
+
* flow would have minted one. Every verify below runs against real twin state — a create→read→
|
|
40
|
+
* assert, never a status-only check.
|
|
41
|
+
*/
|
|
42
|
+
async function withWorld(fn: (h: Handle, root: string) => Promise<boolean>, opts: { scopes?: string[] } = {}): Promise<boolean> {
|
|
43
|
+
const root = mkdtempSync(join(tmpdir(), 'x-cap-'));
|
|
44
|
+
const h: Handle = (step) => handleXTwinRequest({
|
|
45
|
+
method: step.m,
|
|
46
|
+
path: step.p,
|
|
47
|
+
body: step.b === undefined ? undefined : JSON.stringify(step.b),
|
|
48
|
+
root,
|
|
49
|
+
readOnly: step.readOnly,
|
|
50
|
+
occurredAt: new Date().toISOString(),
|
|
51
|
+
headers: step.headers ?? AUTH,
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
return await verifyBoundary('x.withWorld', async () => {
|
|
55
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: ORG, username: 'orgvoice', name: 'Org Voice' }, headers: {} });
|
|
56
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: MEMBER, username: 'member', name: 'A Member' }, headers: {} });
|
|
57
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: TOKEN, account_id: ORG, scopes: opts.scopes ?? FULL_SCOPES }, headers: {} });
|
|
58
|
+
return fn(h, root);
|
|
59
|
+
});
|
|
60
|
+
} finally {
|
|
61
|
+
rmSync(root, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Seed an inbound mention from the member and return its id — the input to the reply class. */
|
|
66
|
+
async function seedMention(h: Handle, text = 'hey @orgvoice can you help?'): Promise<string> {
|
|
67
|
+
const seeded = await h({ m: 'POST', p: '/_twin/posts', b: { author_id: MEMBER, text }, headers: {} });
|
|
68
|
+
return String((seeded.body as Body).data.id);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── media fixtures: the smallest bytes the twin reads as an image and as a video ─────────────
|
|
72
|
+
function box(type: string, payload: Uint8Array): Uint8Array {
|
|
73
|
+
const out = new Uint8Array(8 + payload.length);
|
|
74
|
+
new DataView(out.buffer).setUint32(0, out.length);
|
|
75
|
+
out.set(new TextEncoder().encode(type), 4);
|
|
76
|
+
out.set(payload, 8);
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
const concat = (...parts: Uint8Array[]): Uint8Array => {
|
|
80
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
81
|
+
let at = 0;
|
|
82
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
/** A PNG header naming a 64x48 image (the twin reads type and size from the header, as X does). */
|
|
86
|
+
function tinyPng(): Uint8Array {
|
|
87
|
+
const ihdr = new Uint8Array(13);
|
|
88
|
+
new DataView(ihdr.buffer).setUint32(0, 64);
|
|
89
|
+
new DataView(ihdr.buffer).setUint32(4, 48);
|
|
90
|
+
ihdr.set([8, 2, 0, 0, 0], 8);
|
|
91
|
+
return concat(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), box('IHDR', ihdr), new Uint8Array(4));
|
|
92
|
+
}
|
|
93
|
+
/** An MP4's boxes for a 3-second 640x360 video: ftyp, then moov with mvhd and a video tkhd. */
|
|
94
|
+
function tinyMp4(): Uint8Array {
|
|
95
|
+
const mvhd = new Uint8Array(100);
|
|
96
|
+
new DataView(mvhd.buffer).setUint32(12, 1000);
|
|
97
|
+
new DataView(mvhd.buffer).setUint32(16, 3000);
|
|
98
|
+
const tkhd = new Uint8Array(84);
|
|
99
|
+
new DataView(tkhd.buffer).setUint32(76, 640 << 16);
|
|
100
|
+
new DataView(tkhd.buffer).setUint32(80, 360 << 16);
|
|
101
|
+
return concat(box('ftyp', new TextEncoder().encode('isom\0\0\x02\0isom')), box('mdat', new Uint8Array(4096)), box('moov', concat(box('mvhd', mvhd), box('trak', box('tkhd', tkhd)))));
|
|
102
|
+
}
|
|
103
|
+
const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64');
|
|
104
|
+
const MEDIA_AUTH = { authorization: 'Bearer org-media-token' };
|
|
105
|
+
/** Register a bearer that also holds `media.write`, which the upload endpoints require. */
|
|
106
|
+
async function mediaToken(h: Handle): Promise<void> {
|
|
107
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'org-media-token', account_id: ORG, scopes: [...FULL_SCOPES, 'media.write'] }, headers: {} });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec =>
|
|
111
|
+
({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
112
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec =>
|
|
113
|
+
({ id, area, title, dimension, tier, expected: 'todo' });
|
|
114
|
+
|
|
115
|
+
export const X_CAPABILITIES: CapabilitySpec[] = [
|
|
116
|
+
// ── streaming + media (X's OAuth 2.0 authorize/token/refresh/revoke round trip is owned by
|
|
117
|
+
// packages/twin/xidentity; this pack CONSUMES the user access token that flow mints) ─────────
|
|
118
|
+
todo('x.stream.filtered', 'streaming', 'Filtered stream (GET /2/tweets/search/stream): serve the long-lived streaming connection over the twin\'s stored posts, matching the filtered-stream rules, with reconnect and backfill_minutes semantics', 'api', 'common'),
|
|
119
|
+
todo('x.stream.sampled', 'streaming', 'Sampled volume streams (GET /2/tweets/sample/stream and sample10): serve the streaming connection over the twin\'s stored posts with the vendor\'s partitioning parameters', 'api', 'niche'),
|
|
120
|
+
done('x.media.upload', 'media', 'Media upload: POST /2/media/upload for an image, and the chunked initialize/append/finalize protocol with its STATUS-polled processing states for a video, persisting the bytes and returning a media_id attachable to a post', 'api', 'common', () =>
|
|
121
|
+
withWorld(async (h) => {
|
|
122
|
+
await mediaToken(h);
|
|
123
|
+
// an image in one request, base64 in a JSON body: its size and dimensions read from its bytes
|
|
124
|
+
const image = await h({ m: 'POST', p: '/2/media/upload', b: { media: b64(tinyPng()), media_category: 'tweet_image' }, headers: MEDIA_AUTH });
|
|
125
|
+
const img = (image.body as Body).data;
|
|
126
|
+
if (image.status !== 200 || !/^3_\d+$/.test(img?.media_key) || img.image?.w !== 64 || img.image?.h !== 48 || img.expires_after_secs !== 86400) return false;
|
|
127
|
+
// media.write is required: the posting token without it is refused
|
|
128
|
+
const noScope = await h({ m: 'POST', p: '/2/media/upload', b: { media: b64(tinyPng()), media_category: 'tweet_image' } });
|
|
129
|
+
if (noScope.status !== 403) return false;
|
|
130
|
+
// a video in two segments, then processing walked by STATUS
|
|
131
|
+
const mp4 = tinyMp4();
|
|
132
|
+
const init = await h({ m: 'POST', p: '/2/media/upload/initialize', b: { media_type: 'video/mp4', total_bytes: mp4.length, media_category: 'tweet_video' }, headers: MEDIA_AUTH });
|
|
133
|
+
const id = (init.body as Body).data?.id as string;
|
|
134
|
+
if (init.status !== 200 || !/^7_\d+$/.test((init.body as Body).data?.media_key)) return false;
|
|
135
|
+
const half = Math.floor(mp4.length / 2);
|
|
136
|
+
for (const [index, part] of [[0, mp4.subarray(0, half)], [1, mp4.subarray(half)]] as const) {
|
|
137
|
+
const appended = await h({ m: 'POST', p: `/2/media/upload/${id}/append`, b: { media: b64(part), segment_index: index }, headers: MEDIA_AUTH });
|
|
138
|
+
if (appended.status !== 200) return false;
|
|
139
|
+
}
|
|
140
|
+
const finalized = (await h({ m: 'POST', p: `/2/media/upload/${id}/finalize`, headers: MEDIA_AUTH })).body as Body;
|
|
141
|
+
if (finalized.data?.size !== mp4.length || finalized.data?.processing_info?.state !== 'pending') return false;
|
|
142
|
+
const early = await h({ m: 'POST', p: '/2/tweets', b: { text: 'too soon', media: { media_ids: [id] } }, headers: MEDIA_AUTH });
|
|
143
|
+
if (early.status !== 400) return false;
|
|
144
|
+
const states: string[] = [];
|
|
145
|
+
for (let i = 0; i < 2; i += 1) states.push(((await h({ m: 'GET', p: `/2/media/upload?command=STATUS&media_id=${id}`, headers: MEDIA_AUTH })).body as Body).data?.processing_info?.state);
|
|
146
|
+
return states.join(',') === 'in_progress,succeeded';
|
|
147
|
+
})),
|
|
148
|
+
todo('x.media.video_poster_frame', 'media', "A video's preview_image_url as a frame of the video (the twin decodes nothing and serves a stand-in poster at the video's size)", 'api', 'common'),
|
|
149
|
+
todo('x.media.video_transcode_variants', 'media', "The variants X's transcoder publishes (an HLS playlist and several MP4 bit rates) rather than the one uploaded file", 'api', 'niche'),
|
|
150
|
+
todo('x.media.gif', 'media', 'Animated GIF upload (tweet_gif) and its animated_gif media type', 'api', 'common'),
|
|
151
|
+
todo('x.media.alt_text', 'media', 'Alt text on an image (POST /2/media/metadata) and the alt_text media field', 'api', 'common'),
|
|
152
|
+
|
|
153
|
+
// ── the ORIGINAL POST class (POST /2/tweets, no reply object) ────────────────────────────────
|
|
154
|
+
done('x.tweets.create', 'tweets', 'Create an original post (POST /2/tweets)', 'api', 'core', () =>
|
|
155
|
+
withWorld(async (h) => {
|
|
156
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'the org speaks' } });
|
|
157
|
+
const body = created.body as Body;
|
|
158
|
+
if (created.status !== 201 || typeof body.data?.id !== 'string' || body.data.text !== 'the org speaks') return false;
|
|
159
|
+
// Read it back through the twin's own state: a create that does not land is not a create.
|
|
160
|
+
const seenFrom = await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions`, headers: AUTH });
|
|
161
|
+
// The org's own post mentions nobody, so the member's mentions timeline stays empty —
|
|
162
|
+
// which is the read-back that proves the post is real STATE, not an echoed request.
|
|
163
|
+
return seenFrom.status === 200 && (seenFrom.body as Body).meta.result_count === 0;
|
|
164
|
+
})),
|
|
165
|
+
done('x.tweets.create_shape', 'tweets', "Create answers X's thin {id, text, edit_history_tweet_ids} envelope", 'api', 'core', () =>
|
|
166
|
+
withWorld(async (h) => {
|
|
167
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'shape check' } });
|
|
168
|
+
const data = (created.body as Body).data;
|
|
169
|
+
return created.status === 201
|
|
170
|
+
&& Object.keys(data).sort().join(',') === 'edit_history_tweet_ids,id,text'
|
|
171
|
+
&& Array.isArray(data.edit_history_tweet_ids)
|
|
172
|
+
&& data.edit_history_tweet_ids.length === 1
|
|
173
|
+
&& data.edit_history_tweet_ids[0] === data.id;
|
|
174
|
+
})),
|
|
175
|
+
done('x.tweets.create_is_its_own_conversation', 'tweets', 'An original post starts its own conversation', 'api', 'core', () =>
|
|
176
|
+
withWorld(async (h) => {
|
|
177
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member welcome' } });
|
|
178
|
+
const id = (created.body as Body).data.id;
|
|
179
|
+
const mentions = await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions?${THREAD_FIELDS}` });
|
|
180
|
+
const row = (mentions.body as Body).data[0];
|
|
181
|
+
// conversation_id === its own id, and it carries no referenced_tweets: this is a root post.
|
|
182
|
+
return row.id === id && row.conversation_id === id && row.referenced_tweets === undefined && row.in_reply_to_user_id === undefined;
|
|
183
|
+
})),
|
|
184
|
+
done('x.tweets.create_requires_text', 'tweets', 'A create with no text is the invalid-request problem', 'api', 'core', () =>
|
|
185
|
+
withWorld(async (h) => {
|
|
186
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: {} });
|
|
187
|
+
const body = r.body as Body;
|
|
188
|
+
return r.status === 400
|
|
189
|
+
&& body.type === 'https://api.x.com/2/problems/invalid-request'
|
|
190
|
+
&& body.title === 'Invalid Request'
|
|
191
|
+
&& Array.isArray(body.errors) && body.errors[0].parameters.text !== undefined;
|
|
192
|
+
})),
|
|
193
|
+
done('x.tweets.ids_are_snowflake_shaped_and_unique', 'tweets', 'Minted post ids are numeric snowflakes, never a row count', 'api', 'core', () =>
|
|
194
|
+
withWorld(async (h) => {
|
|
195
|
+
const ids: string[] = [];
|
|
196
|
+
for (const text of ['one', 'two', 'three']) {
|
|
197
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text } });
|
|
198
|
+
ids.push((r.body as Body).data.id);
|
|
199
|
+
}
|
|
200
|
+
// Derived from the id SET already in state, so a pulled vendor id above the row count can
|
|
201
|
+
// never be clobbered by a later local create.
|
|
202
|
+
return ids.every((id) => /^\d{19}$/.test(id)) && new Set(ids).size === 3
|
|
203
|
+
&& BigInt(ids[1]!) > BigInt(ids[0]!) && BigInt(ids[2]!) > BigInt(ids[1]!);
|
|
204
|
+
})),
|
|
205
|
+
|
|
206
|
+
// ── the MENTION-REPLY class (POST /2/tweets WITH reply.in_reply_to_tweet_id) ─────────────────
|
|
207
|
+
// Same route, same method, same vendor operation as the class above. Everything that makes
|
|
208
|
+
// them two things lives in the body field these verifies exercise.
|
|
209
|
+
done('x.tweets.reply', 'tweets', 'Reply to a post (POST /2/tweets with reply.in_reply_to_tweet_id)', 'api', 'core', () =>
|
|
210
|
+
withWorld(async (h) => {
|
|
211
|
+
const mentionId = await seedMention(h);
|
|
212
|
+
// The text carries `@member` because that is what a reply on X looks like: the client
|
|
213
|
+
// prepends the parent author's handle, so a reply IS a mention of them. The twin does not
|
|
214
|
+
// rewrite text, so the verify posts what a client would post.
|
|
215
|
+
const replied = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member happy to help', reply: { in_reply_to_tweet_id: mentionId } } });
|
|
216
|
+
if (replied.status !== 201) return false;
|
|
217
|
+
const replyId = (replied.body as Body).data.id;
|
|
218
|
+
// Read the reply back out of real twin state through the MEMBER's mentions timeline, and
|
|
219
|
+
// assert the THREADING the body field caused — not the status the request returned.
|
|
220
|
+
const conversation = await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions?${THREAD_FIELDS}` });
|
|
221
|
+
const rows = (conversation.body as Body).data as Body[];
|
|
222
|
+
const stored = rows.find((r) => r.id === replyId);
|
|
223
|
+
return stored !== undefined
|
|
224
|
+
&& stored.in_reply_to_user_id === MEMBER
|
|
225
|
+
&& stored.conversation_id === mentionId
|
|
226
|
+
&& stored.referenced_tweets?.[0]?.type === 'replied_to'
|
|
227
|
+
&& stored.referenced_tweets?.[0]?.id === mentionId;
|
|
228
|
+
})),
|
|
229
|
+
done('x.tweets.reply_joins_the_parent_conversation', 'tweets', "A reply inherits the parent's conversation_id, not its own", 'api', 'core', () =>
|
|
230
|
+
withWorld(async (h) => {
|
|
231
|
+
const rootId = await seedMention(h);
|
|
232
|
+
const first = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member one', reply: { in_reply_to_tweet_id: rootId } } });
|
|
233
|
+
const firstId = (first.body as Body).data.id;
|
|
234
|
+
const second = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member two', reply: { in_reply_to_tweet_id: firstId } } });
|
|
235
|
+
const secondId = (second.body as Body).data.id;
|
|
236
|
+
const rows = (await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions?${THREAD_FIELDS}` })).body as Body;
|
|
237
|
+
const deep = (rows.data as Body[]).find((r) => r.id === secondId);
|
|
238
|
+
// Two levels down the thread and still in the ROOT's conversation — the vendor's rule.
|
|
239
|
+
return deep?.conversation_id === rootId && deep?.referenced_tweets?.[0]?.id === firstId;
|
|
240
|
+
})),
|
|
241
|
+
done('x.tweets.reply_to_missing_post_refuses', 'tweets', 'A reply to a post that does not exist is resource-not-found', 'api', 'core', () =>
|
|
242
|
+
withWorld(async (h) => {
|
|
243
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'to nobody', reply: { in_reply_to_tweet_id: '404404404404404404' } } });
|
|
244
|
+
const body = r.body as Body;
|
|
245
|
+
return r.status === 404
|
|
246
|
+
&& body.errors?.[0]?.resource_type === 'tweet'
|
|
247
|
+
&& body.errors?.[0]?.parameter === 'reply.in_reply_to_tweet_id'
|
|
248
|
+
&& body.errors?.[0]?.type === 'https://api.twitter.com/2/problems/resource-not-found';
|
|
249
|
+
})),
|
|
250
|
+
done('x.tweets.reply_to_deleted_post_refuses', 'tweets', 'A reply to a RETRACTED post refuses rather than resurrecting the thread', 'api', 'common', () =>
|
|
251
|
+
withWorld(async (h) => {
|
|
252
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'temporary' } });
|
|
253
|
+
const id = (created.body as Body).data.id;
|
|
254
|
+
await h({ m: 'DELETE', p: `/2/tweets/${id}` });
|
|
255
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'late', reply: { in_reply_to_tweet_id: id } } });
|
|
256
|
+
return r.status === 404;
|
|
257
|
+
})),
|
|
258
|
+
done('x.tweets.reply_and_post_are_one_route', 'tweets', 'Reply and original post are the SAME method+path, told apart only by the body', 'api', 'core', () =>
|
|
259
|
+
withWorld(async (h) => {
|
|
260
|
+
const mentionId = await seedMention(h);
|
|
261
|
+
const post = await h({ m: 'POST', p: '/2/tweets', b: { text: 'a' } });
|
|
262
|
+
const reply = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member b', reply: { in_reply_to_tweet_id: mentionId } } });
|
|
263
|
+
const rows = (await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions?${THREAD_FIELDS}` })).body as Body;
|
|
264
|
+
const stored = (rows.data as Body[]).find((r) => r.id === (reply.body as Body).data.id);
|
|
265
|
+
// Identical requests apart from one field; two different things stored. THIS is the fact
|
|
266
|
+
// the census's body-field discriminator exists to make expressible in a grant.
|
|
267
|
+
return post.status === 201 && reply.status === 201
|
|
268
|
+
&& stored?.referenced_tweets?.[0]?.type === 'replied_to'
|
|
269
|
+
&& stored?.conversation_id === mentionId;
|
|
270
|
+
})),
|
|
271
|
+
done('x.tweets.reply_null_discriminator_is_an_original_post', 'tweets', 'A null in_reply_to_tweet_id is an original post, not a broken reply', 'api', 'common', () =>
|
|
272
|
+
withWorld(async (h) => {
|
|
273
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'plain', reply: { in_reply_to_tweet_id: null } } });
|
|
274
|
+
if (r.status !== 201) return false;
|
|
275
|
+
const id = (r.body as Body).data.id;
|
|
276
|
+
const seeded = await h({ m: 'POST', p: '/_twin/posts', b: { author_id: MEMBER, text: `@orgvoice re ${id}` }, headers: {} });
|
|
277
|
+
const rows = (await h({ m: 'GET', p: `/2/users/${ORG}/mentions` })).body as Body;
|
|
278
|
+
return String((seeded.body as Body).data.id) === (rows.data as Body[])[0]!.id;
|
|
279
|
+
})),
|
|
280
|
+
|
|
281
|
+
// ── the QUOTE class (POST /2/tweets WITH quote_tweet_id) ─────────────────────────────────────
|
|
282
|
+
// A third body-field discriminator on the same one route. A quote CITES rather than answers, so
|
|
283
|
+
// for the census's reply/original split it is an ORIGINAL post — the org speaking under its own
|
|
284
|
+
// name — and it starts its own conversation.
|
|
285
|
+
done('x.tweets.quote', 'tweets', 'Quote post (POST /2/tweets with quote_tweet_id)', 'api', 'core', () =>
|
|
286
|
+
withWorld(async (h) => {
|
|
287
|
+
const quotedId = await seedMention(h, 'the member said something quotable');
|
|
288
|
+
const quote = await h({ m: 'POST', p: '/2/tweets', b: { text: 'worth reading', quote_tweet_id: quotedId } });
|
|
289
|
+
if (quote.status !== 201) return false;
|
|
290
|
+
const id = (quote.body as Body).data.id;
|
|
291
|
+
const row = ((await h({ m: 'GET', p: `/2/tweets/${id}?${THREAD_FIELDS}` })).body as Body).data;
|
|
292
|
+
const missing = await h({ m: 'POST', p: '/2/tweets', b: { text: 'nope', quote_tweet_id: '404404404404404404' } });
|
|
293
|
+
// The quoted post is REFERENCED as `quoted`, not `replied_to`; the quote is its own
|
|
294
|
+
// conversation root and answers nobody. All three follow from the one body field.
|
|
295
|
+
return row.id === id && row.conversation_id === id && row.in_reply_to_user_id === undefined
|
|
296
|
+
&& row.referenced_tweets?.length === 1
|
|
297
|
+
&& row.referenced_tweets[0].type === 'quoted' && row.referenced_tweets[0].id === quotedId
|
|
298
|
+
&& missing.status === 404
|
|
299
|
+
&& (missing.body as Body).errors?.[0]?.parameter === 'quote_tweet_id'
|
|
300
|
+
&& (missing.body as Body).errors?.[0]?.resource_type === 'tweet';
|
|
301
|
+
})),
|
|
302
|
+
|
|
303
|
+
// ── lookup (GET /2/tweets/:id and GET /2/tweets?ids=) ────────────────────────────────────────
|
|
304
|
+
done('x.tweets.lookup_by_id', 'tweets', 'Post lookup (GET /2/tweets/:id)', 'api', 'core', () =>
|
|
305
|
+
withWorld(async (h) => {
|
|
306
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'read me back' } });
|
|
307
|
+
const id = (created.body as Body).data.id;
|
|
308
|
+
const found = await h({ m: 'GET', p: `/2/tweets/${id}?tweet.fields=author_id,created_at` });
|
|
309
|
+
const row = (found.body as Body).data;
|
|
310
|
+
// NEVER A FABRICATED RECORD. An id nobody created comes back inside `errors`, by name, with
|
|
311
|
+
// no `data` at all — the difference between a twin and a mock that agrees with any question.
|
|
312
|
+
const ghost = (await h({ m: 'GET', p: '/2/tweets/404404404404404404' })).body as Body;
|
|
313
|
+
const malformed = await h({ m: 'GET', p: '/2/tweets/not-a-snowflake' });
|
|
314
|
+
// …and a RETRACTED post stays gone rather than being resurrected by a later read.
|
|
315
|
+
await h({ m: 'DELETE', p: `/2/tweets/${id}` });
|
|
316
|
+
const afterDelete = (await h({ m: 'GET', p: `/2/tweets/${id}` })).body as Body;
|
|
317
|
+
return found.status === 200 && row.id === id && row.text === 'read me back'
|
|
318
|
+
&& row.author_id === ORG && typeof row.created_at === 'string'
|
|
319
|
+
&& ghost.data === undefined && ghost.errors?.[0]?.resource_type === 'tweet'
|
|
320
|
+
&& ghost.errors[0].value === '404404404404404404' && ghost.errors[0].parameter === 'id'
|
|
321
|
+
&& afterDelete.data === undefined && afterDelete.errors?.[0]?.value === id
|
|
322
|
+
&& malformed.status === 400 && (malformed.body as Body).errors?.[0]?.parameters?.id?.[0] === 'not-a-snowflake';
|
|
323
|
+
})),
|
|
324
|
+
done('x.tweets.lookup_by_ids', 'tweets', 'Bulk post lookup (GET /2/tweets?ids=)', 'api', 'core', () =>
|
|
325
|
+
withWorld(async (h) => {
|
|
326
|
+
const first = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'one' } })).body as Body).data.id;
|
|
327
|
+
const second = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'two' } })).body as Body).data.id;
|
|
328
|
+
const r = await h({ m: 'GET', p: `/2/tweets?ids=${first},404404404404404404,${second}` });
|
|
329
|
+
const body = r.body as Body;
|
|
330
|
+
const noIds = await h({ m: 'GET', p: '/2/tweets' });
|
|
331
|
+
const rows = body.data as Body[];
|
|
332
|
+
// PARTIAL, the way X answers a bulk lookup: the ids that exist in `data`, in the order the
|
|
333
|
+
// caller asked for them, and the one that does not in `errors` — never three invented rows.
|
|
334
|
+
return r.status === 200
|
|
335
|
+
&& rows.map((row) => row.id).join(',') === `${first},${second}`
|
|
336
|
+
&& rows.map((row) => row.text).join(',') === 'one,two'
|
|
337
|
+
&& body.errors?.length === 1 && body.errors[0].value === '404404404404404404'
|
|
338
|
+
&& body.errors[0].parameter === 'ids'
|
|
339
|
+
&& noIds.status === 400 && (noIds.body as Body).errors?.[0]?.parameters?.ids !== undefined;
|
|
340
|
+
})),
|
|
341
|
+
|
|
342
|
+
// ── the field projection (tweet.fields / expansions) ─────────────────────────────────────────
|
|
343
|
+
done('x.fields.tweet_fields', 'fields', 'tweet.fields projection on every post-returning read', 'api', 'core', () =>
|
|
344
|
+
withWorld(async (h) => {
|
|
345
|
+
const id = await seedMention(h);
|
|
346
|
+
const bare = ((await h({ m: 'GET', p: `/2/users/${ORG}/mentions` })).body as Body).data[0];
|
|
347
|
+
const asked = ((await h({ m: 'GET', p: `/2/users/${ORG}/mentions?tweet.fields=author_id,created_at` })).body as Body).data[0];
|
|
348
|
+
const lookup = ((await h({ m: 'GET', p: `/2/tweets/${id}?tweet.fields=conversation_id` })).body as Body).data;
|
|
349
|
+
const unknown = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?tweet.fields=not_a_field` });
|
|
350
|
+
const unmodelled = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?tweet.fields=public_metrics` });
|
|
351
|
+
// THE DEFAULT IS X'S THREE FIELDS AND NOTHING ELSE. A twin that returned everything always
|
|
352
|
+
// would pass an integrator whose client forgets tweet.fields and break them in production.
|
|
353
|
+
return Object.keys(bare).sort().join(',') === 'edit_history_tweet_ids,id,text'
|
|
354
|
+
&& Object.keys(asked).sort().join(',') === 'author_id,created_at,edit_history_tweet_ids,id,text'
|
|
355
|
+
&& asked.author_id === MEMBER && typeof asked.created_at === 'string'
|
|
356
|
+
&& Object.keys(lookup).sort().join(',') === 'conversation_id,edit_history_tweet_ids,id,text'
|
|
357
|
+
&& lookup.conversation_id === id
|
|
358
|
+
// an UNDOCUMENTED value is the vendor's refusal; a DOCUMENTED value this twin holds no
|
|
359
|
+
// state for is refused too, rather than silently omitted (a caller cannot tell an
|
|
360
|
+
// unmodelled field from an absent one).
|
|
361
|
+
&& unknown.status === 400 && (unknown.body as Body).errors?.[0]?.parameters?.['tweet.fields']?.[0] === 'not_a_field'
|
|
362
|
+
&& unmodelled.status === 400 && (unmodelled.body as Body).errors?.[0]?.parameters?.['tweet.fields']?.[0] === 'public_metrics';
|
|
363
|
+
})),
|
|
364
|
+
done('x.fields.expansions', 'fields', 'expansions + the `includes` sidecar (author_id, referenced_tweets.id)', 'api', 'core', () =>
|
|
365
|
+
withWorld(async (h) => {
|
|
366
|
+
const mentionId = await seedMention(h);
|
|
367
|
+
const replied = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member on it', reply: { in_reply_to_tweet_id: mentionId } } });
|
|
368
|
+
const replyId = (replied.body as Body).data.id;
|
|
369
|
+
const expanded = (await h({ m: 'GET', p: `/2/tweets/${replyId}?expansions=author_id,referenced_tweets.id` })).body as Body;
|
|
370
|
+
const bare = (await h({ m: 'GET', p: `/2/tweets/${replyId}` })).body as Body;
|
|
371
|
+
const unmodelled = await h({ m: 'GET', p: `/2/tweets/${replyId}?expansions=geo.place_id` });
|
|
372
|
+
const unknown = await h({ m: 'GET', p: `/2/tweets/${replyId}?expansions=not_an_expansion` });
|
|
373
|
+
return expanded.data.author_id === ORG
|
|
374
|
+
&& expanded.data.referenced_tweets?.[0]?.id === mentionId
|
|
375
|
+
&& expanded.includes.users?.length === 1
|
|
376
|
+
&& expanded.includes.users[0].id === ORG && expanded.includes.users[0].username === 'orgvoice'
|
|
377
|
+
&& expanded.includes.users[0].name === 'Org Voice'
|
|
378
|
+
&& expanded.includes.tweets?.length === 1
|
|
379
|
+
&& expanded.includes.tweets[0].id === mentionId
|
|
380
|
+
&& expanded.includes.tweets[0].text === 'hey @orgvoice can you help?'
|
|
381
|
+
// NOTHING expanded means NO sidecar and no source field — the expansion is what produces
|
|
382
|
+
// both, so a twin that always shipped `includes` would prove nothing here.
|
|
383
|
+
&& bare.includes === undefined && bare.data.author_id === undefined && bare.data.referenced_tweets === undefined
|
|
384
|
+
&& unmodelled.status === 400 && unknown.status === 400;
|
|
385
|
+
})),
|
|
386
|
+
|
|
387
|
+
done('x.fields.user_fields', 'fields', 'user.fields projection on user lookup and inside `includes`', 'api', 'common', () =>
|
|
388
|
+
withWorld(async (h) => {
|
|
389
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3000', username: 'profiled', name: 'Profiled', description: 'we build things', location: 'Brooklyn', url: 'https://example.org', verified: true }, headers: {} });
|
|
390
|
+
const own = ((await h({ m: 'POST', p: '/_twin/posts', b: { author_id: '3000', text: 'from the profiled account' }, headers: {} })).body as Body).data.id;
|
|
391
|
+
const asked = (await h({ m: 'GET', p: '/2/users/3000?user.fields=description,location,url,verified,protected,created_at,most_recent_tweet_id' })).body as Body;
|
|
392
|
+
const bare = (await h({ m: 'GET', p: '/2/users/3000' })).body as Body;
|
|
393
|
+
// The same parser shapes the users a post read expands.
|
|
394
|
+
const expanded = (await h({ m: 'GET', p: `/2/tweets/${own}?expansions=author_id&user.fields=description` })).body as Body;
|
|
395
|
+
const unmodelled = await h({ m: 'GET', p: '/2/users/3000?user.fields=public_metrics' });
|
|
396
|
+
const unknown = await h({ m: 'GET', p: '/2/users/3000?user.fields=not_a_field' });
|
|
397
|
+
return asked.data.description === 'we build things' && asked.data.location === 'Brooklyn'
|
|
398
|
+
&& asked.data.url === 'https://example.org' && asked.data.verified === true && asked.data.protected === false
|
|
399
|
+
&& typeof asked.data.created_at === 'string' && asked.data.most_recent_tweet_id === own
|
|
400
|
+
// X's default user object is exactly three fields: nothing rides along unasked.
|
|
401
|
+
&& Object.keys(bare.data).sort().join(',') === 'id,name,username'
|
|
402
|
+
&& expanded.includes.users[0].description === 'we build things'
|
|
403
|
+
&& unmodelled.status === 400 && (unmodelled.body as Body).errors?.[0]?.parameters?.['user.fields']?.[0] === 'public_metrics'
|
|
404
|
+
&& unknown.status === 400;
|
|
405
|
+
})),
|
|
406
|
+
|
|
407
|
+
// ── user lookup ──────────────────────────────────────────────────────────────────────────────
|
|
408
|
+
done('x.users.lookup_by_id', 'users', 'User lookup by id (GET /2/users/:id)', 'api', 'core', () =>
|
|
409
|
+
withWorld(async (h) => {
|
|
410
|
+
const found = await h({ m: 'GET', p: `/2/users/${ORG}` });
|
|
411
|
+
const missing = await h({ m: 'GET', p: '/2/users/424242' });
|
|
412
|
+
const malformed = await h({ m: 'GET', p: '/2/users/not-an-id' });
|
|
413
|
+
const missingBody = missing.body as Body;
|
|
414
|
+
return found.status === 200 && (found.body as Body).data.id === ORG
|
|
415
|
+
&& (found.body as Body).data.username === 'orgvoice' && (found.body as Body).data.name === 'Org Voice'
|
|
416
|
+
// X's partial-error 200: no `data`, one `errors` row naming the user id.
|
|
417
|
+
&& missing.status === 200 && missingBody.data === undefined
|
|
418
|
+
&& missingBody.errors?.[0]?.resource_type === 'user' && missingBody.errors[0].value === '424242'
|
|
419
|
+
&& malformed.status === 400;
|
|
420
|
+
})),
|
|
421
|
+
done('x.users.lookup_by_username', 'users', 'User lookup by username (GET /2/users/by/username/:username)', 'api', 'core', () =>
|
|
422
|
+
withWorld(async (h) => {
|
|
423
|
+
const exact = (await h({ m: 'GET', p: '/2/users/by/username/orgvoice' })).body as Body;
|
|
424
|
+
// Handles are case-insensitive at X.
|
|
425
|
+
const cased = (await h({ m: 'GET', p: '/2/users/by/username/OrgVoice' })).body as Body;
|
|
426
|
+
const missing = await h({ m: 'GET', p: '/2/users/by/username/nobodyhere' });
|
|
427
|
+
const malformed = await h({ m: 'GET', p: '/2/users/by/username/has-a-dash' });
|
|
428
|
+
const anon = await h({ m: 'GET', p: '/2/users/by/username/orgvoice', headers: {} });
|
|
429
|
+
return exact.data?.id === ORG && cased.data?.id === ORG
|
|
430
|
+
&& missing.status === 200 && (missing.body as Body).errors?.[0]?.parameter === 'username'
|
|
431
|
+
&& (missing.body as Body).errors[0].resource_type === 'user'
|
|
432
|
+
&& malformed.status === 400 && anon.status === 401;
|
|
433
|
+
})),
|
|
434
|
+
done('x.users.lookup_scope_gate', 'users', 'User lookup refuses a token without users.read', 'api', 'common', () =>
|
|
435
|
+
withWorld(async (h) => {
|
|
436
|
+
const refused = await h({ m: 'GET', p: `/2/users/${ORG}` });
|
|
437
|
+
return refused.status === 403;
|
|
438
|
+
}, { scopes: ['tweet.read', 'tweet.write'] })),
|
|
439
|
+
|
|
440
|
+
// ── the timelines ────────────────────────────────────────────────────────────────────────────
|
|
441
|
+
done('x.timelines.user_tweets', 'timelines', 'User posts timeline (GET /2/users/:id/tweets)', 'api', 'core', () =>
|
|
442
|
+
withWorld(async (h) => {
|
|
443
|
+
const first = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'first thing' } })).body as Body).data.id;
|
|
444
|
+
const second = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'second thing' } })).body as Body).data.id;
|
|
445
|
+
await seedMention(h, 'a member post @orgvoice that is NOT ours');
|
|
446
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets?tweet.fields=author_id` })).body as Body).data as Body[];
|
|
447
|
+
// DIRTY STATE ON PURPOSE: retract the newer post and read the timeline again. A fresh-root
|
|
448
|
+
// verify could never see a delete failing to leave the author's own timeline.
|
|
449
|
+
await h({ m: 'DELETE', p: `/2/tweets/${second}` });
|
|
450
|
+
const after = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body as Body).data as Body[];
|
|
451
|
+
const theirs = (await h({ m: 'GET', p: `/2/users/${MEMBER}/tweets` })).body as Body;
|
|
452
|
+
const stranger = await h({ m: 'GET', p: '/2/users/999999/tweets' });
|
|
453
|
+
return rows.map((r) => r.id).join(',') === `${second},${first}`
|
|
454
|
+
&& rows.every((r) => r.author_id === ORG)
|
|
455
|
+
&& after.map((r) => r.id).join(',') === String(first)
|
|
456
|
+
&& theirs.meta.result_count === 1
|
|
457
|
+
&& stranger.status === 404 && (stranger.body as Body).errors?.[0]?.resource_type === 'user';
|
|
458
|
+
})),
|
|
459
|
+
done('x.timelines.reverse_chronological', 'timelines', 'Home timeline (GET /2/users/:id/timelines/reverse_chronological)', 'api', 'core', () =>
|
|
460
|
+
withWorld(async (h) => {
|
|
461
|
+
const mine = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'our own words' } })).body as Body).data.id;
|
|
462
|
+
const theirs = await seedMention(h, 'a member post that tags nobody');
|
|
463
|
+
const before = (await h({ m: 'GET', p: `/2/users/${ORG}/timelines/reverse_chronological` })).body as Body;
|
|
464
|
+
await h({ m: 'POST', p: '/_twin/follows', b: { follower_id: ORG, followed_id: MEMBER }, headers: {} });
|
|
465
|
+
const after = (await h({ m: 'GET', p: `/2/users/${ORG}/timelines/reverse_chronological?tweet.fields=author_id` })).body as Body;
|
|
466
|
+
const someoneElses = await h({ m: 'GET', p: `/2/users/${MEMBER}/timelines/reverse_chronological` });
|
|
467
|
+
// THE FOLLOW EDGE IS WHAT CHANGES THE ANSWER. Before it the home timeline is the account's
|
|
468
|
+
// own words; after it, it is those plus the account it follows. A twin with no graph could
|
|
469
|
+
// not tell these two reads apart, which is why the graph is real seeded state.
|
|
470
|
+
return before.data.length === 1 && before.data[0].id === mine
|
|
471
|
+
&& (after.data as Body[]).map((r) => r.id).join(',') === `${theirs},${mine}`
|
|
472
|
+
&& (after.data as Body[]).map((r) => r.author_id).join(',') === `${MEMBER},${ORG}`
|
|
473
|
+
&& someoneElses.status === 403 && (someoneElses.body as Body).title === 'Forbidden';
|
|
474
|
+
})),
|
|
475
|
+
done('x.timelines.pagination', 'timelines', 'pagination_token / next_token paging on a timeline', 'api', 'core', () =>
|
|
476
|
+
withWorld(async (h) => {
|
|
477
|
+
const ids: string[] = [];
|
|
478
|
+
for (let i = 0; i < 12; i += 1) ids.push(await seedMention(h, `n${i} @orgvoice`));
|
|
479
|
+
const first = (await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5` })).body as Body;
|
|
480
|
+
const again = (await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5` })).body as Body;
|
|
481
|
+
const second = (await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5&pagination_token=${first.meta.next_token}` })).body as Body;
|
|
482
|
+
const third = (await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5&pagination_token=${second.meta.next_token}` })).body as Body;
|
|
483
|
+
const bad = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?pagination_token=not-a-token` });
|
|
484
|
+
const walked = [...(first.data as Body[]), ...(second.data as Body[]), ...(third.data as Body[])].map((r) => r.id);
|
|
485
|
+
return walked.join(',') === [...ids].reverse().join(',')
|
|
486
|
+
&& new Set(walked).size === 12
|
|
487
|
+
&& (third.data as Body[]).length === 2
|
|
488
|
+
// the LAST page carries no token — an unconditional one walks a client into an empty page
|
|
489
|
+
// forever — and the token is a pure function of state, so the same page hands back the same
|
|
490
|
+
// token every time.
|
|
491
|
+
&& third.meta.next_token === undefined
|
|
492
|
+
&& typeof first.meta.next_token === 'string' && again.meta.next_token === first.meta.next_token
|
|
493
|
+
&& bad.status === 400 && (bad.body as Body).errors?.[0]?.parameters?.pagination_token?.[0] === 'not-a-token';
|
|
494
|
+
})),
|
|
495
|
+
done('x.timelines.since_until_id', 'timelines', 'since_id / until_id / start_time / end_time timeline windows', 'api', 'core', () =>
|
|
496
|
+
withWorld(async (h, root) => {
|
|
497
|
+
// PINNED, DISTINCT occurredAt values. `created_at` is what the WRITE stored, so a time
|
|
498
|
+
// window can only be asserted over posts whose times were chosen rather than rolled.
|
|
499
|
+
const at = (day: number) => `2026-08-2${day}T00:00:00.000Z`;
|
|
500
|
+
const seed = async (day: number, text: string) => {
|
|
501
|
+
const r = await handleXTwinRequest({ method: 'POST', path: '/_twin/posts', body: JSON.stringify({ author_id: MEMBER, text }), root, occurredAt: at(day) });
|
|
502
|
+
return String((r.body as Body).data.id);
|
|
503
|
+
};
|
|
504
|
+
const one = await seed(1, 'one @orgvoice');
|
|
505
|
+
const two = await seed(2, 'two @orgvoice');
|
|
506
|
+
const three = await seed(3, 'three @orgvoice');
|
|
507
|
+
const ids = (r: { body: unknown }) => ((r.body as Body).data as Body[] | undefined ?? []).map((row) => row.id).join(',');
|
|
508
|
+
const since = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?since_id=${one}` });
|
|
509
|
+
const until = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?until_id=${three}` });
|
|
510
|
+
const window = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?start_time=${at(2)}&end_time=${at(3)}` });
|
|
511
|
+
// X'S OWN PRECEDENCE: an id bound and a time bound on the same side cannot both apply, and
|
|
512
|
+
// the id wins. since_id=one WITH start_time=day-3 must still answer two rows, not one.
|
|
513
|
+
const precedence = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?since_id=${one}&start_time=${at(3)}` });
|
|
514
|
+
const badId = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?since_id=not-an-id` });
|
|
515
|
+
const badTime = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?start_time=yesterday` });
|
|
516
|
+
return ids(since) === `${three},${two}`
|
|
517
|
+
&& ids(until) === `${two},${one}`
|
|
518
|
+
&& ids(window) === String(two)
|
|
519
|
+
&& ids(precedence) === `${three},${two}`
|
|
520
|
+
&& badId.status === 400 && badTime.status === 400
|
|
521
|
+
&& (badTime.body as Body).errors?.[0]?.parameters?.start_time?.[0] === 'yesterday';
|
|
522
|
+
})),
|
|
523
|
+
|
|
524
|
+
// ── recent search ────────────────────────────────────────────────────────────────────────────
|
|
525
|
+
done('x.search.recent', 'search', 'Recent search (GET /2/tweets/search/recent)', 'api', 'core', () =>
|
|
526
|
+
withWorld(async (h) => {
|
|
527
|
+
const ours = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'shipping the twin today' } })).body as Body).data.id;
|
|
528
|
+
await seedMention(h, 'the member is shipping something else');
|
|
529
|
+
const term = (await h({ m: 'GET', p: '/2/tweets/search/recent?query=shipping' })).body as Body;
|
|
530
|
+
const scoped = (await h({ m: 'GET', p: '/2/tweets/search/recent?query=shipping from:orgvoice&tweet.fields=author_id' })).body as Body;
|
|
531
|
+
const phrase = (await h({ m: 'GET', p: '/2/tweets/search/recent?query="shipping the twin"' })).body as Body;
|
|
532
|
+
const nothing = (await h({ m: 'GET', p: '/2/tweets/search/recent?query=nobodyeversaidthis' })).body as Body;
|
|
533
|
+
const noQuery = await h({ m: 'GET', p: '/2/tweets/search/recent' });
|
|
534
|
+
// Grammar this twin does not model is REFUSED, never ignored: answering a query the twin
|
|
535
|
+
// did not honour is a fake success wearing a 200 (x.search.query_grammar stays a todo).
|
|
536
|
+
const ungrammatical = await h({ m: 'GET', p: '/2/tweets/search/recent?query=shipping -twin' });
|
|
537
|
+
// Search bounds max_results at 10 where the timelines allow 5 — the same value, two answers.
|
|
538
|
+
const tooSmall = await h({ m: 'GET', p: '/2/tweets/search/recent?query=shipping&max_results=5' });
|
|
539
|
+
const timelineOk = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5` });
|
|
540
|
+
// X's keyword operator matches TOKENS, not substrings. Substring matching answered `ship`
|
|
541
|
+
// — and `hipping` — with these rows, a confident result for a query the vendor returns
|
|
542
|
+
// nothing for, which is worse than refusing the query.
|
|
543
|
+
const substring = (await h({ m: 'GET', p: '/2/tweets/search/recent?query=hipp' })).body as Body;
|
|
544
|
+
const phraseOrder = (await h({ m: 'GET', p: '/2/tweets/search/recent?query="twin the shipping"' })).body as Body;
|
|
545
|
+
const hashtagLike = (await h({ m: 'GET', p: '/2/tweets/search/recent?query=today' })).body as Body;
|
|
546
|
+
return substring.meta.result_count === 0
|
|
547
|
+
&& phraseOrder.meta.result_count === 0
|
|
548
|
+
&& hashtagLike.meta.result_count === 1
|
|
549
|
+
&& term.meta.result_count === 2
|
|
550
|
+
&& (scoped.data as Body[]).length === 1
|
|
551
|
+
&& (scoped.data as Body[])[0]!.id === ours && (scoped.data as Body[])[0]!.author_id === ORG
|
|
552
|
+
&& (phrase.data as Body[]).length === 1 && (phrase.data as Body[])[0]!.id === ours
|
|
553
|
+
&& nothing.data === undefined && nothing.meta.result_count === 0
|
|
554
|
+
&& noQuery.status === 400
|
|
555
|
+
&& ungrammatical.status === 400 && (ungrammatical.body as Body).errors?.[0]?.parameters?.query?.[0] === '-twin'
|
|
556
|
+
&& tooSmall.status === 400 && timelineOk.status === 200;
|
|
557
|
+
})),
|
|
558
|
+
|
|
559
|
+
// ── post length ──────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
done('x.errors.text_length_limit', 'errors', 'Post length limits (280 / long-post tiers) and their refusal', 'api', 'core', () =>
|
|
561
|
+
withWorld(async (h) => {
|
|
562
|
+
const at280 = 'a'.repeat(280);
|
|
563
|
+
const accepted = await h({ m: 'POST', p: '/2/tweets', b: { text: at280 } });
|
|
564
|
+
const refused = await h({ m: 'POST', p: '/2/tweets', b: { text: 'a'.repeat(281) } });
|
|
565
|
+
// THE REFUSAL REFUSED. A 400 that still wrote is exactly the bug a status-only verify
|
|
566
|
+
// cannot see, so the author's own timeline is read back afterwards.
|
|
567
|
+
const stored = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body as Body).data as Body[];
|
|
568
|
+
// The long-post tier is an ACCOUNT, not a different endpoint.
|
|
569
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3000', username: 'premium', name: 'Premium', post_character_limit: 25000 }, headers: {} });
|
|
570
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'premium-token', account_id: '3000', scopes: FULL_SCOPES }, headers: {} });
|
|
571
|
+
const premium = { authorization: 'Bearer premium-token' };
|
|
572
|
+
const long = await h({ m: 'POST', p: '/2/tweets', b: { text: 'b'.repeat(1200) }, headers: premium });
|
|
573
|
+
const beyondPremium = await h({ m: 'POST', p: '/2/tweets', b: { text: 'b'.repeat(25001) }, headers: premium });
|
|
574
|
+
const impossibleTier = await h({ m: 'POST', p: '/_twin/accounts', b: { username: 'nope', post_character_limit: 26000 }, headers: {} });
|
|
575
|
+
return accepted.status === 201 && refused.status === 400
|
|
576
|
+
&& (refused.body as Body).errors?.[0]?.parameters?.text?.[0] === '281'
|
|
577
|
+
&& stored.length === 1 && stored[0]!.text === at280
|
|
578
|
+
&& long.status === 201 && beyondPremium.status === 400 && impossibleTier.status === 400;
|
|
579
|
+
})),
|
|
580
|
+
|
|
581
|
+
// ── retraction (DELETE /2/tweets/:id) ────────────────────────────────────────────────────────
|
|
582
|
+
done('x.tweets.delete', 'tweets', 'Retract a post (DELETE /2/tweets/:id)', 'api', 'core', () =>
|
|
583
|
+
withWorld(async (h) => {
|
|
584
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member oops' } });
|
|
585
|
+
const id = (created.body as Body).data.id;
|
|
586
|
+
const before = (await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions` })).body as Body;
|
|
587
|
+
const deleted = await h({ m: 'DELETE', p: `/2/tweets/${id}` });
|
|
588
|
+
const after = (await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions` })).body as Body;
|
|
589
|
+
// Deleted AND gone from the timeline — a delete that answers 200 while the post keeps
|
|
590
|
+
// showing is exactly the bug a status-only verify would miss.
|
|
591
|
+
return before.meta.result_count === 1
|
|
592
|
+
&& deleted.status === 200 && (deleted.body as Body).data.deleted === true
|
|
593
|
+
&& after.meta.result_count === 0;
|
|
594
|
+
})),
|
|
595
|
+
done('x.tweets.delete_unknown_refuses', 'tweets', 'Deleting a post that does not exist is resource-not-found', 'api', 'core', () =>
|
|
596
|
+
withWorld(async (h) => {
|
|
597
|
+
const r = await h({ m: 'DELETE', p: '/2/tweets/404404404404404404' });
|
|
598
|
+
return r.status === 404 && (r.body as Body).errors?.[0]?.resource_type === 'tweet';
|
|
599
|
+
})),
|
|
600
|
+
done('x.tweets.delete_twice_refuses', 'tweets', 'A second delete of the same post refuses (not a silent success)', 'api', 'common', () =>
|
|
601
|
+
withWorld(async (h) => {
|
|
602
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'once' } });
|
|
603
|
+
const id = (created.body as Body).data.id;
|
|
604
|
+
const first = await h({ m: 'DELETE', p: `/2/tweets/${id}` });
|
|
605
|
+
const second = await h({ m: 'DELETE', p: `/2/tweets/${id}` });
|
|
606
|
+
return first.status === 200 && second.status === 404;
|
|
607
|
+
})),
|
|
608
|
+
done('x.tweets.delete_only_your_own', 'tweets', "A public voice cannot retract someone ELSE's post", 'api', 'core', () =>
|
|
609
|
+
withWorld(async (h) => {
|
|
610
|
+
const foreignId = await seedMention(h);
|
|
611
|
+
const r = await h({ m: 'DELETE', p: `/2/tweets/${foreignId}` });
|
|
612
|
+
const still = (await h({ m: 'GET', p: `/2/users/${ORG}/mentions` })).body as Body;
|
|
613
|
+
return r.status === 403 && (r.body as Body).title === 'Forbidden' && still.meta.result_count === 1;
|
|
614
|
+
})),
|
|
615
|
+
|
|
616
|
+
// ── the mentions timeline (GET /2/users/:id/mentions) ────────────────────────────────────────
|
|
617
|
+
done('x.mentions.list', 'mentions', 'Read the mentions timeline (GET /2/users/:id/mentions)', 'api', 'core', () =>
|
|
618
|
+
withWorld(async (h) => {
|
|
619
|
+
const mentionId = await seedMention(h);
|
|
620
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?${THREAD_FIELDS}` });
|
|
621
|
+
const body = r.body as Body;
|
|
622
|
+
return r.status === 200
|
|
623
|
+
&& body.data.length === 1 && body.data[0].id === mentionId
|
|
624
|
+
&& body.data[0].author_id === MEMBER
|
|
625
|
+
&& body.meta.result_count === 1 && body.meta.newest_id === mentionId && body.meta.oldest_id === mentionId;
|
|
626
|
+
})),
|
|
627
|
+
done('x.mentions.empty_omits_data', 'mentions', "An EMPTY timeline omits `data` entirely, as the vendor does", 'api', 'core', () =>
|
|
628
|
+
withWorld(async (h) => {
|
|
629
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions` });
|
|
630
|
+
const body = r.body as Body;
|
|
631
|
+
// A client reading `response.data.length` breaks against the real vendor if the twin
|
|
632
|
+
// invents `[]` here. Faithfulness is the absence.
|
|
633
|
+
return r.status === 200 && body.data === undefined && body.meta.result_count === 0 && Object.keys(body).join(',') === 'meta';
|
|
634
|
+
})),
|
|
635
|
+
done('x.mentions.newest_first', 'mentions', 'The timeline is ordered newest-first by post id', 'api', 'core', () =>
|
|
636
|
+
withWorld(async (h) => {
|
|
637
|
+
const first = await seedMention(h, 'first @orgvoice');
|
|
638
|
+
const second = await seedMention(h, 'second @orgvoice');
|
|
639
|
+
const third = await seedMention(h, 'third @orgvoice');
|
|
640
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${ORG}/mentions` })).body as Body).data as Body[];
|
|
641
|
+
return rows.map((r) => r.id).join(',') === [third, second, first].join(',');
|
|
642
|
+
})),
|
|
643
|
+
done('x.mentions.excludes_own_posts', 'mentions', "The org's own posts are not its own mentions", 'api', 'core', () =>
|
|
644
|
+
withWorld(async (h) => {
|
|
645
|
+
await h({ m: 'POST', p: '/2/tweets', b: { text: 'talking about @orgvoice, i.e. me' } });
|
|
646
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions` });
|
|
647
|
+
return (r.body as Body).meta.result_count === 0;
|
|
648
|
+
})),
|
|
649
|
+
done('x.mentions.handle_boundaries', 'mentions', '@handle matching respects word boundaries and is case-insensitive', 'api', 'common', () =>
|
|
650
|
+
withWorld(async (h) => {
|
|
651
|
+
await h({ m: 'POST', p: '/_twin/posts', b: { author_id: MEMBER, text: 'ping @OrgVoice!' }, headers: {} });
|
|
652
|
+
await h({ m: 'POST', p: '/_twin/posts', b: { author_id: MEMBER, text: 'not @orgvoicebot though' }, headers: {} });
|
|
653
|
+
await h({ m: 'POST', p: '/_twin/posts', b: { author_id: MEMBER, text: 'nor foo@orgvoice' }, headers: {} });
|
|
654
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions` });
|
|
655
|
+
return (r.body as Body).meta.result_count === 1;
|
|
656
|
+
})),
|
|
657
|
+
done('x.mentions.max_results_bounds', 'mentions', 'max_results is bounded 5..100, out of range is the invalid-request problem', 'api', 'common', () =>
|
|
658
|
+
withWorld(async (h) => {
|
|
659
|
+
await seedMention(h);
|
|
660
|
+
const low = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=1` });
|
|
661
|
+
const high = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=500` });
|
|
662
|
+
const okRange = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5` });
|
|
663
|
+
return low.status === 400 && high.status === 400 && okRange.status === 200
|
|
664
|
+
&& (low.body as Body).errors?.[0]?.parameters?.max_results?.[0] === '1';
|
|
665
|
+
})),
|
|
666
|
+
done('x.mentions.max_results_pages', 'mentions', 'max_results actually bounds the page', 'api', 'common', () =>
|
|
667
|
+
withWorld(async (h) => {
|
|
668
|
+
for (let i = 0; i < 7; i += 1) await seedMention(h, `n${i} @orgvoice`);
|
|
669
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions?max_results=5` });
|
|
670
|
+
const body = r.body as Body;
|
|
671
|
+
return body.data.length === 5 && body.meta.result_count === 5;
|
|
672
|
+
})),
|
|
673
|
+
done('x.mentions.unknown_user_refuses', 'mentions', 'A mentions read for an account that does not exist is resource-not-found', 'api', 'common', () =>
|
|
674
|
+
withWorld(async (h) => {
|
|
675
|
+
const r = await h({ m: 'GET', p: '/2/users/999999/mentions' });
|
|
676
|
+
return r.status === 404 && (r.body as Body).errors?.[0]?.resource_type === 'user';
|
|
677
|
+
})),
|
|
678
|
+
done('x.mentions.excludes_retracted', 'mentions', 'A retracted post leaves the mentions timeline', 'api', 'common', () =>
|
|
679
|
+
withWorld(async (h) => {
|
|
680
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member hi' } });
|
|
681
|
+
await h({ m: 'DELETE', p: `/2/tweets/${(created.body as Body).data.id}` });
|
|
682
|
+
const r = await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions` });
|
|
683
|
+
return (r.body as Body).meta.result_count === 0;
|
|
684
|
+
})),
|
|
685
|
+
|
|
686
|
+
// ── auth: the bearer xidentity's flow mints, and the scopes it carries ────────────────────────
|
|
687
|
+
done('x.auth.bearer_required', 'auth', 'An absent bearer is the about:blank 401 problem', 'api', 'core', () =>
|
|
688
|
+
withWorld(async (h) => {
|
|
689
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions`, headers: {} });
|
|
690
|
+
const body = r.body as Body;
|
|
691
|
+
return r.status === 401 && body.type === 'about:blank' && body.title === 'Unauthorized' && body.status === 401;
|
|
692
|
+
})),
|
|
693
|
+
done('x.auth.unknown_bearer_refuses', 'auth', 'An unregistered bearer is refused, not silently accepted', 'api', 'core', () =>
|
|
694
|
+
withWorld(async (h) => {
|
|
695
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'sneaky' }, headers: { authorization: 'Bearer not-a-real-token' } });
|
|
696
|
+
return r.status === 401;
|
|
697
|
+
})),
|
|
698
|
+
done('x.auth.malformed_authorization_refuses', 'auth', 'A non-Bearer Authorization header is refused', 'api', 'common', () =>
|
|
699
|
+
withWorld(async (h) => {
|
|
700
|
+
const basic = await h({ m: 'POST', p: '/2/tweets', b: { text: 'x' }, headers: { authorization: `Basic ${TOKEN}` } });
|
|
701
|
+
const bare = await h({ m: 'POST', p: '/2/tweets', b: { text: 'x' }, headers: { authorization: TOKEN } });
|
|
702
|
+
return basic.status === 401 && bare.status === 401;
|
|
703
|
+
})),
|
|
704
|
+
done('x.auth.write_scope_gate', 'auth', 'Posting without tweet.write is the 403 problem', 'api', 'core', () =>
|
|
705
|
+
withWorld(async (h) => {
|
|
706
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'unauthorized voice' } });
|
|
707
|
+
const body = r.body as Body;
|
|
708
|
+
return r.status === 403 && body.title === 'Forbidden' && String(body.detail).includes('tweet.write');
|
|
709
|
+
}, { scopes: ['tweet.read', 'users.read'] })),
|
|
710
|
+
done('x.auth.read_scope_gate', 'auth', 'Reading mentions without tweet.read is the 403 problem', 'api', 'core', () =>
|
|
711
|
+
withWorld(async (h) => {
|
|
712
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions` });
|
|
713
|
+
return r.status === 403 && String((r.body as Body).detail).includes('tweet.read');
|
|
714
|
+
}, { scopes: ['tweet.write', 'users.read'] })),
|
|
715
|
+
done('x.auth.reply_scope_equals_post_scope', 'auth', 'A reply requires the same vendor scope an original post does', 'api', 'core', () =>
|
|
716
|
+
withWorld(async (h) => {
|
|
717
|
+
// The VENDOR does not distinguish them — one operation, one scope. The estate's ladder
|
|
718
|
+
// lives in the census/grant layer, not in a scope X does not publish. Recording that here
|
|
719
|
+
// keeps a future reader from inventing a `tweet.reply` scope X has never had.
|
|
720
|
+
const mention = await h({ m: 'POST', p: '/_twin/posts', b: { author_id: MEMBER, text: '@orgvoice hi' }, headers: {} });
|
|
721
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'hello', reply: { in_reply_to_tweet_id: (mention.body as Body).data.id } } });
|
|
722
|
+
return r.status === 403 && String((r.body as Body).detail).includes('tweet.write');
|
|
723
|
+
}, { scopes: ['tweet.read', 'users.read'] })),
|
|
724
|
+
|
|
725
|
+
// ── error surface ────────────────────────────────────────────────────────────────────────────
|
|
726
|
+
done('x.errors.unmodelled_route_404s', 'errors', 'An unmodelled /2 route fails like the vendor, never a fake success', 'api', 'core', () =>
|
|
727
|
+
withWorld(async (h) => {
|
|
728
|
+
// Recent search IS modelled now, so the unmodelled-route claim moved to routes that are
|
|
729
|
+
// genuinely still absent: post COUNTS and the engagement verbs.
|
|
730
|
+
const counts = await h({ m: 'GET', p: '/2/tweets/counts/recent?query=volter' });
|
|
731
|
+
const like = await h({ m: 'POST', p: `/2/users/${ORG}/likes`, b: { tweet_id: '1' } });
|
|
732
|
+
const hidden = await h({ m: 'PUT', p: '/2/tweets/1900000000000000001/hidden', b: { hidden: true } });
|
|
733
|
+
return counts.status === 404 && (counts.body as Body).title === 'Not Found Error'
|
|
734
|
+
&& like.status === 404 && hidden.status === 404;
|
|
735
|
+
})),
|
|
736
|
+
done('x.errors.problem_content_type', 'errors', 'Problem envelopes carry application/problem+json', 'api', 'common', () =>
|
|
737
|
+
withWorld(async (h) => {
|
|
738
|
+
const r = await h({ m: 'GET', p: `/2/users/${ORG}/mentions`, headers: {} });
|
|
739
|
+
return r.headers?.['content-type'] === 'application/problem+json';
|
|
740
|
+
})),
|
|
741
|
+
done('x.errors.malformed_body_refuses', 'errors', 'An unparseable request body is the invalid-request problem', 'api', 'common', () =>
|
|
742
|
+
withWorld(async (h, root) => {
|
|
743
|
+
const r = await handleXTwinRequest({ method: 'POST', path: '/2/tweets', body: '{not json', headers: AUTH, root });
|
|
744
|
+
return r.status === 400 && (r.body as Body).type === 'https://api.x.com/2/problems/invalid-request';
|
|
745
|
+
})),
|
|
746
|
+
done('x.errors.rate_limit_429_code_88', 'errors', 'The armed 15-minute-window refusal is 429 with legacy code 88', 'api', 'common', () =>
|
|
747
|
+
withWorld(async (h) => {
|
|
748
|
+
await h({ m: 'POST', p: '/_twin/rate_limit', b: { armed: true, limit: 200 }, headers: {} });
|
|
749
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'blocked' } });
|
|
750
|
+
const body = r.body as Body;
|
|
751
|
+
return r.status === 429
|
|
752
|
+
&& body.errors?.[0]?.code === 88 && body.errors?.[0]?.message === 'Rate limit exceeded'
|
|
753
|
+
&& r.headers?.['x-rate-limit-remaining'] === '0'
|
|
754
|
+
&& typeof r.headers?.['x-rate-limit-reset'] === 'string';
|
|
755
|
+
})),
|
|
756
|
+
done('x.errors.read_only_refuses_writes', 'errors', 'A read-only twin refuses every write with 405', 'api', 'core', () =>
|
|
757
|
+
withWorld(async (h) => {
|
|
758
|
+
const post = await h({ m: 'POST', p: '/2/tweets', b: { text: 'nope' }, readOnly: true });
|
|
759
|
+
const del = await h({ m: 'DELETE', p: '/2/tweets/1', readOnly: true });
|
|
760
|
+
const read = await h({ m: 'GET', p: `/2/users/${ORG}/mentions`, readOnly: true });
|
|
761
|
+
return post.status === 405 && del.status === 405 && read.status === 200;
|
|
762
|
+
})),
|
|
763
|
+
|
|
764
|
+
// ── connector (pull/push over an INJECTED client) ────────────────────────────────────────────
|
|
765
|
+
done('x.connector.pull_mentions', 'connector', 'Pull the mentions timeline from the real vendor into observed state', 'connector', 'core', () =>
|
|
766
|
+
withWorld(async (h, root) => {
|
|
767
|
+
// A PATH-AWARE fake, because the pull now reads two timelines: what was said to the org
|
|
768
|
+
// and what the org itself said. A fake that answered every path identically would fold the
|
|
769
|
+
// same rows twice and prove nothing about either read.
|
|
770
|
+
const execute: XExecute = async (_m, path) => (path.includes('/mentions')
|
|
771
|
+
? { data: [{ id: '1900000000000000001', text: 'hey @orgvoice', author_id: MEMBER, created_at: '2026-08-24T00:00:00.000Z', conversation_id: '1900000000000000001' }], meta: { result_count: 1 } }
|
|
772
|
+
: { meta: { result_count: 0 } });
|
|
773
|
+
const result = await syncXFromReal(execute, { userId: ORG, root });
|
|
774
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${ORG}/mentions?${THREAD_FIELDS}` })).body as Body).data as Body[];
|
|
775
|
+
return result.observed === 1 && result.deltasAppended > 0 && rows.length === 1
|
|
776
|
+
&& rows[0]!.id === '1900000000000000001' && rows[0]!.author_id === MEMBER;
|
|
777
|
+
})),
|
|
778
|
+
done('x.connector.pull_idempotent', 'connector', 'A second identical pull appends zero deltas', 'connector', 'core', () =>
|
|
779
|
+
withWorld(async (_h, root) => {
|
|
780
|
+
const execute: XExecute = async (_m, path) => (path.includes('/mentions')
|
|
781
|
+
? { data: [{ id: '1900000000000000002', text: 'hey @orgvoice', author_id: MEMBER, created_at: '2026-08-24T00:00:00.000Z' }], meta: { result_count: 1 } }
|
|
782
|
+
: { meta: { result_count: 0 } });
|
|
783
|
+
await syncXFromReal(execute, { userId: ORG, root });
|
|
784
|
+
const second = await syncXFromReal(execute, { userId: ORG, root });
|
|
785
|
+
return second.observed === 1 && second.deltasAppended === 0;
|
|
786
|
+
})),
|
|
787
|
+
done('x.connector.empty_timeline_is_not_a_refusal', 'connector', "An empty timeline (meta only, no data) pulls as zero rows, not an error", 'connector', 'core', () =>
|
|
788
|
+
withWorld(async (_h, root) => {
|
|
789
|
+
const execute: XExecute = async () => ({ meta: { result_count: 0 } });
|
|
790
|
+
const result = await syncXFromReal(execute, { userId: ORG, root });
|
|
791
|
+
return result.observed === 0 && result.deltasAppended === 0;
|
|
792
|
+
})),
|
|
793
|
+
// COUNTING THROWS IS NOT A TEST. The first version of this verify counted exceptions, and a
|
|
794
|
+
// DEAD connector seam throws too — so it was green against the saboteur that exists to catch
|
|
795
|
+
// exactly this (scripts/mutation-test.ts, `connectorDead`). A refusal has to be identified by
|
|
796
|
+
// the REFUSAL: the vendor's own reply has to reach the caller inside the error, real observed
|
|
797
|
+
// state has to survive it untouched, and a legitimate pull in the same run has to still fold.
|
|
798
|
+
// All three break under a dead seam; none of them breaks because "an exception happened".
|
|
799
|
+
done('x.connector.refused_pull_throws', 'connector', 'A refusal-shaped reply THROWS naming the refusal, and folds NOTHING over observed state', 'connector', 'core', () =>
|
|
800
|
+
withWorld(async (h, root) => {
|
|
801
|
+
// A LEGITIMATE pull FIRST, so there is real observed state for a refusal to damage — and so
|
|
802
|
+
// this verify can never be satisfied by everything throwing.
|
|
803
|
+
const good: XExecute = async (_m, path) => (path.includes('/mentions')
|
|
804
|
+
? { data: [{ id: '1900000000000000001', text: 'hey @orgvoice', author_id: MEMBER, created_at: '2026-08-24T00:00:00.000Z', conversation_id: '1900000000000000001' }], meta: { result_count: 1 } }
|
|
805
|
+
: { meta: { result_count: 0 } });
|
|
806
|
+
const folded = await syncXFromReal(good, { userId: ORG, root });
|
|
807
|
+
if (folded.observed !== 1 || folded.deltasAppended === 0) return false;
|
|
808
|
+
|
|
809
|
+
// Each case asserts a fragment of the VENDOR'S OWN REPLY in the message the caller sees.
|
|
810
|
+
// The saboteur's message ('connector seam is dead') carries none of them.
|
|
811
|
+
const cases: Array<{ execute: XExecute; names: string }> = [
|
|
812
|
+
// a 200 carrying a problem envelope: neither `data` nor `meta`
|
|
813
|
+
{ execute: async () => ({ title: 'Unauthorized', type: 'about:blank', status: 401 }), names: 'about:blank' },
|
|
814
|
+
// a row whose id is not an X-shaped snowflake — a foreign id entering the projection
|
|
815
|
+
{ execute: async () => ({ data: [{ id: 'not-a-snowflake', text: 'x' }], meta: { result_count: 1 } }), names: 'not-a-snowflake' },
|
|
816
|
+
// `data` present but not an array
|
|
817
|
+
{ execute: async () => ({ data: { id: '1900000000000000009' }, meta: { result_count: 1 } }), names: '1900000000000000009' },
|
|
818
|
+
];
|
|
819
|
+
for (const { execute, names } of cases) {
|
|
820
|
+
let message: string | undefined;
|
|
821
|
+
try {
|
|
822
|
+
await syncXFromReal(execute, { userId: ORG, root });
|
|
823
|
+
} catch (error) {
|
|
824
|
+
message = error instanceof Error ? error.message : String(error);
|
|
825
|
+
}
|
|
826
|
+
if (message === undefined) return false;
|
|
827
|
+
if (!/^x mentions pull (refused or malformed|returned a row)/.test(message)) return false;
|
|
828
|
+
if (!message.includes(names)) return false;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// And the refusals folded NOTHING: the legitimately observed post is still the only row,
|
|
832
|
+
// unchanged. A refusal that quietly emptied the timeline would pass every assertion above.
|
|
833
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${ORG}/mentions` })).body as Body).data as Body[];
|
|
834
|
+
return rows.length === 1 && rows[0]!.id === '1900000000000000001' && rows[0]!.text === 'hey @orgvoice';
|
|
835
|
+
})),
|
|
836
|
+
done('x.connector.push_request_shape', 'connector', 'The push request shape carries the reply discriminator unchanged', 'connector', 'core', () => {
|
|
837
|
+
const post = xRequestForAction({ operation: 'x.post.create', subject: { type: 'post', id: '9000000000000000001' }, fields: { text: 'hello' } });
|
|
838
|
+
const reply = xRequestForAction({ operation: 'x.post.reply', subject: { type: 'post', id: '9000000000000000002' }, fields: { text: 'hi', referenced_tweets: [{ type: 'replied_to', id: '1900000000000000001' }] } });
|
|
839
|
+
const quote = xRequestForAction({ operation: 'x.post.quote', subject: { type: 'post', id: '9000000000000000003' }, fields: { text: 'worth reading', referenced_tweets: [{ type: 'quoted', id: '1900000000000000002' }] } });
|
|
840
|
+
const del = xRequestForAction({ operation: 'x.post.delete', subject: { type: 'post', id: '1900000000000000009' }, fields: {} });
|
|
841
|
+
return post?.method === 'POST' && post.path === '/2/tweets' && JSON.parse(post.body!).reply === undefined
|
|
842
|
+
&& reply?.method === 'POST' && reply.path === '/2/tweets' && JSON.parse(reply.body!).reply.in_reply_to_tweet_id === '1900000000000000001'
|
|
843
|
+
&& quote?.method === 'POST' && quote.path === '/2/tweets'
|
|
844
|
+
&& JSON.parse(quote.body!).quote_tweet_id === '1900000000000000002' && JSON.parse(quote.body!).reply === undefined
|
|
845
|
+
&& del?.method === 'DELETE' && del.path === '/2/tweets/1900000000000000009';
|
|
846
|
+
}),
|
|
847
|
+
done('x.connector.push_confirms_the_real_id', 'connector', "A pushed post is confirmed under the id the VENDOR minted, retiring the local one", 'connector', 'core', () =>
|
|
848
|
+
withWorld(async (h, root) => {
|
|
849
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: '@member pushed' } });
|
|
850
|
+
const localId = (created.body as Body).data.id;
|
|
851
|
+
const { pendingActions } = await import('@volter/world-core');
|
|
852
|
+
const action = pendingActions('x', root).find((a) => a.operation === 'x.post.create');
|
|
853
|
+
if (!action) return false;
|
|
854
|
+
const execute: XExecute = async () => ({ data: { id: '1911111111111111111', text: '@member pushed' } });
|
|
855
|
+
const result = await pushXAction(execute, action as never, { root });
|
|
856
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${MEMBER}/mentions` })).body as Body).data as Body[];
|
|
857
|
+
// The public id is live and the locally minted one is retired IN THE SAME confirmation, so
|
|
858
|
+
// no window exists in which a later delete could address a post that does not exist.
|
|
859
|
+
return result.realId === '1911111111111111111'
|
|
860
|
+
&& rows.length === 1 && rows[0]!.id === '1911111111111111111'
|
|
861
|
+
&& !rows.some((r) => r.id === localId);
|
|
862
|
+
})),
|
|
863
|
+
done('x.connector.pull_own_timeline', 'connector', "Pull the org's OWN posted timeline from the real vendor", 'connector', 'core', () =>
|
|
864
|
+
withWorld(async (h, root) => {
|
|
865
|
+
// A post the org made SOMEWHERE ELSE — on x.com, or in a run this twin never saw. Nothing
|
|
866
|
+
// local knows it exists, so a pull that reads only mentions comes back still believing the
|
|
867
|
+
// org never spoke.
|
|
868
|
+
const execute: XExecute = async (_m, path) => (path.includes('/mentions')
|
|
869
|
+
? { meta: { result_count: 0 } }
|
|
870
|
+
: { data: [{ id: '1900000000000000077', text: 'said this from a phone', author_id: ORG, created_at: '2026-08-24T00:00:00.000Z', conversation_id: '1900000000000000077' }], meta: { result_count: 1 } });
|
|
871
|
+
const before = (await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body as Body;
|
|
872
|
+
const result = await syncXFromReal(execute, { userId: ORG, root });
|
|
873
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets?tweet.fields=author_id,created_at` })).body as Body).data as Body[];
|
|
874
|
+
// …and the read really is the OWN-timeline leg: opting out of it observes nothing at all.
|
|
875
|
+
const optedOut = await syncXFromReal(execute, { userId: ORG, root, includeOwnTimeline: false });
|
|
876
|
+
return before.meta.result_count === 0
|
|
877
|
+
&& result.observed === 1 && result.deltasAppended > 0
|
|
878
|
+
&& rows.length === 1 && rows[0]!.id === '1900000000000000077'
|
|
879
|
+
&& rows[0]!.text === 'said this from a phone' && rows[0]!.author_id === ORG
|
|
880
|
+
&& rows[0]!.created_at === '2026-08-24T00:00:00.000Z'
|
|
881
|
+
&& optedOut.observed === 0;
|
|
882
|
+
})),
|
|
883
|
+
done('x.connector.pull_pagination', 'connector', 'Follow next_token across a multi-page mentions pull', 'connector', 'core', () =>
|
|
884
|
+
withWorld(async (h, root) => {
|
|
885
|
+
const asked: string[] = [];
|
|
886
|
+
const page = (ids: string[], author: string, next?: string) => ({
|
|
887
|
+
data: ids.map((id) => ({ id, text: author === MEMBER ? `hey @orgvoice ${id}` : `the org said ${id}`, author_id: author, created_at: '2026-08-24T00:00:00.000Z' })),
|
|
888
|
+
meta: { result_count: ids.length, ...(next === undefined ? {} : { next_token: next }) },
|
|
889
|
+
});
|
|
890
|
+
// BOTH legs page. The own-timeline leg answering a single empty page left half the claim
|
|
891
|
+
// ("both pull legs follow next_token") resting on the mentions leg alone — and the two legs
|
|
892
|
+
// carry DIFFERENT authors, so the mentions read below can only see the mentions leg's rows.
|
|
893
|
+
const execute: XExecute = async (_m, path) => {
|
|
894
|
+
asked.push(path);
|
|
895
|
+
if (path.includes('/mentions')) {
|
|
896
|
+
return path.includes('pagination_token=PAGE2') ? page(['1900000000000000012'], MEMBER) : page(['1900000000000000011'], MEMBER, 'PAGE2');
|
|
897
|
+
}
|
|
898
|
+
return path.includes('pagination_token=OWN2') ? page(['1900000000000000022'], ORG) : page(['1900000000000000021'], ORG, 'OWN2');
|
|
899
|
+
};
|
|
900
|
+
const result = await syncXFromReal(execute, { userId: ORG, root });
|
|
901
|
+
const rows = ((await h({ m: 'GET', p: `/2/users/${ORG}/mentions` })).body as Body).data as Body[];
|
|
902
|
+
const own = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body as Body).data as Body[];
|
|
903
|
+
// A vendor that keeps handing back the SAME token is refused by name rather than paged
|
|
904
|
+
// forever — and, just as important, rather than truncated in silence.
|
|
905
|
+
let looped: string | undefined;
|
|
906
|
+
try {
|
|
907
|
+
await syncXFromReal(async () => page(['1900000000000000013'], MEMBER, 'SAME'), { userId: ORG, root });
|
|
908
|
+
} catch (error) {
|
|
909
|
+
looped = error instanceof Error ? error.message : String(error);
|
|
910
|
+
}
|
|
911
|
+
// …and a vendor that keeps handing back DISTINCT tokens forever hits the page cap, which
|
|
912
|
+
// the repeated-token guard would otherwise always reach first.
|
|
913
|
+
let capped: string | undefined;
|
|
914
|
+
try {
|
|
915
|
+
let issued = 0;
|
|
916
|
+
await syncXFromReal(async () => page([`19000000000000001${(issued += 1) % 10}`], MEMBER, `T${issued}`), { userId: ORG, root });
|
|
917
|
+
} catch (error) {
|
|
918
|
+
capped = error instanceof Error ? error.message : String(error);
|
|
919
|
+
}
|
|
920
|
+
return result.observed === 4
|
|
921
|
+
&& own.map((r) => r.id).sort().join(',') === '1900000000000000021,1900000000000000022'
|
|
922
|
+
&& asked.filter((path) => path.includes('/mentions')).length === 2
|
|
923
|
+
&& asked.filter((path) => path.includes('/tweets') && !path.includes('/mentions')).length === 2
|
|
924
|
+
&& asked.some((path) => path.includes('pagination_token=PAGE2'))
|
|
925
|
+
&& asked.some((path) => path.includes('pagination_token=OWN2'))
|
|
926
|
+
&& rows.map((r) => r.id).sort().join(',') === '1900000000000000011,1900000000000000012'
|
|
927
|
+
&& looped !== undefined && /repeated next_token \(SAME\)/.test(looped)
|
|
928
|
+
&& capped !== undefined && /exceeded 32 pages/.test(capped);
|
|
929
|
+
})),
|
|
930
|
+
done('x.connector.live_refuses_unmapped_paths', 'connector', 'The live executor refuses any X path this pack has not modelled', 'connector', 'core', async () => {
|
|
931
|
+
let calls = 0;
|
|
932
|
+
const execute = (await import('./x-connector.ts')).liveXExecute('token', { fetchImpl: (async () => { calls += 1; return new Response('{}'); }) as unknown as typeof fetch, budgetOptions: { path: join(mkdtempSync(join(tmpdir(), 'x-budget-')), 'ledger.json') } });
|
|
933
|
+
// Same rule as x.connector.refused_pull_throws: the refusal is identified by NAMING the path
|
|
934
|
+
// it refused, not by an exception having occurred.
|
|
935
|
+
for (const path of ['/2/users/1/likes', '/1.1/statuses/update.json', '/2/dm_conversations']) {
|
|
936
|
+
let message: string | undefined;
|
|
937
|
+
try {
|
|
938
|
+
await execute('POST', path, { body: '{}' });
|
|
939
|
+
} catch (error) {
|
|
940
|
+
message = error instanceof Error ? error.message : String(error);
|
|
941
|
+
}
|
|
942
|
+
if (message === undefined) return false;
|
|
943
|
+
if (!message.startsWith('liveXExecute: refusing to call an unmapped X path:')) return false;
|
|
944
|
+
if (!message.endsWith(path)) return false;
|
|
945
|
+
}
|
|
946
|
+
// And it refused BEFORE any I/O: the injected fake was never called.
|
|
947
|
+
return calls === 0;
|
|
948
|
+
}),
|
|
949
|
+
done('x.connector.live_refuses_injected_authorization', 'connector', 'The live executor refuses a caller-supplied Authorization header', 'connector', 'core', async () => {
|
|
950
|
+
let calls = 0;
|
|
951
|
+
const execute = (await import('./x-connector.ts')).liveXExecute('token', { fetchImpl: (async () => { calls += 1; return new Response('{}'); }) as unknown as typeof fetch, budgetOptions: { path: join(mkdtempSync(join(tmpdir(), 'x-budget-')), 'ledger.json') } });
|
|
952
|
+
// /2/tweets IS a mapped path, so an exception here can only come from the header refusal —
|
|
953
|
+
// and it must say so, by name, before any I/O.
|
|
954
|
+
let message: string | undefined;
|
|
955
|
+
try {
|
|
956
|
+
await execute('POST', '/2/tweets', { headers: { Authorization: 'Bearer someone-elses' }, body: '{}' });
|
|
957
|
+
} catch (error) {
|
|
958
|
+
message = error instanceof Error ? error.message : String(error);
|
|
959
|
+
}
|
|
960
|
+
return message === 'liveXExecute: refusing an injected Authorization header; the guarded credential is fixed at construction' && calls === 0;
|
|
961
|
+
}),
|
|
962
|
+
|
|
963
|
+
// ── the rate budget (the fail-closed backstop on the live path) ──────────────────────────────
|
|
964
|
+
// A TRAP THIS PACK WALKED INTO ONCE, WRITTEN DOWN SO THE NEXT READER DOES NOT. `write` and
|
|
965
|
+
// `other` are BOTH 5 — deliberately, so unmodelled surface is priced at the write. That makes
|
|
966
|
+
// `xCallWeight('POST', '/2/tweets') === X_CALL_WEIGHTS.write` a TAUTOLOGY: delete the
|
|
967
|
+
// `^POST /2/tweets$` rule and the default answers 5 and the assertion still passes. The
|
|
968
|
+
// mutation gate cannot see it either, because it sabotages module seams, not a data table.
|
|
969
|
+
// So every assertion below is one the DEFAULT would answer differently.
|
|
970
|
+
done('x.budget.prices_a_write_above_a_read', 'rate_limit', 'A post costs more budget than reading the mentions timeline', 'connector', 'core', () => {
|
|
971
|
+
const read = xCallWeight('GET', '/2/users/1000/mentions?tweet.fields=author_id');
|
|
972
|
+
const unruled = xCallWeight('GET', '/2/tweets/1900000000000000001');
|
|
973
|
+
// The READ rule is the only weight distinguishable from the fallback, so it carries the
|
|
974
|
+
// behavioural half: a path the rules do not name falls to `other`, and the mentions read
|
|
975
|
+
// must NOT, or the rule never fired.
|
|
976
|
+
// (No `read === unruled` guard: the compiler narrows both to their literal weights after the
|
|
977
|
+
// checks above and rejects the comparison as provably false — which is the property itself.)
|
|
978
|
+
if (read !== X_CALL_WEIGHTS.read || unruled !== X_CALL_WEIGHTS.other) return false;
|
|
979
|
+
// The WRITE rules cannot be told from the fallback by their weight, so they are asserted
|
|
980
|
+
// where they actually live: the committed declaration the kernel registry is armed from.
|
|
981
|
+
const matchers = X_RATE_BUDGET.rules?.map((rule) => `${rule.match} ${rule.weight}`) ?? [];
|
|
982
|
+
return matchers.includes(`^POST /2/tweets$ ${X_CALL_WEIGHTS.write}`)
|
|
983
|
+
&& matchers.includes(`^DELETE /2/tweets/ ${X_CALL_WEIGHTS.write}`)
|
|
984
|
+
&& matchers.includes(`^GET /2/users/[^/]+/mentions$ ${X_CALL_WEIGHTS.read}`)
|
|
985
|
+
&& X_CALL_WEIGHTS.write > X_CALL_WEIGHTS.read;
|
|
986
|
+
}),
|
|
987
|
+
done('x.budget.case_insensitive_method', 'rate_limit', "A lower-cased method and a trailing slash are priced as the call really is", 'connector', 'core', () => {
|
|
988
|
+
// Priced on the READ path for the reason above: an un-normalized key misses the rule and
|
|
989
|
+
// falls to `other` (5), which is distinguishable from `read` (1). On a write path it would
|
|
990
|
+
// fall to 5 and look identical to a hit, proving nothing.
|
|
991
|
+
return xCallWeight('get', '/2/users/1000/mentions') === X_CALL_WEIGHTS.read
|
|
992
|
+
&& xCallWeight('GET', '/2/users/1000/mentions/') === X_CALL_WEIGHTS.read
|
|
993
|
+
&& xCallWeight('get', '/2/users/1000/mentions/?tweet.fields=author_id') === X_CALL_WEIGHTS.read;
|
|
994
|
+
}),
|
|
995
|
+
done('x.budget.enforced_fail_closed', 'rate_limit', 'The declared ceiling stops the live path BEFORE the request goes out', 'connector', 'core', async () => {
|
|
996
|
+
let calls = 0;
|
|
997
|
+
const ledger = join(mkdtempSync(join(tmpdir(), 'x-budget-')), 'ledger.json');
|
|
998
|
+
const execute = (await import('./x-connector.ts')).liveXExecute('token', {
|
|
999
|
+
fetchImpl: (async () => { calls += 1; return new Response(JSON.stringify({ data: { id: '1900000000000000001', text: 'x' } }), { headers: { 'content-type': 'application/json' } }); }) as unknown as typeof fetch,
|
|
1000
|
+
budgetOptions: { path: ledger },
|
|
1001
|
+
});
|
|
1002
|
+
// Ten writes at weight 5 exhaust the 50-unit window exactly; the eleventh must never reach
|
|
1003
|
+
// fetch. Nothing here waits on a wall clock: the guard refuses synchronously.
|
|
1004
|
+
let refusedAt = 0;
|
|
1005
|
+
for (let i = 0; i < 20; i += 1) {
|
|
1006
|
+
try { await execute('POST', '/2/tweets', { body: '{"text":"x"}' }); } catch (error) { if (error instanceof XBudgetError) { refusedAt = i; break; } throw error; }
|
|
1007
|
+
}
|
|
1008
|
+
return refusedAt > 0 && calls === refusedAt && calls * X_CALL_WEIGHTS.write <= X_BUDGET_CEILING;
|
|
1009
|
+
}),
|
|
1010
|
+
done('x.budget.declared_ceiling_is_the_pack_descriptor', 'rate_limit', "The pack descriptor arms the SAME declaration the connector imports", 'connector', 'common', async () => {
|
|
1011
|
+
const { pack } = await import('./index.ts');
|
|
1012
|
+
const { X_RATE_BUDGET } = await import('./x-budget.ts');
|
|
1013
|
+
return pack.rateBudget === X_RATE_BUDGET && new XBudget({ path: join(mkdtempSync(join(tmpdir(), 'x-budget-')), 'l.json') }).path.endsWith('l.json');
|
|
1014
|
+
}),
|
|
1015
|
+
|
|
1016
|
+
// ── the twin control plane (how a WORLD seeds a rehearsal) ───────────────────────────────────
|
|
1017
|
+
done('x.twin_control.seed_account_and_token', 'twin_control', 'A world seeds an account and the bearer xidentity would have minted', 'api', 'core', () =>
|
|
1018
|
+
withWorld(async (h) => {
|
|
1019
|
+
const r = await h({ m: 'POST', p: '/2/tweets', b: { text: 'seeded and speaking' } });
|
|
1020
|
+
return r.status === 201;
|
|
1021
|
+
})),
|
|
1022
|
+
done('x.twin_control.rejects_unshaped_username', 'twin_control', 'A seeded account must carry an X-shaped username', 'api', 'common', () =>
|
|
1023
|
+
withWorld(async (h) => {
|
|
1024
|
+
const tooLong = await h({ m: 'POST', p: '/_twin/accounts', b: { username: 'averyveryverylongusername' }, headers: {} });
|
|
1025
|
+
const punctuation = await h({ m: 'POST', p: '/_twin/accounts', b: { username: 'has.a.dot' }, headers: {} });
|
|
1026
|
+
return tooLong.status === 400 && punctuation.status === 400;
|
|
1027
|
+
})),
|
|
1028
|
+
done('x.twin_control.token_must_name_a_real_account', 'twin_control', 'A seeded token cannot name an account that does not exist', 'api', 'common', () =>
|
|
1029
|
+
withWorld(async (h) => {
|
|
1030
|
+
const r = await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'orphan', account_id: '777777' }, headers: {} });
|
|
1031
|
+
return r.status === 400;
|
|
1032
|
+
})),
|
|
1033
|
+
done('x.twin_control.is_not_vendor_surface', 'twin_control', 'The control plane is refused in read-only mode and is not under /2', 'api', 'common', () =>
|
|
1034
|
+
withWorld(async (h) => {
|
|
1035
|
+
const readOnly = await h({ m: 'POST', p: '/_twin/accounts', b: { username: 'nope' }, headers: {}, readOnly: true });
|
|
1036
|
+
const unknown = await h({ m: 'POST', p: '/_twin/nothing', b: {}, headers: {} });
|
|
1037
|
+
return readOnly.status === 405 && unknown.status === 404;
|
|
1038
|
+
})),
|
|
1039
|
+
|
|
1040
|
+
// ── the enumerated surface this pack does NOT model yet ──────────────────────────────────────
|
|
1041
|
+
todo('x.tweets.poll', 'tweets', 'Post with a poll (POST /2/tweets with poll.options/duration_minutes)', 'api', 'common'),
|
|
1042
|
+
done('x.tweets.media_attachment', 'tweets', 'Post with media (POST /2/tweets with media.media_ids), read back through attachments.media_keys and includes.media', 'api', 'common', () =>
|
|
1043
|
+
withWorld(async (h) => {
|
|
1044
|
+
await mediaToken(h);
|
|
1045
|
+
const up = (await h({ m: 'POST', p: '/2/media/upload', b: { media: b64(tinyPng()), media_category: 'tweet_image' }, headers: MEDIA_AUTH })).body as Body;
|
|
1046
|
+
const mediaId = up.data?.id as string;
|
|
1047
|
+
// another account's media id is X's own refusal, and nothing lands
|
|
1048
|
+
await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'member-token', account_id: MEMBER, scopes: FULL_SCOPES }, headers: {} });
|
|
1049
|
+
const foreign = await h({ m: 'POST', p: '/2/tweets', b: { text: 'not mine', media: { media_ids: [mediaId] } }, headers: { authorization: 'Bearer member-token' } });
|
|
1050
|
+
if (foreign.status !== 400 || (foreign.body as Body).errors?.[0]?.message !== 'Your media IDs are invalid.') return false;
|
|
1051
|
+
const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'with a picture', media: { media_ids: [mediaId] } } });
|
|
1052
|
+
const postId = (created.body as Body).data?.id as string;
|
|
1053
|
+
if (created.status !== 201) return false;
|
|
1054
|
+
const read = (await h({ m: 'GET', p: `/2/tweets/${postId}?expansions=attachments.media_keys&media.fields=url,type,width,height` })).body as Body;
|
|
1055
|
+
const media = read.includes?.media?.[0];
|
|
1056
|
+
// the default projection carries no attachments; the expansion brings them and the sidecar
|
|
1057
|
+
const plain = (await h({ m: 'GET', p: `/2/tweets/${postId}` })).body as Body;
|
|
1058
|
+
return read.data?.attachments?.media_keys?.[0] === up.data.media_key && media?.media_key === up.data.media_key
|
|
1059
|
+
&& media.type === 'photo' && media.width === 64 && media.height === 48 && /^https:\/\/pbs\.twimg\.com\/media\/[0-9a-f]{64}\.png$/.test(media.url)
|
|
1060
|
+
&& plain.data?.attachments === undefined && plain.includes === undefined;
|
|
1061
|
+
})),
|
|
1062
|
+
todo('x.tweets.reply_settings', 'tweets', 'reply_settings (mentionedUsers / following / subscribers)', 'api', 'common'),
|
|
1063
|
+
todo('x.tweets.exclude_reply_user_ids', 'tweets', 'reply.exclude_reply_user_ids on a reply', 'api', 'niche'),
|
|
1064
|
+
todo('x.tweets.super_followers_only', 'tweets', 'for_super_followers_only posts', 'api', 'niche'),
|
|
1065
|
+
todo('x.tweets.geo_place', 'tweets', 'geo.place_id tagging on a post', 'api', 'niche'),
|
|
1066
|
+
todo('x.tweets.hide_reply', 'tweets', 'Hide/unhide a reply (PUT /2/tweets/:id/hidden)', 'api', 'common'),
|
|
1067
|
+
todo('x.tweets.quote_tweets_list', 'tweets', 'Quote posts of a post (GET /2/tweets/:id/quote_tweets)', 'api', 'common'),
|
|
1068
|
+
todo('x.tweets.liking_users', 'tweets', 'Users who liked a post (GET /2/tweets/:id/liking_users)', 'api', 'common'),
|
|
1069
|
+
todo('x.tweets.retweeted_by', 'tweets', 'Users who reposted a post (GET /2/tweets/:id/retweeted_by)', 'api', 'common'),
|
|
1070
|
+
todo('x.fields.entities', 'fields', 'entities (mentions, urls, hashtags) on a returned post', 'api', 'common'),
|
|
1071
|
+
todo('x.fields.public_metrics', 'fields', 'public_metrics (reply/repost/like/quote counts) on a post', 'api', 'common'),
|
|
1072
|
+
todo('x.timelines.exclude', 'timelines', 'exclude=retweets,replies on a timeline read', 'api', 'common'),
|
|
1073
|
+
todo('x.search.seven_day_window', 'search', "Recent search's seven-day corpus window, and the refusal for a start_time older than it", 'api', 'common'),
|
|
1074
|
+
todo('x.search.all', 'search', 'Full-archive search (GET /2/tweets/search/all)', 'api', 'niche'),
|
|
1075
|
+
todo('x.search.counts', 'search', 'Post counts (GET /2/tweets/counts/recent and /all)', 'api', 'niche'),
|
|
1076
|
+
todo('x.search.query_grammar', 'search', "X's search query grammar (operators, groups, negation)", 'api', 'niche'),
|
|
1077
|
+
todo('x.engagement.like', 'engagement', 'Like / unlike a post (POST and DELETE /2/users/:id/likes)', 'api', 'common'),
|
|
1078
|
+
todo('x.engagement.retweet', 'engagement', 'Repost / un-repost (POST and DELETE /2/users/:id/retweets)', 'api', 'common'),
|
|
1079
|
+
todo('x.engagement.bookmark', 'engagement', 'Bookmarks (GET, POST and DELETE /2/users/:id/bookmarks)', 'api', 'niche'),
|
|
1080
|
+
todo('x.engagement.liked_tweets', 'engagement', 'Posts a user liked (GET /2/users/:id/liked_tweets)', 'api', 'niche'),
|
|
1081
|
+
todo('x.streaming.rules', 'streaming', 'Filtered-stream rule management (GET and POST /2/tweets/search/stream/rules)', 'api', 'niche'),
|
|
1082
|
+
todo('x.rate_limit.published_figures', 'rate_limit', "Pin the budget to X's published per-endpoint posting limits (the ceiling here is conservative, not transcribed)", 'connector', 'core'),
|
|
1083
|
+
todo('x.rate_limit.response_headers', 'rate_limit', 'x-rate-limit-limit/remaining/reset headers on every /2 response, counted per window', 'api', 'common'),
|
|
1084
|
+
todo('x.rate_limit.window_enforcement', 'rate_limit', 'Enforce the real 15-minute window in the twin rather than only on an armed refusal', 'api', 'common'),
|
|
1085
|
+
todo('x.errors.scope_wording', 'errors', "Pin X's own wording for the insufficient-scope 403 (the twin states the refusal plainly)", 'api', 'common'),
|
|
1086
|
+
todo('x.errors.resource_not_found_wording', 'errors', "Pin X's own detail/title wording for the resource-not-found problem", 'api', 'common'),
|
|
1087
|
+
todo('x.errors.text_length_weighting', 'errors', "X's WEIGHTED character count (CJK and emoji cost two, a URL counts 23) rather than the twin's plain code-point count", 'api', 'common'),
|
|
1088
|
+
todo('x.errors.duplicate_content', 'errors', "X's duplicate-content refusal for posting the same text twice", 'api', 'common'),
|
|
1089
|
+
todo('x.auth.app_only_bearer', 'auth', 'App-only (OAuth 2.0 client-credentials) bearer for the public read endpoints', 'api', 'common'),
|
|
1090
|
+
todo('x.auth.oauth1_user_context', 'auth', "OAuth 1.0a user-context signing, X's alternate auth for these endpoints", 'api', 'niche'),
|
|
1091
|
+
todo('x.auth.access_tier_gate', 'auth', "Access-tier gating (Free/Basic/Pro), which decides whether an endpoint exists for a token at all", 'api', 'common'),
|
|
1092
|
+
// ══ MIRROR (dimension: 'ui' — every one is DATA-COUPLED: seed through the handler, read the
|
|
1093
|
+
// SAME route the screen reads, render the mirror's OWN component over it) ══════════════════════
|
|
1094
|
+
done('x.mirror.profile_header', 'mirror', "The profile header renders the account the user-lookup route serves", 'ui', 'core', () =>
|
|
1095
|
+
withWorld(async (h) => {
|
|
1096
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3000', username: 'profiled', name: 'Profiled Org', description: 'we build things', location: 'Brooklyn', verified: true }, headers: {} });
|
|
1097
|
+
const user = ((await h({ m: 'GET', p: `/2/users/by/username/profiled?${PROFILE_READ_QUERY}` })).body as Body).data as XRow | undefined;
|
|
1098
|
+
if (!user) return false;
|
|
1099
|
+
const markup = renderToStaticMarkup(createElement(ProfileHeader, { user }));
|
|
1100
|
+
return markup.includes('Profiled Org') && markup.includes('@profiled') && markup.includes('we build things')
|
|
1101
|
+
&& markup.includes('Brooklyn') && markup.includes(joinedLabel(user.created_at)) && markup.includes('class="badge"');
|
|
1102
|
+
})),
|
|
1103
|
+
done('x.mirror.timeline', 'mirror', "The timeline renders the account's own posts newest first, each with name, @handle, time and text", 'ui', 'core', () =>
|
|
1104
|
+
withWorld(async (h) => {
|
|
1105
|
+
const older = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'the older post' } })).body as Body).data.id as string;
|
|
1106
|
+
const newer = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'the newer post, see @member' } })).body as Body).data.id as string;
|
|
1107
|
+
const page = (await h({ m: 'GET', p: `/2/users/${ORG}/tweets?${POST_READ_QUERY}` })).body as Body;
|
|
1108
|
+
const rows = (page.data ?? []) as XRow[];
|
|
1109
|
+
const users = usersById(page.includes);
|
|
1110
|
+
const tweets = tweetsById(page.includes);
|
|
1111
|
+
const markup = rows.map((post) => renderToStaticMarkup(createElement(PostRow, { post, users, tweets, nowMs: Date.parse(String(post.created_at)) + 120_000 }))).join('');
|
|
1112
|
+
const iNewer = markup.indexOf(`data-post-id="${newer}"`);
|
|
1113
|
+
const iOlder = markup.indexOf(`data-post-id="${older}"`);
|
|
1114
|
+
return rows.length === 2 && iNewer !== -1 && iOlder !== -1 && iNewer < iOlder
|
|
1115
|
+
&& markup.includes('Org Voice') && markup.includes('@orgvoice') && markup.includes('>2m<')
|
|
1116
|
+
// the mention is an entity link, as x.com draws it
|
|
1117
|
+
&& markup.includes('href="#/member"');
|
|
1118
|
+
})),
|
|
1119
|
+
done('x.mirror.quote_card', 'mirror', 'A quote post renders the quoted post inside its card, from the `includes` the read expanded', 'ui', 'common', () =>
|
|
1120
|
+
withWorld(async (h) => {
|
|
1121
|
+
const mentionId = await seedMention(h, 'a member said something @orgvoice');
|
|
1122
|
+
const quote = ((await h({ m: 'POST', p: '/2/tweets', b: { text: 'worth reading', quote_tweet_id: mentionId } })).body as Body).data.id as string;
|
|
1123
|
+
const read = (await h({ m: 'GET', p: `/2/tweets/${quote}?${POST_READ_QUERY}` })).body as Body;
|
|
1124
|
+
if (!read.data) return false;
|
|
1125
|
+
const markup = renderToStaticMarkup(createElement(PostRow, { post: read.data as XRow, users: usersById(read.includes), tweets: tweetsById(read.includes), nowMs: Date.now() }));
|
|
1126
|
+
return markup.includes('class="quote"') && markup.includes('a member said something') && markup.includes('@member') && markup.includes('worth reading');
|
|
1127
|
+
})),
|
|
1128
|
+
done('x.mirror.serves_the_vendor_api_on_its_own_origin', 'mirror', 'The mirror origin serves the REAL X handler — one serving code path, not a second app', 'ui', 'core', async () => {
|
|
1129
|
+
const root = mkdtempSync(join(tmpdir(), 'x-cap-mirror-'));
|
|
1130
|
+
const server = await createXMirrorServer({ root, port: 0 });
|
|
1131
|
+
try {
|
|
1132
|
+
const json = { 'content-type': 'application/json' };
|
|
1133
|
+
await fetch(`${server.url}/_twin/accounts`, { method: 'POST', headers: json, body: JSON.stringify({ id: ORG, username: 'orgvoice', name: 'Org Voice' }) });
|
|
1134
|
+
await fetch(`${server.url}/_twin/tokens`, { method: 'POST', headers: json, body: JSON.stringify({ token: TOKEN, account_id: ORG, scopes: FULL_SCOPES }) });
|
|
1135
|
+
// 1. a WRITE to X's real path, through the MIRROR origin
|
|
1136
|
+
const posted = await fetch(`${server.url}/2/tweets`, { method: 'POST', headers: { ...json, ...AUTH }, body: JSON.stringify({ text: 'written through the mirror origin' }) });
|
|
1137
|
+
if (posted.status !== 201) return false;
|
|
1138
|
+
const id = ((await posted.json()) as Body).data.id;
|
|
1139
|
+
// 2. it landed in the SAME projection the API serves — read back through the handler directly
|
|
1140
|
+
const direct = await handleXTwinRequest({ method: 'GET', path: `/2/tweets/${id}`, headers: AUTH, root });
|
|
1141
|
+
// 3. the mirror origin enforces the twin's OWN auth: no bearer is X's 401, not a mirror bypass
|
|
1142
|
+
const anon = await fetch(`${server.url}/2/users/by/username/orgvoice`);
|
|
1143
|
+
const shell = await (await fetch(`${server.url}/`)).text();
|
|
1144
|
+
return direct.status === 200 && (direct.body as Body).data?.text === 'written through the mirror origin'
|
|
1145
|
+
&& anon.status === 401 && shell.includes('<div id="root">');
|
|
1146
|
+
} finally {
|
|
1147
|
+
server.stop();
|
|
1148
|
+
rmSync(root, { recursive: true, force: true });
|
|
1149
|
+
}
|
|
1150
|
+
}),
|
|
1151
|
+
todo('x.mirror.notifications_all', 'mirror', "Notifications beyond Mentions (likes, reposts, follows) — the twin models no engagement events to list", 'ui', 'common'),
|
|
1152
|
+
todo('x.mirror.profile_counts', 'mirror', "The Following / Followers counts under the profile header (needs user public_metrics)", 'ui', 'common'),
|
|
1153
|
+
|
|
1154
|
+
// ── Pull-surface coverage audit gaps — filed as manifest todos so
|
|
1155
|
+
// the demand-ordered build list is drawn from manifest todos. See pull-audit.json.
|
|
1156
|
+
todo('x.connector.pull_conversation', 'connector', 'Connector: pull the replies under a conversation from the real vendor', 'connector', 'common'),
|
|
1157
|
+
];
|
|
1158
|
+
|
|
1159
|
+
export async function xCapabilities(): Promise<CapabilityReport> {
|
|
1160
|
+
return checkCapabilities('x', X_CAPABILITIES);
|
|
1161
|
+
}
|