@volter/twin-x 0.1.0 → 0.1.2

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.
@@ -15,6 +15,15 @@ const SESSION_TOKEN = 'x-mirror.token';
15
15
  function readSession() {
16
16
  if (typeof sessionStorage === 'undefined')
17
17
  return null;
18
+ // the World signed these screens in as the account its config names (`signIn.as`): taken once, as a sign-in would
19
+ // (off the page once taken, and not again in this tab once the person signs out)
20
+ const w = window;
21
+ const given = sessionStorage.getItem('volter.signedOut') ? undefined : w.__VOLTER_SIGN_IN__;
22
+ delete w.__VOLTER_SIGN_IN__;
23
+ if (given && !sessionStorage.getItem(SESSION_TOKEN)) {
24
+ sessionStorage.setItem(SESSION_HANDLE, given.account);
25
+ sessionStorage.setItem(SESSION_TOKEN, given.token);
26
+ }
18
27
  const handle = sessionStorage.getItem(SESSION_HANDLE);
19
28
  const token = sessionStorage.getItem(SESSION_TOKEN);
20
29
  return handle && token ? { handle, token } : null;
@@ -391,6 +400,7 @@ function App() {
391
400
  const signOut = () => {
392
401
  sessionStorage.removeItem(SESSION_HANDLE);
393
402
  sessionStorage.removeItem(SESSION_TOKEN);
403
+ sessionStorage.setItem('volter.signedOut', '1');
394
404
  setSession(null);
395
405
  setMe(null);
396
406
  go('i/flow/login');
package/dist/src/index.js CHANGED
@@ -16,11 +16,13 @@ import { X_RATE_BUDGET as RATE_BUDGET } from "./x-budget.js";
16
16
  * recent search, the three per-user timelines (mentions, the account's own posts, the home
17
17
  * timeline), user lookup by id and by username, and image upload (one-shot and chunked) — plus the
18
18
  * twin-only control prefix. The user-id
19
- * rule EXCLUDES `/2/users/me`: that route is xidentity's, and the two claims stay disjoint. ANCHORED: an unanchored claim would swallow
19
+ * rule EXCLUDES `/2/users/me`: that route is xidentity's, and the two claims stay disjoint. So does
20
+ * the control prefix: `/_twin/assets/consent.{js,css}` are xidentity's, the page assets of the
21
+ * OAuth 1.0a screen it serves on this host (claimed here, the screen rendered unstyled in a World). ANCHORED: an unanchored claim would swallow
20
22
  * `/2/tweetsomething`. Everything else on api.x.com stays unclaimed, so an unmodelled X call
21
23
  * refuses loudly rather than landing in a twin that cannot serve it.
22
24
  */
23
- const X_API_PATHS = '^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/';
25
+ const X_API_PATHS = '^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/(?!assets/consent\\.(?:js|css)$)';
24
26
  export const pack = {
25
27
  vendor: 'x',
26
28
  // The SAME object x-budget.ts declares at module load — one source of truth, so registering
@@ -6,6 +6,7 @@
6
6
  // are X surface this manifest deliberately does NOT enumerate — they are outside the census's
7
7
  // committed boundary, and claiming them here would inflate a denominator nothing measures.
8
8
  // (`packages/twin/xidentity` owns X's OAuth + identity surface and its own denominator.)
9
+ import { createHmac } from 'node:crypto';
9
10
  import { mkdtempSync, rmSync } from 'node:fs';
10
11
  import { tmpdir } from 'node:os';
11
12
  import { join } from 'node:path';
@@ -13,6 +14,8 @@ import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
13
14
  import { pushXAction, xRequestForAction, syncXFromReal } from "./x-connector.js";
14
15
  import { X_BUDGET_CEILING, X_CALL_WEIGHTS, X_RATE_BUDGET, XBudget, XBudgetError, xCallWeight } from "./x-budget.js";
15
16
  import { handleXTwinRequest } from "./x-twin.js";
17
+ import { createXTwinFetch } from "./x-server.js";
18
+ import { applyTwinWrite } from '@volter/world-core';
16
19
  import { createElement } from 'react';
17
20
  import { renderToStaticMarkup } from 'react-dom/server';
18
21
  import { PostRow, ProfileHeader } from "../client/x-mirror.js";
@@ -101,6 +104,30 @@ const MEDIA_AUTH = { authorization: 'Bearer org-media-token' };
101
104
  async function mediaToken(h) {
102
105
  await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'org-media-token', account_id: ORG, scopes: [...FULL_SCOPES, 'media.write'] }, headers: {} });
103
106
  }
107
+ // ── OAuth 1.0a: xidentity's credential store, and an independent client signer ──────────────────
108
+ // The access token a person approved lives in xidentity's state (xidentity-oauth1.ts names the row
109
+ // shapes as the contract; x-oauth1.ts reads them). A verify here cannot run xidentity's legs — A3
110
+ // forbids the import — so it writes the rows those legs write, on the same root, as a World that
111
+ // ran them would hold; x-oauth1.integration.test.ts drives the real legs across both packs.
112
+ const O1_APP = { key: 'VerifyConsumerKey00000001', secret: 'VerifyConsumerSecret000000000000000000000000001' };
113
+ const O1_TOKEN = { key: `${ORG}-VerifyAccessToken0000000000000000000000000`, secret: 'VerifyAccessSecret000000000000000000000001' };
114
+ async function seedOAuth1(root, token = O1_TOKEN, fields = {}) {
115
+ const at = '2026-02-01T00:00:00.000Z';
116
+ await applyTwinWrite('xidentity', { operation: 'oauth1_app.create', subjectType: 'oauth1_app', subjectId: O1_APP.key, fields: { secret: O1_APP.secret, name: 'Verify App', callbackUrls: [], accessLevel: 'read-write' }, occurredAt: at }, root);
117
+ await applyTwinWrite('xidentity', { operation: 'oauth1_token.create', subjectType: 'oauth1_token', subjectId: token.key, fields: { consumerKey: O1_APP.key, secret: token.secret, userId: ORG, screenName: 'orgvoice', accessLevel: 'read-write', revoked: false, ...fields }, occurredAt: at }, root);
118
+ }
119
+ /** RFC 5849 HMAC-SHA1, written here from docs.x.com's creating-a-signature guide — NOT x-oauth1.ts's
120
+ * verifier, so a verify cannot pass by agreeing with itself. `url` is what the client signed. */
121
+ function o1Header(method, url, app = O1_APP, token = O1_TOKEN) {
122
+ const enc = (v) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
123
+ const [base, query = ''] = url.split('?');
124
+ const oauth = { oauth_consumer_key: app.key, oauth_nonce: 'verifynonce', oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: '1769904000', oauth_token: token.key, oauth_version: '1.0' };
125
+ const params = [...Object.entries(oauth), ...new URLSearchParams(query).entries()].map(([k, v]) => `${enc(k)}=${enc(v)}`).sort().join('&');
126
+ oauth['oauth_signature'] = createHmac('sha1', `${enc(app.secret)}&${enc(token.secret)}`).update(`${method}&${enc(base)}&${enc(params)}`).digest('base64');
127
+ return 'OAuth ' + Object.entries(oauth).map(([k, v]) => `${enc(k)}="${enc(v)}"`).join(', ');
128
+ }
129
+ /** A direct in-process call names no host, so the client signed X's own API origin. */
130
+ const API = 'https://api.x.com';
104
131
  const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
105
132
  const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
106
133
  export const X_CAPABILITIES = [
@@ -108,6 +135,22 @@ export const X_CAPABILITIES = [
108
135
  // packages/twin/xidentity; this pack CONSUMES the user access token that flow mints) ─────────
109
136
  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'),
110
137
  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'),
138
+ done('x.media.multipart_unnamed_part', 'media', 'A multipart APPEND whose media part names no filename (twitter-api-v2\'s uploadMedia, what Postiz sends) keeps its bytes exactly: the image it finalizes is the one uploaded', 'api', 'common', () => withWorld(async (h, root) => {
139
+ await mediaToken(h);
140
+ const fetchTwin = createXTwinFetch({ root });
141
+ const png = tinyPng();
142
+ const init = await h({ m: 'POST', p: '/2/media/upload/initialize', b: { media_type: 'image/png', total_bytes: png.length, media_category: 'tweet_image' }, headers: MEDIA_AUTH });
143
+ const id = String(init.body.data?.id ?? '');
144
+ const boundary = '----twinverify';
145
+ const part = (head, bytes) => concat(new TextEncoder().encode(`--${boundary}\r\n${head}\r\n\r\n`), bytes, new TextEncoder().encode('\r\n'));
146
+ const body = concat(new TextEncoder().encode(`a preamble, which is not a part --${boundary} either\r\n`), part('Content-Disposition: form-data; name="segment_index"', new TextEncoder().encode('0')), part('Content-Disposition: form-data; name="media"\r\nContent-Type: application/octet-stream', png), new TextEncoder().encode(`--${boundary}--\r\n`));
147
+ const append = await fetchTwin(new Request(`http://127.0.0.1:9/2/media/upload/${id}/append`, { method: 'POST', headers: { ...MEDIA_AUTH, 'content-type': `multipart/form-data; boundary=${boundary}` }, body: Buffer.from(body) }));
148
+ const fin = await h({ m: 'POST', p: `/2/media/upload/${id}/finalize`, headers: MEDIA_AUTH });
149
+ const posted = await h({ m: 'POST', p: '/2/tweets', b: { text: 'with the image', media: { media_ids: [id] } }, headers: MEDIA_AUTH });
150
+ const read = await h({ m: 'GET', p: `/2/tweets/${String(posted.body.data?.id)}?expansions=attachments.media_keys&media.fields=width,height,url`, headers: MEDIA_AUTH });
151
+ const media = read.body.includes?.media?.[0];
152
+ return append.status === 200 && fin.status === 200 && posted.status === 201 && media?.width === 64 && media?.height === 48;
153
+ })),
111
154
  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', () => withWorld(async (h) => {
112
155
  await mediaToken(h);
113
156
  // an image in one request, base64 in a JSON body: its size and dimensions read from its bytes
@@ -183,6 +226,35 @@ export const X_CAPABILITIES = [
183
226
  && body.title === 'Invalid Request'
184
227
  && Array.isArray(body.errors) && body.errors[0].parameters.text !== undefined;
185
228
  })),
229
+ done('x.tweets.disclosure_labels_false', 'tweets', 'made_with_ai / paid_partnership sent as false: an unlabelled post or reply, as Postiz sends every one', 'api', 'common', () => withWorld(async (h) => {
230
+ const labels = { made_with_ai: false, paid_partnership: false };
231
+ const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'scheduled from postiz', ...labels } });
232
+ const id = created.body.data?.id;
233
+ if (created.status !== 201 || typeof id !== 'string')
234
+ return false;
235
+ const replied = await h({ m: 'POST', p: '/2/tweets', b: { text: 'and a thread', reply: { in_reply_to_tweet_id: id }, ...labels } });
236
+ const replyId = replied.body.data?.id;
237
+ if (replied.status !== 201)
238
+ return false;
239
+ // both landed as STATE: the reply joined the post's conversation, read back through the twin
240
+ const read = (await h({ m: 'GET', p: `/2/tweets/${replyId}?${THREAD_FIELDS}` })).body;
241
+ if (read.data?.conversation_id !== id || read.data?.referenced_tweets?.[0]?.id !== id)
242
+ return false;
243
+ // a label that says something is still refused by name, and nothing lands; a non-boolean is invalid
244
+ const before = (await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body.meta?.result_count;
245
+ const labelled = await h({ m: 'POST', p: '/2/tweets', b: { text: 'an ad', paid_partnership: true } });
246
+ const stringly = await h({ m: 'POST', p: '/2/tweets', b: { text: 'ai?', made_with_ai: 'false' } });
247
+ const after = (await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body.meta?.result_count;
248
+ // `true` is refused as unmodelled surface; a string is not a boolean at all — two different refusals
249
+ const labelledError = labelled.body.errors?.[0];
250
+ const stringlyError = stringly.body.errors?.[0];
251
+ return labelled.status === 400 && labelledError?.parameters?.paid_partnership?.[0] === 'true'
252
+ && /does not model/.test(String(labelledError?.message)) && !/must be a boolean/.test(String(labelledError?.message))
253
+ && stringly.status === 400 && stringlyError?.parameters?.made_with_ai?.[0] === '"false"'
254
+ && /must be a boolean/.test(String(stringlyError?.message)) && !/does not model/.test(String(stringlyError?.message))
255
+ && before === 2 && after === 2;
256
+ })),
257
+ todo('x.tweets.disclosure_labels', 'tweets', 'made_with_ai / paid_partnership set true: the AI-generated-media and paid-partnership labels stored and shown on the post', 'api', 'common'),
186
258
  done('x.tweets.ids_are_snowflake_shaped_and_unique', 'tweets', 'Minted post ids are numeric snowflakes, never a row count', 'api', 'core', () => withWorld(async (h) => {
187
259
  const ids = [];
188
260
  for (const text of ['one', 'two', 'three']) {
@@ -381,6 +453,21 @@ export const X_CAPABILITIES = [
381
453
  && unmodelled.status === 400 && unmodelled.body.errors?.[0]?.parameters?.['user.fields']?.[0] === 'public_metrics'
382
454
  && unknown.status === 400;
383
455
  })),
456
+ // Postiz asks it on every @-mention lookup (x.provider.ts `mention`: userByUsername with
457
+ // user.fields username,name,profile_image_url; its connect-time v2.me is xidentity's); refused, the
458
+ // mention box showed no suggestions (cookbook/postiz finding 14).
459
+ done('x.fields.user_profile_image_url', 'fields', "user.fields=profile_image_url: the photo the account set, or X's default avatar for one that never set a photo — every X account has one", 'api', 'common', () => withWorld(async (h) => {
460
+ const photo = 'https://pbs.twimg.com/profile_images/1/pictured_normal.jpg';
461
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3100', username: 'pictured', name: 'Pictured', profile_image_url: photo }, headers: {} });
462
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3101', username: 'plain', name: 'Plain' }, headers: {} });
463
+ const own = (await h({ m: 'GET', p: '/2/users/by/username/pictured?user.fields=username,name,profile_image_url' })).body.data;
464
+ const plain = (await h({ m: 'GET', p: '/2/users/3101?user.fields=profile_image_url' })).body.data;
465
+ const bare = (await h({ m: 'GET', p: '/2/users/3100' })).body.data;
466
+ const bad = await h({ m: 'POST', p: '/_twin/accounts', b: { username: 'badphoto', profile_image_url: 'not a url' }, headers: {} });
467
+ return own?.profile_image_url === photo && own?.name === 'Pictured'
468
+ && plain?.profile_image_url === 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png'
469
+ && bare?.profile_image_url === undefined && bad.status === 400;
470
+ })),
384
471
  // ── user lookup ──────────────────────────────────────────────────────────────────────────────
385
472
  done('x.users.lookup_by_id', 'users', 'User lookup by id (GET /2/users/:id)', 'api', 'core', () => withWorld(async (h) => {
386
473
  const found = await h({ m: 'GET', p: `/2/users/${ORG}` });
@@ -1033,7 +1120,87 @@ export const X_CAPABILITIES = [
1033
1120
  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'),
1034
1121
  todo('x.errors.duplicate_content', 'errors', "X's duplicate-content refusal for posting the same text twice", 'api', 'common'),
1035
1122
  todo('x.auth.app_only_bearer', 'auth', 'App-only (OAuth 2.0 client-credentials) bearer for the public read endpoints', 'api', 'common'),
1036
- todo('x.auth.oauth1_user_context', 'auth', "OAuth 1.0a user-context signing, X's alternate auth for these endpoints", 'api', 'niche'),
1123
+ done('x.auth.oauth1_user_context', 'auth', "OAuth 1.0a User Context, X's alternate auth for these endpoints: a request signed with an access token xidentity issued posts and reads as that token's user", 'api', 'common', () => withWorld(async (h, root) => {
1124
+ await seedOAuth1(root);
1125
+ const posted = await h({ m: 'POST', p: '/2/tweets', b: { text: 'signed, not bearer' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`) } });
1126
+ const id = String(posted.body.data?.id ?? '');
1127
+ // a read with a query: the query is part of what was signed
1128
+ const q = `/2/tweets/${id}?tweet.fields=author_id`;
1129
+ const read = await h({ m: 'GET', p: q, headers: { authorization: o1Header('GET', `${API}${q}`) } });
1130
+ return posted.status === 201 && read.body.data?.author_id === ORG && read.body.data?.text === 'signed, not bearer';
1131
+ })),
1132
+ done('x.auth.oauth1_bad_signature_refuses', 'auth', 'An OAuth 1.0a signature made with the wrong consumer or token secret, or over another URL or query, is the about:blank 401 and writes nothing', 'api', 'core', () => withWorld(async (h, root) => {
1133
+ await seedOAuth1(root);
1134
+ const post = (authorization) => h({ m: 'POST', p: '/2/tweets', b: { text: 'forged' }, headers: { authorization } });
1135
+ const wrongConsumer = await post(o1Header('POST', `${API}/2/tweets`, { ...O1_APP, secret: 'wrong' }));
1136
+ const wrongToken = await post(o1Header('POST', `${API}/2/tweets`, O1_APP, { ...O1_TOKEN, secret: 'wrong' }));
1137
+ const otherUrl = await post(o1Header('POST', `${API}/2/tweets/other`));
1138
+ const q = `/2/users/by/username/orgvoice`;
1139
+ const otherQuery = await h({ m: 'GET', p: `${q}?user.fields=description`, headers: { authorization: o1Header('GET', `${API}${q}`) } });
1140
+ // a repeated key (X accepts none) and a query realm (signed like any parameter) do not ride on a valid signature
1141
+ const signedQ = o1Header('GET', `${API}${q}?user.fields=description`);
1142
+ const plainQ = await h({ m: 'GET', p: `${q}?user.fields=description`, headers: { authorization: signedQ } });
1143
+ const dup = `${q}?user.fields=description&user.fields=description`;
1144
+ const repeated = await h({ m: 'GET', p: dup, headers: { authorization: o1Header('GET', `${API}${dup}`) } });
1145
+ const realm = await h({ m: 'GET', p: `${q}?user.fields=description&realm=x`, headers: { authorization: signedQ } });
1146
+ const timeline = await h({ m: 'GET', p: `/2/users/${ORG}/tweets` });
1147
+ return plainQ.status === 200 && [wrongConsumer, wrongToken, otherUrl, otherQuery, repeated, realm].every((r) => r.status === 401 && r.body.type === 'about:blank')
1148
+ && timeline.body.meta?.result_count === 0;
1149
+ })),
1150
+ done('x.auth.oauth1_unissued_or_revoked_refuses', 'auth', 'An access token xidentity never issued, has revoked, or issued to another App is refused (401)', 'api', 'core', () => withWorld(async (h, root) => {
1151
+ await seedOAuth1(root);
1152
+ const revoked = { key: `${ORG}-RevokedAccessToken000000000000000000000000`, secret: 'RevokedSecret' };
1153
+ await seedOAuth1(root, revoked, { revoked: true });
1154
+ const other = { key: `${ORG}-OtherAppsToken00000000000000000000000000000`, secret: 'OtherSecret' };
1155
+ await seedOAuth1(root, other, { consumerKey: 'SomeOtherApp' });
1156
+ const post = (token) => h({ m: 'POST', p: '/2/tweets', b: { text: 'x' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`, O1_APP, token) } });
1157
+ const good = await post(O1_TOKEN);
1158
+ return good.status === 201 && (await post({ key: `${ORG}-NeverIssued`, secret: O1_TOKEN.secret })).status === 401
1159
+ && (await post(revoked)).status === 401 && (await post(other)).status === 401;
1160
+ })),
1161
+ done('x.auth.oauth1_read_only_permission', 'auth', 'A token from a Read-only App (or x_auth_access_type=read) reads but cannot post or upload: the 403 oauth1-permissions problem', 'api', 'common', () => withWorld(async (h, root) => {
1162
+ await seedOAuth1(root, O1_TOKEN, { accessLevel: 'read' });
1163
+ const q = '/2/users/by/username/orgvoice';
1164
+ const read = await h({ m: 'GET', p: q, headers: { authorization: o1Header('GET', `${API}${q}`) } });
1165
+ const post = await h({ m: 'POST', p: '/2/tweets', b: { text: 'read-only' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`) } });
1166
+ const upload = await h({ m: 'POST', p: '/2/media/upload/initialize', b: { media_type: 'video/mp4', total_bytes: 10, media_category: 'tweet_video' }, headers: { authorization: o1Header('POST', `${API}/2/media/upload/initialize`) } });
1167
+ const isPermissions = (r) => r.status === 403 && r.body.type === 'https://api.twitter.com/2/problems/oauth1-permissions';
1168
+ return read.status === 200 && read.body.data?.id === ORG && isPermissions(post) && isPermissions(upload);
1169
+ })),
1170
+ done('x.auth.oauth1_signed_host', 'auth', 'The signature covers the URL the client signed: https://api.x.com when the request names X\'s host (the injector keeps it), the twin\'s own origin when reached directly', 'api', 'common', () => withWorld(async (_h, root) => {
1171
+ await seedOAuth1(root);
1172
+ const fetchTwin = createXTwinFetch({ root });
1173
+ const q = '/2/users/by/username/orgvoice';
1174
+ const named = await fetchTwin(new Request(`http://127.0.0.1:9${q}`, { headers: { host: 'api.x.com', authorization: o1Header('GET', `https://api.x.com${q}`) } }));
1175
+ const direct = await fetchTwin(new Request(`http://127.0.0.1:9${q}`, { headers: { authorization: o1Header('GET', `http://127.0.0.1:9${q}`) } }));
1176
+ const mismatch = await fetchTwin(new Request(`http://127.0.0.1:9${q}`, { headers: { authorization: o1Header('GET', `https://api.x.com${q}`) } }));
1177
+ return named.status === 200 && direct.status === 200 && mismatch.status === 401;
1178
+ })),
1179
+ done('x.auth.oauth1_world_app', 'auth', "The World's own App (X_API_KEY / X_API_SECRET the World sets) verifies a token issued to it; the same values from the caller's shell alone do not", 'api', 'common', () => withWorld(async (h, root) => {
1180
+ const names = ['X_API_KEY', 'X_API_SECRET', 'VOLTER_WORLD_ENV_NAMES'];
1181
+ const saved = Object.fromEntries(names.map((n) => [n, process.env[n]]));
1182
+ const app = { key: 'WorldConsumerKey000000001', secret: 'WorldConsumerSecret0000000000000000000000000001' };
1183
+ await applyTwinWrite('xidentity', { operation: 'oauth1_token.create', subjectType: 'oauth1_token', subjectId: O1_TOKEN.key, fields: { consumerKey: app.key, secret: O1_TOKEN.secret, userId: ORG, screenName: 'orgvoice', accessLevel: 'read-write', revoked: false }, occurredAt: '2026-02-01T00:00:00.000Z' }, root);
1184
+ const post = () => h({ m: 'POST', p: '/2/tweets', b: { text: 'from the World app' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`, app) } });
1185
+ try {
1186
+ Object.assign(process.env, { X_API_KEY: app.key, X_API_SECRET: app.secret });
1187
+ delete process.env.VOLTER_WORLD_ENV_NAMES;
1188
+ const shellOnly = await post();
1189
+ process.env.VOLTER_WORLD_ENV_NAMES = JSON.stringify(['X_API_KEY', 'X_API_SECRET']);
1190
+ const world = await post();
1191
+ return shellOnly.status === 401 && world.status === 201;
1192
+ }
1193
+ finally {
1194
+ for (const [n, v] of Object.entries(saved)) {
1195
+ if (v === undefined)
1196
+ delete process.env[n];
1197
+ else
1198
+ process.env[n] = v;
1199
+ }
1200
+ }
1201
+ })),
1202
+ todo('x.auth.oauth1_permissions_wording', 'auth', 'PIN the live 403 for a Read-only OAuth 1.0a token on a write endpoint (the twin serves the widely-reported oauth1-permissions problem)', 'api', 'niche'),
1203
+ todo('x.auth.oauth1_timestamp_window', 'auth', "Refuse an OAuth 1.0a oauth_timestamp outside X's window and a replayed nonce", 'api', 'niche'),
1037
1204
  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'),
1038
1205
  // ══ MIRROR (dimension: 'ui' — every one is DATA-COUPLED: seed through the handler, read the
1039
1206
  // SAME route the screen reads, render the mirror's OWN component over it) ══════════════════════
@@ -59,3 +59,15 @@ export declare function createXMirrorServer(options: {
59
59
  export declare function xMirrorHtml(): string;
60
60
  /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
61
61
  export declare function xMirrorStyles(): Promise<string>;
62
+ /** The account `as` names (a username, with or without its @) and a fresh token minted for it through the twin's own
63
+ * `/_twin/tokens` door, as the World's seed issues one: what X's screens sign in with when the World's config says
64
+ * `signIn: { as }` (the World hands them over, served-world.ts). A stored token (the app's own, perhaps) is never read
65
+ * back; this one carries only what the screens do: read posts and people, and post. Null when the World holds no such
66
+ * account, or the door refuses the token. */
67
+ export declare function xMirrorSignIn(as: string, ctx: {
68
+ root: string;
69
+ twin: (path: string, init?: RequestInit) => Promise<Response>;
70
+ }): Promise<{
71
+ account: string;
72
+ token: string;
73
+ } | null>;
@@ -14,11 +14,11 @@
14
14
  // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's
15
15
  // own fetch adapter as its API backend and reads every byte of state back over the wire.
16
16
  import { readFile } from 'node:fs/promises';
17
- import { bundleClient, fileResponse } from '@volter/world-core';
17
+ import { twinResources, bundleClient, fileResponse, filePathOf } from '@volter/world-core';
18
18
  import { serveHttp } from '@volter/world-core';
19
19
  import { createXTwinFetch } from "./x-server.js";
20
- const CLIENT_ENTRY = () => new URL('../client/x-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
21
- const CLIENT_CSS = () => new URL('../client/x-mirror.css', import.meta.url).pathname; // lazy: same reason
20
+ const CLIENT_ENTRY = () => filePathOf(new URL('../client/x-mirror.tsx', import.meta.url)); // lazy: workerd rejects a top-level relative import.meta.url
21
+ const CLIENT_CSS = () => filePathOf(new URL('../client/x-mirror.css', import.meta.url)); // lazy: same reason
22
22
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
23
23
  const LONG_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
24
24
  /** A wall-clock reading of an instant in a time zone. x.com shows the VIEWER'S local time, so the
@@ -251,3 +251,20 @@ export function xMirrorHtml() {
251
251
  export function xMirrorStyles() {
252
252
  return readFile(CLIENT_CSS(), 'utf8');
253
253
  }
254
+ /** The account `as` names (a username, with or without its @) and a fresh token minted for it through the twin's own
255
+ * `/_twin/tokens` door, as the World's seed issues one: what X's screens sign in with when the World's config says
256
+ * `signIn: { as }` (the World hands them over, served-world.ts). A stored token (the app's own, perhaps) is never read
257
+ * back; this one carries only what the screens do: read posts and people, and post. Null when the World holds no such
258
+ * account, or the door refuses the token. */
259
+ export async function xMirrorSignIn(as, ctx) {
260
+ const handle = as.replace(/^@/, '').toLowerCase();
261
+ const account = twinResources('x', ctx.root).find((r) => r.type === 'account' && String(r.username ?? '').toLowerCase() === handle);
262
+ if (!account)
263
+ return null;
264
+ const token = `volter-view-${crypto.randomUUID()}`;
265
+ const minted = await ctx.twin('/_twin/tokens', {
266
+ method: 'POST', headers: { 'content-type': 'application/json' },
267
+ body: JSON.stringify({ token, account_id: account.id, scopes: ['tweet.read', 'users.read', 'tweet.write'] }),
268
+ });
269
+ return minted.ok ? { account: String(account.username), token } : null;
270
+ }
@@ -0,0 +1,24 @@
1
+ export type OAuth1User = {
2
+ userId: string;
3
+ token: string;
4
+ accessLevel: 'read' | 'read-write';
5
+ };
6
+ export declare const isOAuth1Header: (value: string | undefined) => boolean;
7
+ /**
8
+ * The user a request signed with OAuth 1.0a acts for, or null: an unparseable header, a method
9
+ * other than HMAC-SHA1, a token xidentity never issued or has revoked, a token presented by another
10
+ * App, or a signature that does not verify over (method, the signed URL, query + oauth params) with
11
+ * `consumer_secret&token_secret`. A JSON or multipart body is not signed (the guide; twitter-api-v2
12
+ * signs a body only when it is form-encoded, and this surface has no form-encoded route).
13
+ */
14
+ export declare function verifyOAuth1(req: {
15
+ method: string;
16
+ path: string;
17
+ headers: Record<string, string>;
18
+ root?: string;
19
+ publicBase?: string;
20
+ }): OAuth1User | null;
21
+ /** OAuth 1.0a has no scopes: the App's permission level decides (docs.x.com developer-apps, "App
22
+ * permissions (OAuth 1.0a)" — Read only: view; Read and write: also post and delete). As the OAuth
23
+ * 2.0 scopes those levels stand for on the routes this pack gates. */
24
+ export declare function scopesOfAccessLevel(level: OAuth1User['accessLevel']): string[];
@@ -0,0 +1,145 @@
1
+ // OAuth 1.0a User Context on the posting surface — X's alternate auth for these endpoints (every
2
+ // operation this pack serves lists `UserToken` beside `OAuth2UserToken` in X's v2 OpenAPI), and the
3
+ // one Postiz posts with (gitroomhq/postiz-app@374fb202334a x.provider.ts: twitter-api-v2 with
4
+ // appKey/appSecret/accessToken/accessSecret, and its own `signOAuth1` for POST /2/tweets).
5
+ //
6
+ // ONE VENDOR, TWO PACKS. X has one credential store and `xidentity` is it: its three legs
7
+ // (xidentity-oauth1.ts) mint the access token a person approved. This pack READS that store through
8
+ // the kernel projection — `projectOwnerResources('xidentity', root)`, never a write — for exactly the two
9
+ // row types that file names as the contract:
10
+ // oauth1_app id=consumer key { secret, name, callbackUrls, accessLevel: 'read'|'read-write', deleted? }
11
+ // oauth1_token id=access token { consumerKey, secret, userId, screenName, accessLevel, revoked }
12
+ // plus the World's own App from the env (worldOAuth1App there, transcribed below). A3 forbids the
13
+ // import, so the RFC 5849 verifier is TRANSCRIBED from xidentity-oauth1.ts, as x-problems.ts and
14
+ // x-scopes.ts transcribe theirs; the twitter-api-v2 test that spans both packs
15
+ // (x-oauth1.integration.test.ts) goes red if the two drift apart.
16
+ //
17
+ // The token acts for its `userId`. That id is the X account in this pack's state too: a World that
18
+ // posts as a person seeds the same account (id and handle) in both packs, as it seeds the one a
19
+ // bearer from `/_twin/tokens` names.
20
+ import { createHmac } from 'node:crypto';
21
+ import { OwnerStoreAmbiguousError, projectOwnerResources, worldEnvValue } from '@volter/world-core';
22
+ /** Constant-time equality of two same-length byte strings. Not node:crypto's timingSafeEqual: the
23
+ * mirror's browser client bundles this module's chain, and Bun's browser polyfill has none. */
24
+ function sameBytes(a, b) {
25
+ let difference = 0;
26
+ for (let i = 0; i < a.length; i++)
27
+ difference |= a[i] ^ b[i];
28
+ return difference === 0;
29
+ }
30
+ // ── RFC 5849, transcribed from xidentity-oauth1.ts (docs.x.com creating-a-signature) ─────────────
31
+ const pct = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
32
+ function parseOAuthHeader(value) {
33
+ if (!value)
34
+ return null;
35
+ const m = /^OAuth\s+(.*)$/is.exec(value.trim());
36
+ if (!m)
37
+ return null;
38
+ const out = [];
39
+ for (const part of m[1].split(',')) {
40
+ const kv = /^\s*([^=\s]+)\s*=\s*"([^"]*)"\s*$/.exec(part);
41
+ if (!kv) {
42
+ if (part.trim() === '')
43
+ continue;
44
+ return null;
45
+ }
46
+ try {
47
+ out.push([decodeURIComponent(kv[1]), decodeURIComponent(kv[2])]);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ function baseString(method, baseUrl, pairs) {
56
+ const encoded = pairs.map(([k, v]) => [pct(k), pct(v)]).sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
57
+ return `${method.toUpperCase()}&${pct(baseUrl)}&${pct(encoded.map(([k, v]) => `${k}=${v}`).join('&'))}`;
58
+ }
59
+ const X_HOSTS = new Set(['api.x.com', 'api.twitter.com']);
60
+ /** The URL the client signed: X's host when the request still names it (the injector keeps Host,
61
+ * or names it in `x-volter-twin-original-host`), else where it was sent (`publicBase`), else — a
62
+ * direct in-process call — X's own API origin. */
63
+ function signedBaseUrl(headers, publicBase, path) {
64
+ const named = (headers['x-volter-twin-original-host'] ?? headers['host'] ?? '').toLowerCase().replace(/:\d+$/, '');
65
+ if (X_HOSTS.has(named))
66
+ return `https://${named}${path}`;
67
+ if (!publicBase)
68
+ return `https://api.x.com${path}`;
69
+ let u;
70
+ try {
71
+ u = new URL(publicBase);
72
+ }
73
+ catch {
74
+ return `https://api.x.com${path}`;
75
+ }
76
+ const port = u.port && !((u.protocol === 'https:' && u.port === '443') || (u.protocol === 'http:' && u.port === '80')) ? `:${u.port}` : '';
77
+ return `${u.protocol}//${u.hostname.toLowerCase()}${port}${u.pathname.replace(/\/+$/, '')}${path}`;
78
+ }
79
+ // ── the World's own App (transcribed from xidentity-oauth1.ts worldOAuth1App) ────────────────────
80
+ const WORLD_APP_ENV = [['X_API_KEY', 'X_API_SECRET'], ['TWITTER_API_KEY', 'TWITTER_API_SECRET']];
81
+ function consumerSecretOf(identity, consumerKey) {
82
+ const row = identity.find((r) => r.type === 'oauth1_app' && String(r.id) === consumerKey);
83
+ if (row && row.deleted !== true)
84
+ return String(row.secret);
85
+ for (const [keyName, secretName] of WORLD_APP_ENV) {
86
+ const key = worldEnvValue(keyName);
87
+ const secret = worldEnvValue(secretName);
88
+ if (key && secret)
89
+ return key === consumerKey ? secret : undefined;
90
+ }
91
+ return undefined;
92
+ }
93
+ export const isOAuth1Header = (value) => /^OAuth\s/i.test((value ?? '').trim());
94
+ /**
95
+ * The user a request signed with OAuth 1.0a acts for, or null: an unparseable header, a method
96
+ * other than HMAC-SHA1, a token xidentity never issued or has revoked, a token presented by another
97
+ * App, or a signature that does not verify over (method, the signed URL, query + oauth params) with
98
+ * `consumer_secret&token_secret`. A JSON or multipart body is not signed (the guide; twitter-api-v2
99
+ * signs a body only when it is form-encoded, and this surface has no form-encoded route).
100
+ */
101
+ export function verifyOAuth1(req) {
102
+ const header = parseOAuthHeader(req.headers['authorization'] ?? Object.entries(req.headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1]);
103
+ if (!header)
104
+ return null;
105
+ const oauth = new Map(header);
106
+ if (oauth.get('oauth_signature_method') !== 'HMAC-SHA1' || (oauth.has('oauth_version') && oauth.get('oauth_version') !== '1.0'))
107
+ return null;
108
+ for (const k of ['oauth_consumer_key', 'oauth_nonce', 'oauth_signature', 'oauth_timestamp', 'oauth_token'])
109
+ if (!oauth.get(k))
110
+ return null;
111
+ let identity;
112
+ try {
113
+ // the store that issued this token (a World running xidentity twice: the one holding it)
114
+ identity = projectOwnerResources('xidentity', req.root, { type: 'oauth1_token', id: String(oauth.get('oauth_token')) });
115
+ }
116
+ catch (error) {
117
+ if (error instanceof OwnerStoreAmbiguousError)
118
+ return null; // two stores issued it: not a token this World can resolve
119
+ throw error;
120
+ }
121
+ const token = identity.find((r) => r.type === 'oauth1_token' && String(r.id) === oauth.get('oauth_token'));
122
+ if (!token || token.revoked === true || token.consumerKey !== oauth.get('oauth_consumer_key'))
123
+ return null;
124
+ const consumerSecret = consumerSecretOf(identity, String(token.consumerKey));
125
+ if (consumerSecret === undefined)
126
+ return null;
127
+ const [p, q = ''] = req.path.split('?');
128
+ const query = [...new URLSearchParams(q).entries()];
129
+ // X accepts no duplicate keys (creating-a-signature footnote 2); only the header's realm is unsigned (RFC 5849 §3.4.1.3.1)
130
+ if (new Set(query.map(([k]) => k)).size !== query.length)
131
+ return null;
132
+ const pairs = [...header.filter(([k]) => k !== 'realm'), ...query].filter(([k]) => k !== 'oauth_signature');
133
+ const base = baseString(req.method, signedBaseUrl(req.headers, req.publicBase, p ?? '/'), pairs);
134
+ const expected = Buffer.from(createHmac('sha1', `${pct(consumerSecret)}&${pct(String(token.secret))}`).update(base).digest('base64'));
135
+ const given = Buffer.from(oauth.get('oauth_signature'));
136
+ if (expected.length !== given.length || !sameBytes(expected, given))
137
+ return null;
138
+ return { userId: String(token.userId), token: String(token.id), accessLevel: token.accessLevel === 'read' ? 'read' : 'read-write' };
139
+ }
140
+ /** OAuth 1.0a has no scopes: the App's permission level decides (docs.x.com developer-apps, "App
141
+ * permissions (OAuth 1.0a)" — Read only: view; Read and write: also post and delete). As the OAuth
142
+ * 2.0 scopes those levels stand for on the routes this pack gates. */
143
+ export function scopesOfAccessLevel(level) {
144
+ return level === 'read' ? ['tweet.read', 'users.read'] : ['tweet.read', 'users.read', 'tweet.write', 'media.write'];
145
+ }
@@ -7,6 +7,13 @@ export type XResponse = {
7
7
  export declare function unauthorizedProblem(): XResponse;
8
8
  /** A user token whose scope set does not cover the endpoint. Wording: `x.errors.scope_wording`. */
9
9
  export declare function forbiddenProblem(detail: string): XResponse;
10
+ /**
11
+ * An OAuth 1.0a token whose App permission does not reach the endpoint (a Read-only token posting).
12
+ * X's v2 answers this with its own problem type rather than the scope wording: the type and detail
13
+ * are the widely-reported wire strings, not a fetched page — EXTRAPOLATION, pinned by
14
+ * `x.auth.oauth1_permissions_wording`.
15
+ */
16
+ export declare function oauth1PermissionsProblem(): XResponse;
10
17
  /** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
11
18
  export declare function notFoundProblem(): XResponse;
12
19
  /**
@@ -45,6 +45,24 @@ export function forbiddenProblem(detail) {
45
45
  headers: { ...PROBLEM },
46
46
  };
47
47
  }
48
+ /**
49
+ * An OAuth 1.0a token whose App permission does not reach the endpoint (a Read-only token posting).
50
+ * X's v2 answers this with its own problem type rather than the scope wording: the type and detail
51
+ * are the widely-reported wire strings, not a fetched page — EXTRAPOLATION, pinned by
52
+ * `x.auth.oauth1_permissions_wording`.
53
+ */
54
+ export function oauth1PermissionsProblem() {
55
+ return {
56
+ status: 403,
57
+ body: {
58
+ title: 'Forbidden',
59
+ type: 'https://api.twitter.com/2/problems/oauth1-permissions',
60
+ status: 403,
61
+ detail: 'Your client app is not configured with the appropriate oauth1 app permissions for this endpoint.',
62
+ },
63
+ headers: { ...PROBLEM },
64
+ };
65
+ }
48
66
  /** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
49
67
  export function notFoundProblem() {
50
68
  return {
@@ -54,6 +54,54 @@ function bytesResponse(request, bytes, contentType) {
54
54
  headers: { ...base, 'content-length': String(body.length), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
55
55
  });
56
56
  }
57
+ /**
58
+ * An RFC 7578 multipart body, read as BYTES. The `media` part is binary whether or not its
59
+ * disposition names a filename: twitter-api-v2@1.29.1's FormDataHelper — what Postiz's
60
+ * `client.v2.uploadMedia` sends each APPEND with — writes `Content-Disposition: form-data;
61
+ * name="media"` and `Content-Type: application/octet-stream` with NO filename, and a WHATWG
62
+ * `formData()` parse turns such a part into a UTF-8-decoded string, corrupting the bytes. Every other
63
+ * part is a text field. Null when the body is not the multipart its content type declares.
64
+ */
65
+ function parseMultipart(body, contentType) {
66
+ const b = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
67
+ if (!b)
68
+ return null;
69
+ const boundary = b[1] ?? b[2];
70
+ const buf = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
71
+ // A delimiter is CRLF "--boundary" (RFC 2046 §5.1.1); the first may open the body with no CRLF
72
+ // before it, anything ahead of it is preamble. A boundary string not at a line start is content.
73
+ const delimiter = Buffer.from(`\r\n--${boundary}`);
74
+ const first = Buffer.from(`--${boundary}`);
75
+ let pos = buf.subarray(0, first.length).equals(first) ? first.length : (() => { const i = buf.indexOf(delimiter); return i < 0 ? -1 : i + delimiter.length; })();
76
+ if (pos < 0)
77
+ return null;
78
+ const fields = {};
79
+ let media;
80
+ for (;;) {
81
+ if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d)
82
+ break; // the closing delimiter
83
+ while (buf[pos] === 0x20 || buf[pos] === 0x09)
84
+ pos += 1; // transport padding
85
+ if (buf[pos] !== 0x0d || buf[pos + 1] !== 0x0a)
86
+ return null;
87
+ const headEnd = buf.indexOf('\r\n\r\n', pos + 2);
88
+ if (headEnd < 0)
89
+ return null;
90
+ const head = buf.subarray(pos + 2, headEnd).toString('utf8');
91
+ const next = buf.indexOf(delimiter, headEnd + 2);
92
+ if (next < 0)
93
+ return null;
94
+ const content = buf.subarray(Math.min(headEnd + 4, next), next);
95
+ const disposition = head.split('\r\n').find((line) => /^content-disposition\s*:/i.test(line)) ?? '';
96
+ const name = /;\s*name="([^"]*)"/i.exec(disposition)?.[1];
97
+ if (name === 'media')
98
+ media = new Uint8Array(content);
99
+ else if (name !== undefined)
100
+ fields[name] = content.toString('utf8');
101
+ pos = next + delimiter.length;
102
+ }
103
+ return { fields, ...(media ? { media } : {}) };
104
+ }
57
105
  export function createXTwinFetch(options = {}) {
58
106
  const adapted = createTwinFetchFromHandler(handleXTwinRequest, {
59
107
  ...options,
@@ -83,21 +131,11 @@ export function createXTwinFetch(options = {}) {
83
131
  }
84
132
  const type = request.headers.get('content-type') ?? '';
85
133
  if (request.method === 'POST' && MULTIPART_UPLOAD_PATH.test(url.pathname) && /^multipart\/form-data/i.test(type)) {
86
- let form;
87
- try {
88
- form = await request.formData();
89
- }
90
- catch {
134
+ const parsed = parseMultipart(new Uint8Array(await request.arrayBuffer()), type);
135
+ if (!parsed) {
91
136
  return Response.json({ errors: [{ parameters: { body: ['(unparseable)'] }, message: 'The multipart body could not be parsed.' }], title: 'Invalid Request', detail: 'One or more parameters to your request was invalid.', type: 'https://api.x.com/2/problems/invalid-request' }, { status: 400 });
92
137
  }
93
- const fields = {};
94
- let media;
95
- for (const [name, value] of form.entries()) {
96
- if (typeof value === 'string')
97
- fields[name] = value;
98
- else if (name === 'media' && value instanceof Blob)
99
- media = new Uint8Array(await value.arrayBuffer());
100
- }
138
+ const { fields, media } = parsed;
101
139
  const headers = {};
102
140
  request.headers.forEach((value, key) => { headers[key] = value; });
103
141
  const invoke = () => handleXTwinRequest({
@@ -18,4 +18,8 @@ export type XRequest = {
18
18
  * is minted from it; a direct handler call with none mints X's own pbs.twimg.com form. */
19
19
  publicBase?: string;
20
20
  };
21
+ /** The picture X shows for an account that never uploaded one: every X account has a
22
+ * `profile_image_url`, and this is its value until the person sets a photo. The twin serves the
23
+ * URL, not the bytes (it claims no abs.twimg.com), as it serves no profile photos at all. */
24
+ export declare const X_DEFAULT_PROFILE_IMAGE_URL = "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png";
21
25
  export declare function handleXTwinRequest(request: XRequest): Promise<XResponse>;