@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.
@@ -35,16 +35,18 @@
35
35
  // default projection is the vendor's three fields, and `projectPost` is the ONE place a stored
36
36
  // post becomes a response body.
37
37
  //
38
- // AUTH. OAuth 2.0 is xidentity's and stays there. This pack CONSUMES the user access token that
39
- // flow mints: a bearer, opaque, scope-carrying. `/_twin/tokens` registers one locally, which is
40
- // what a world does instead of running the authorize leg for every test.
38
+ // AUTH. OAuth is xidentity's and stays there. This pack CONSUMES what it mints: an OAuth 2.0 user
39
+ // bearer — opaque, scope-carrying; `/_twin/tokens` registers one locally, which is what a world
40
+ // does instead of running the authorize leg for every test — or an OAuth 1.0a User Context
41
+ // signature over an access token xidentity's three legs issued (x-oauth1.ts).
41
42
  //
42
43
  // DETERMINISM. Every served response is a pure function of (request, stored state). Nothing on
43
44
  // the read path reads a clock: `created_at` is what the WRITE stored, and the `next_token` a page
44
45
  // hands back is derived from the id of the last row on that page, never from entropy or time.
45
- import { applyTwinWrite, projectResources } from '@volter/world-core';
46
- import { forbiddenProblem, invalidRequestProblem, lookupResult, notFoundProblem, rateLimitExceeded, readOnlyRefusal, resourceNotFoundError, resourceNotFoundProblem, unauthorizedProblem, } from "./x-problems.js";
46
+ import { applyTwinWrite, projectResources, worldNow } from '@volter/world-core';
47
+ import { forbiddenProblem, invalidRequestProblem, lookupResult, notFoundProblem, oauth1PermissionsProblem, rateLimitExceeded, readOnlyRefusal, resourceNotFoundError, resourceNotFoundProblem, unauthorizedProblem, } from "./x-problems.js";
47
48
  import { missingScopes, parseScopeList, REQUIRED_SCOPES } from "./x-scopes.js";
49
+ import { isOAuth1Header, scopesOfAccessLevel, verifyOAuth1 } from "./x-oauth1.js";
48
50
  import { clearSegments, listMediaIds, MAX_IMAGE_BYTES, MAX_TWEET_VIDEO_MS, MAX_VIDEO_BYTES, MEDIA_EXPIRES_AFTER_SECS, mediaExtension, putSegment, putXBlob, readMediaRecord, readSegments, sniffImage, sniffVideo, writeMediaRecord, } from "./x-media.js";
49
51
  const SERVICE = 'x';
50
52
  /** X's post ids are snowflakes. A minted id must never collide with one PULLED from the real
@@ -112,7 +114,17 @@ function bearerFrom(headers) {
112
114
  }
113
115
  return undefined;
114
116
  }
115
- function authorize(all, headers) {
117
+ function authorize(all, request) {
118
+ const headers = request.headers;
119
+ const authorization = headers ? Object.entries(headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1] : undefined;
120
+ if (isOAuth1Header(authorization)) {
121
+ // X's alternate scheme for every route here: a request signed with an access token the
122
+ // person approved at xidentity's screen. Anything that does not verify is the same 401.
123
+ const user = verifyOAuth1({ method: request.method, path: request.path, headers: headers ?? {}, root: request.root, publicBase: request.publicBase });
124
+ if (!user)
125
+ return unauthorizedProblem();
126
+ return { accountId: user.userId, scopes: scopesOfAccessLevel(user.accessLevel), token: user.token, oauth1: true };
127
+ }
116
128
  const token = bearerFrom(headers);
117
129
  if (!token)
118
130
  return unauthorizedProblem();
@@ -126,6 +138,8 @@ function scopeRefusal(auth, operation) {
126
138
  const missing = missingScopes(auth.scopes, REQUIRED_SCOPES[operation]);
127
139
  if (missing.length === 0)
128
140
  return undefined;
141
+ if (auth.oauth1)
142
+ return oauth1PermissionsProblem();
129
143
  return forbiddenProblem(`This request requires the ${missing.join(', ')} scope(s), which this token does not hold.`);
130
144
  }
131
145
  // ── tweet.fields / expansions: the projection X actually serves ──────────────────────────────
@@ -196,8 +210,12 @@ const USER_FIELDS_DOCUMENTED = new Set([
196
210
  * which is a pure function of the account's live posts. `public_metrics` is NOT here: two of its
197
211
  * counts (listed, like) have no state behind them, and a zero the twin cannot know is a fabrication. */
198
212
  const USER_FIELDS_MODELLED = new Set([
199
- 'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'protected', 'url', 'username', 'verified',
213
+ 'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'profile_image_url', 'protected', 'url', 'username', 'verified',
200
214
  ]);
215
+ /** The picture X shows for an account that never uploaded one: every X account has a
216
+ * `profile_image_url`, and this is its value until the person sets a photo. The twin serves the
217
+ * URL, not the bytes (it claims no abs.twimg.com), as it serves no profile photos at all. */
218
+ export const X_DEFAULT_PROFILE_IMAGE_URL = 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png';
201
219
  /** Read `user.fields` off the query string, refusing the way `tweet.fields` refuses. */
202
220
  function parseUserFields(url) {
203
221
  const userFields = new Set();
@@ -328,6 +346,9 @@ function projectUser(account, userFields, all) {
328
346
  out.location = account.location;
329
347
  if (userFields.has('url'))
330
348
  out.url = typeof account.url === 'string' ? account.url : '';
349
+ if (userFields.has('profile_image_url')) {
350
+ out.profile_image_url = typeof account.profile_image_url === 'string' && account.profile_image_url !== '' ? account.profile_image_url : X_DEFAULT_PROFILE_IMAGE_URL;
351
+ }
331
352
  if (userFields.has('protected'))
332
353
  out.protected = account.protected === true;
333
354
  if (userFields.has('verified'))
@@ -614,6 +635,13 @@ const POST_FIELDS_UNMODELLED = new Set([
614
635
  'nullcast', 'paid_partnership', 'poll', 'reply_settings', 'share_with_followers',
615
636
  ]);
616
637
  const REPLY_FIELDS_UNMODELLED = new Set(['exclude_reply_user_ids', 'auto_populate_reply_metadata']);
638
+ /** The two disclosure labels, when they label nothing. `made_with_ai` ("this post contains AI-generated
639
+ * media") and `paid_partnership` ("this post is a paid partnership") are booleans whose `false` is the
640
+ * post every create here already makes, unlabelled; Postiz sends both on every post and reply, `false`
641
+ * unless the person ticked the box (x.provider.ts, `assetBoolean(...) || false`). `false` is read as the
642
+ * absent value it is; `true` — a label this twin does not store or show — stays refused by name, and
643
+ * so does a non-boolean. */
644
+ const POST_DISCLOSURE_FIELDS = new Set(['made_with_ai', 'paid_partnership']);
617
645
  /** A post id in a request body: a STRING matching ^[0-9]{1,19}$, as the schema types it. */
618
646
  function postIdField(value, parameter) {
619
647
  if (typeof value !== 'string' || !/^\d{1,19}$/.test(value)) {
@@ -625,6 +653,11 @@ async function createPost(request, all, auth, payload) {
625
653
  for (const key of Object.keys(payload)) {
626
654
  if (POST_FIELDS_MODELLED.has(key))
627
655
  continue;
656
+ if (POST_DISCLOSURE_FIELDS.has(key) && payload[key] === false)
657
+ continue;
658
+ if (POST_DISCLOSURE_FIELDS.has(key) && payload[key] !== true) {
659
+ return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field must be a boolean.`);
660
+ }
628
661
  if (POST_FIELDS_UNMODELLED.has(key))
629
662
  return unmodelledOption(key, JSON.stringify(payload[key]));
630
663
  return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field is not a parameter of this request.`);
@@ -698,7 +731,7 @@ async function createPost(request, all, auth, payload) {
698
731
  }
699
732
  // a post's id is the snowflake high-water mark + 1: timelines, since_id and pagination order by it
700
733
  const id = mintSnowflakeId(all, await listMediaIds(request.root));
701
- const createdAt = request.occurredAt ?? new Date().toISOString();
734
+ const createdAt = request.occurredAt ?? worldNow();
702
735
  await applyTwinWrite(SERVICE, {
703
736
  operation: isReply ? 'x.post.reply' : isQuote ? 'x.post.quote' : 'x.post.create',
704
737
  subjectType: 'post',
@@ -738,7 +771,7 @@ async function deletePost(request, all, auth, id) {
738
771
  subjectType: 'post',
739
772
  subjectId: id,
740
773
  fields: { ...Object.fromEntries(Object.entries(post).filter(([k]) => k !== 'type' && k !== 'id' && k !== 'updatedAt')), deleted: true },
741
- occurredAt: request.occurredAt ?? new Date().toISOString(),
774
+ occurredAt: request.occurredAt ?? worldNow(),
742
775
  actor: { kind: 'agent', id: auth.accountId },
743
776
  }, request.root);
744
777
  return ok({ data: { deleted: true } });
@@ -785,7 +818,7 @@ const INVALID_MEDIA_IDS = 'Your media IDs are invalid.';
785
818
  const CHECK_AFTER_SECS = 1;
786
819
  const isVideoCategory = (category) => VIDEO_CATEGORIES.has(category);
787
820
  function nowSeconds(request) {
788
- return Math.floor(Date.parse(request.occurredAt ?? new Date().toISOString()) / 1000);
821
+ return Math.floor(Date.parse(request.occurredAt ?? worldNow()) / 1000);
789
822
  }
790
823
  /** A field of the upload body, from the multipart form or the JSON object. */
791
824
  function uploadField(request, payload, name) {
@@ -952,7 +985,7 @@ async function uploadMedia(request, all, auth, payload) {
952
985
  const opened = {
953
986
  id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: 'application/octet-stream', state: 'initialized',
954
987
  ...(owners.length > 0 ? { additional_owners: owners } : {}),
955
- created_at: request.occurredAt ?? new Date().toISOString(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
988
+ created_at: request.occurredAt ?? worldNow(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
956
989
  };
957
990
  // Check before storing: a refused image leaves no bytes behind.
958
991
  const checked = finishUpload(opened, bytes, '');
@@ -997,7 +1030,7 @@ async function initializeUpload(request, all, auth, payload) {
997
1030
  const record = {
998
1031
  id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: mediaType, state: 'initialized', total_bytes: total,
999
1032
  ...(owners.length > 0 ? { additional_owners: owners } : {}),
1000
- created_at: request.occurredAt ?? new Date().toISOString(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
1033
+ created_at: request.occurredAt ?? worldNow(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
1001
1034
  };
1002
1035
  await writeMediaRecord(record, request.root);
1003
1036
  return ok({ data: { id, media_key: record.media_key, expires_after_secs: MEDIA_EXPIRES_AFTER_SECS } });
@@ -1334,6 +1367,13 @@ async function handleTwinControl(request, all, path, payload) {
1334
1367
  return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a string.`);
1335
1368
  profile[key] = payload[key];
1336
1369
  }
1370
+ // A photo the person uploaded: X serves it from pbs.twimg.com/profile_images/…, an https URL.
1371
+ if (payload.profile_image_url !== undefined) {
1372
+ if (typeof payload.profile_image_url !== 'string' || !/^https:\/\/\S+$/.test(payload.profile_image_url)) {
1373
+ return invalidRequestProblem({ profile_image_url: [String(payload.profile_image_url)] }, "A seeded account's `profile_image_url` must be an https URL.");
1374
+ }
1375
+ profile.profile_image_url = payload.profile_image_url;
1376
+ }
1337
1377
  for (const key of ['protected', 'verified']) {
1338
1378
  if (payload[key] === undefined)
1339
1379
  continue;
@@ -1341,7 +1381,7 @@ async function handleTwinControl(request, all, path, payload) {
1341
1381
  return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a boolean.`);
1342
1382
  profile[key] = payload[key];
1343
1383
  }
1344
- const createdAt = request.occurredAt ?? new Date().toISOString();
1384
+ const createdAt = request.occurredAt ?? worldNow();
1345
1385
  await applyTwinWrite(SERVICE, {
1346
1386
  operation: 'x.twin.seed_account',
1347
1387
  subjectType: 'account',
@@ -1365,7 +1405,7 @@ async function handleTwinControl(request, all, path, payload) {
1365
1405
  subjectType: 'token',
1366
1406
  subjectId: token,
1367
1407
  fields: { account_id: accountId, scopes },
1368
- occurredAt: request.occurredAt ?? new Date().toISOString(),
1408
+ occurredAt: request.occurredAt ?? worldNow(),
1369
1409
  }, request.root);
1370
1410
  return ok({ data: { token, account_id: accountId, scopes } });
1371
1411
  }
@@ -1378,7 +1418,7 @@ async function handleTwinControl(request, all, path, payload) {
1378
1418
  return invalidRequestProblem({ author_id: [String(authorId ?? '')] }, 'A seeded post must name an account seeded through /_twin/accounts.');
1379
1419
  }
1380
1420
  const id = mintSnowflakeId(all);
1381
- const createdAt = request.occurredAt ?? new Date().toISOString();
1421
+ const createdAt = request.occurredAt ?? worldNow();
1382
1422
  await applyTwinWrite(SERVICE, {
1383
1423
  operation: 'x.twin.seed_post',
1384
1424
  subjectType: 'post',
@@ -1405,7 +1445,7 @@ async function handleTwinControl(request, all, path, payload) {
1405
1445
  subjectType: 'follow',
1406
1446
  subjectId: `${followerId}:${followedId}`,
1407
1447
  fields: { follower_id: followerId, followed_id: followedId },
1408
- occurredAt: request.occurredAt ?? new Date().toISOString(),
1448
+ occurredAt: request.occurredAt ?? worldNow(),
1409
1449
  }, request.root);
1410
1450
  return ok({ data: { follower_id: followerId, followed_id: followedId } });
1411
1451
  }
@@ -1418,7 +1458,7 @@ async function handleTwinControl(request, all, path, payload) {
1418
1458
  subjectType: 'rate_limit',
1419
1459
  subjectId: 'armed',
1420
1460
  fields: { armed: payload.armed !== false, reset: resetAt, limit: typeof payload.limit === 'number' ? payload.limit : 200 },
1421
- occurredAt: request.occurredAt ?? new Date().toISOString(),
1461
+ occurredAt: request.occurredAt ?? worldNow(),
1422
1462
  }, request.root);
1423
1463
  return ok({ data: { armed: payload.armed !== false, reset: resetAt } });
1424
1464
  }
@@ -1469,7 +1509,7 @@ export async function handleXTwinRequest(request) {
1469
1509
  const refusal = armedRateRefusal(all);
1470
1510
  if (refusal)
1471
1511
  return refusal;
1472
- const auth = authorize(all, request.headers);
1512
+ const auth = authorize(all, request);
1473
1513
  if (isResponse(auth))
1474
1514
  return auth;
1475
1515
  const publicBase = (request.publicBase ?? PBS_BASE).replace(/\/+$/, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-x",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local X (Twitter) API v2 twin (post, reply, delete, timelines, user lookup) with an x.com mirror, built on @volter/world-core.",
5
5
  "author": "Volter (https://github.com/volter-ai)",
6
6
  "license": "Apache-2.0",
@@ -36,14 +36,14 @@
36
36
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
37
37
  },
38
38
  "peerDependencies": {
39
- "@volter/world-core": "2.0.0"
39
+ "@volter/world-core": "2.0.2"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/bun": "^1.2.20",
43
43
  "@types/node": "^24.0.0",
44
44
  "@types/react": "^19.2.17",
45
45
  "@types/react-dom": "^19.2.3",
46
- "@volter/world-core": "2.0.0",
46
+ "@volter/world-core": "2.0.2",
47
47
  "@volter/world-tooling": "0.1.0",
48
48
  "twitter-api-v2": "^1.29.1",
49
49
  "typescript": "^5.9.0"
package/src/index.ts CHANGED
@@ -79,11 +79,13 @@ import { X_RATE_BUDGET as RATE_BUDGET } from './x-budget.ts';
79
79
  * recent search, the three per-user timelines (mentions, the account's own posts, the home
80
80
  * timeline), user lookup by id and by username, and image upload (one-shot and chunked) — plus the
81
81
  * twin-only control prefix. The user-id
82
- * rule EXCLUDES `/2/users/me`: that route is xidentity's, and the two claims stay disjoint. ANCHORED: an unanchored claim would swallow
82
+ * rule EXCLUDES `/2/users/me`: that route is xidentity's, and the two claims stay disjoint. So does
83
+ * the control prefix: `/_twin/assets/consent.{js,css}` are xidentity's, the page assets of the
84
+ * OAuth 1.0a screen it serves on this host (claimed here, the screen rendered unstyled in a World). ANCHORED: an unanchored claim would swallow
83
85
  * `/2/tweetsomething`. Everything else on api.x.com stays unclaimed, so an unmodelled X call
84
86
  * refuses loudly rather than landing in a twin that cannot serve it.
85
87
  */
86
- 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/';
88
+ 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)$)';
87
89
 
88
90
  export const pack: TwinPack = {
89
91
  vendor: 'x',
@@ -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, type CapabilityReport, type CapabilitySpec, verifyBo
13
14
  import { pushXAction, xRequestForAction, syncXFromReal, type XExecute } from './x-connector.ts';
14
15
  import { X_BUDGET_CEILING, X_CALL_WEIGHTS, X_RATE_BUDGET, XBudget, XBudgetError, xCallWeight } from './x-budget.ts';
15
16
  import { handleXTwinRequest } from './x-twin.ts';
17
+ import { createXTwinFetch } from './x-server.ts';
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.tsx';
@@ -107,6 +110,31 @@ async function mediaToken(h: Handle): Promise<void> {
107
110
  await h({ m: 'POST', p: '/_twin/tokens', b: { token: 'org-media-token', account_id: ORG, scopes: [...FULL_SCOPES, 'media.write'] }, headers: {} });
108
111
  }
109
112
 
113
+ // ── OAuth 1.0a: xidentity's credential store, and an independent client signer ──────────────────
114
+ // The access token a person approved lives in xidentity's state (xidentity-oauth1.ts names the row
115
+ // shapes as the contract; x-oauth1.ts reads them). A verify here cannot run xidentity's legs — A3
116
+ // forbids the import — so it writes the rows those legs write, on the same root, as a World that
117
+ // ran them would hold; x-oauth1.integration.test.ts drives the real legs across both packs.
118
+ const O1_APP = { key: 'VerifyConsumerKey00000001', secret: 'VerifyConsumerSecret000000000000000000000000001' };
119
+ const O1_TOKEN = { key: `${ORG}-VerifyAccessToken0000000000000000000000000`, secret: 'VerifyAccessSecret000000000000000000000001' };
120
+ async function seedOAuth1(root: string, token = O1_TOKEN, fields: Record<string, unknown> = {}): Promise<void> {
121
+ const at = '2026-02-01T00:00:00.000Z';
122
+ 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);
123
+ 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);
124
+ }
125
+ /** RFC 5849 HMAC-SHA1, written here from docs.x.com's creating-a-signature guide — NOT x-oauth1.ts's
126
+ * verifier, so a verify cannot pass by agreeing with itself. `url` is what the client signed. */
127
+ function o1Header(method: string, url: string, app = O1_APP, token = O1_TOKEN): string {
128
+ const enc = (v: string) => encodeURIComponent(v).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
129
+ const [base, query = ''] = url.split('?');
130
+ const oauth: Record<string, string> = { oauth_consumer_key: app.key, oauth_nonce: 'verifynonce', oauth_signature_method: 'HMAC-SHA1', oauth_timestamp: '1769904000', oauth_token: token.key, oauth_version: '1.0' };
131
+ const params = [...Object.entries(oauth), ...new URLSearchParams(query).entries()].map(([k, v]) => `${enc(k)}=${enc(v)}`).sort().join('&');
132
+ oauth['oauth_signature'] = createHmac('sha1', `${enc(app.secret)}&${enc(token.secret)}`).update(`${method}&${enc(base!)}&${enc(params)}`).digest('base64');
133
+ return 'OAuth ' + Object.entries(oauth).map(([k, v]) => `${enc(k)}="${enc(v)}"`).join(', ');
134
+ }
135
+ /** A direct in-process call names no host, so the client signed X's own API origin. */
136
+ const API = 'https://api.x.com';
137
+
110
138
  const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec =>
111
139
  ({ id, area, title, dimension, tier, expected: 'done', verify });
112
140
  const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec =>
@@ -117,6 +145,28 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
117
145
  // packages/twin/xidentity; this pack CONSUMES the user access token that flow mints) ─────────
118
146
  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
147
  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'),
148
+ 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', () =>
149
+ withWorld(async (h, root) => {
150
+ await mediaToken(h);
151
+ const fetchTwin = createXTwinFetch({ root });
152
+ const png = tinyPng();
153
+ 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 });
154
+ const id = String((init.body as Body).data?.id ?? '');
155
+ const boundary = '----twinverify';
156
+ const part = (head: string, bytes: Uint8Array) => concat(new TextEncoder().encode(`--${boundary}\r\n${head}\r\n\r\n`), bytes, new TextEncoder().encode('\r\n'));
157
+ const body = concat(
158
+ new TextEncoder().encode(`a preamble, which is not a part --${boundary} either\r\n`),
159
+ part('Content-Disposition: form-data; name="segment_index"', new TextEncoder().encode('0')),
160
+ part('Content-Disposition: form-data; name="media"\r\nContent-Type: application/octet-stream', png),
161
+ new TextEncoder().encode(`--${boundary}--\r\n`),
162
+ );
163
+ 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) }));
164
+ const fin = await h({ m: 'POST', p: `/2/media/upload/${id}/finalize`, headers: MEDIA_AUTH });
165
+ const posted = await h({ m: 'POST', p: '/2/tweets', b: { text: 'with the image', media: { media_ids: [id] } }, headers: MEDIA_AUTH });
166
+ const read = await h({ m: 'GET', p: `/2/tweets/${String((posted.body as Body).data?.id)}?expansions=attachments.media_keys&media.fields=width,height,url`, headers: MEDIA_AUTH });
167
+ const media = (read.body as Body).includes?.media?.[0];
168
+ return append.status === 200 && fin.status === 200 && posted.status === 201 && media?.width === 64 && media?.height === 48;
169
+ })),
120
170
  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
171
  withWorld(async (h) => {
122
172
  await mediaToken(h);
@@ -190,6 +240,33 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
190
240
  && body.title === 'Invalid Request'
191
241
  && Array.isArray(body.errors) && body.errors[0].parameters.text !== undefined;
192
242
  })),
243
+ 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', () =>
244
+ withWorld(async (h) => {
245
+ const labels = { made_with_ai: false, paid_partnership: false };
246
+ const created = await h({ m: 'POST', p: '/2/tweets', b: { text: 'scheduled from postiz', ...labels } });
247
+ const id = (created.body as Body).data?.id as string;
248
+ if (created.status !== 201 || typeof id !== 'string') return false;
249
+ const replied = await h({ m: 'POST', p: '/2/tweets', b: { text: 'and a thread', reply: { in_reply_to_tweet_id: id }, ...labels } });
250
+ const replyId = (replied.body as Body).data?.id as string;
251
+ if (replied.status !== 201) return false;
252
+ // both landed as STATE: the reply joined the post's conversation, read back through the twin
253
+ const read = (await h({ m: 'GET', p: `/2/tweets/${replyId}?${THREAD_FIELDS}` })).body as Body;
254
+ if (read.data?.conversation_id !== id || read.data?.referenced_tweets?.[0]?.id !== id) return false;
255
+ // a label that says something is still refused by name, and nothing lands; a non-boolean is invalid
256
+ const before = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body as Body).meta?.result_count;
257
+ const labelled = await h({ m: 'POST', p: '/2/tweets', b: { text: 'an ad', paid_partnership: true } });
258
+ const stringly = await h({ m: 'POST', p: '/2/tweets', b: { text: 'ai?', made_with_ai: 'false' } });
259
+ const after = ((await h({ m: 'GET', p: `/2/users/${ORG}/tweets` })).body as Body).meta?.result_count;
260
+ // `true` is refused as unmodelled surface; a string is not a boolean at all — two different refusals
261
+ const labelledError = (labelled.body as Body).errors?.[0];
262
+ const stringlyError = (stringly.body as Body).errors?.[0];
263
+ return labelled.status === 400 && labelledError?.parameters?.paid_partnership?.[0] === 'true'
264
+ && /does not model/.test(String(labelledError?.message)) && !/must be a boolean/.test(String(labelledError?.message))
265
+ && stringly.status === 400 && stringlyError?.parameters?.made_with_ai?.[0] === '"false"'
266
+ && /must be a boolean/.test(String(stringlyError?.message)) && !/does not model/.test(String(stringlyError?.message))
267
+ && before === 2 && after === 2;
268
+ })),
269
+ 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'),
193
270
  done('x.tweets.ids_are_snowflake_shaped_and_unique', 'tweets', 'Minted post ids are numeric snowflakes, never a row count', 'api', 'core', () =>
194
271
  withWorld(async (h) => {
195
272
  const ids: string[] = [];
@@ -404,6 +481,23 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
404
481
  && unknown.status === 400;
405
482
  })),
406
483
 
484
+ // Postiz asks it on every @-mention lookup (x.provider.ts `mention`: userByUsername with
485
+ // user.fields username,name,profile_image_url; its connect-time v2.me is xidentity's); refused, the
486
+ // mention box showed no suggestions (cookbook/postiz finding 14).
487
+ 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', () =>
488
+ withWorld(async (h) => {
489
+ const photo = 'https://pbs.twimg.com/profile_images/1/pictured_normal.jpg';
490
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3100', username: 'pictured', name: 'Pictured', profile_image_url: photo }, headers: {} });
491
+ await h({ m: 'POST', p: '/_twin/accounts', b: { id: '3101', username: 'plain', name: 'Plain' }, headers: {} });
492
+ const own = ((await h({ m: 'GET', p: '/2/users/by/username/pictured?user.fields=username,name,profile_image_url' })).body as Body).data;
493
+ const plain = ((await h({ m: 'GET', p: '/2/users/3101?user.fields=profile_image_url' })).body as Body).data;
494
+ const bare = ((await h({ m: 'GET', p: '/2/users/3100' })).body as Body).data;
495
+ const bad = await h({ m: 'POST', p: '/_twin/accounts', b: { username: 'badphoto', profile_image_url: 'not a url' }, headers: {} });
496
+ return own?.profile_image_url === photo && own?.name === 'Pictured'
497
+ && plain?.profile_image_url === 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png'
498
+ && bare?.profile_image_url === undefined && bad.status === 400;
499
+ })),
500
+
407
501
  // ── user lookup ──────────────────────────────────────────────────────────────────────────────
408
502
  done('x.users.lookup_by_id', 'users', 'User lookup by id (GET /2/users/:id)', 'api', 'core', () =>
409
503
  withWorld(async (h) => {
@@ -1087,7 +1181,87 @@ export const X_CAPABILITIES: CapabilitySpec[] = [
1087
1181
  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
1182
  todo('x.errors.duplicate_content', 'errors', "X's duplicate-content refusal for posting the same text twice", 'api', 'common'),
1089
1183
  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'),
1184
+ 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', () =>
1185
+ withWorld(async (h, root) => {
1186
+ await seedOAuth1(root);
1187
+ const posted = await h({ m: 'POST', p: '/2/tweets', b: { text: 'signed, not bearer' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`) } });
1188
+ const id = String((posted.body as Body).data?.id ?? '');
1189
+ // a read with a query: the query is part of what was signed
1190
+ const q = `/2/tweets/${id}?tweet.fields=author_id`;
1191
+ const read = await h({ m: 'GET', p: q, headers: { authorization: o1Header('GET', `${API}${q}`) } });
1192
+ return posted.status === 201 && (read.body as Body).data?.author_id === ORG && (read.body as Body).data?.text === 'signed, not bearer';
1193
+ })),
1194
+ 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', () =>
1195
+ withWorld(async (h, root) => {
1196
+ await seedOAuth1(root);
1197
+ const post = (authorization: string) => h({ m: 'POST', p: '/2/tweets', b: { text: 'forged' }, headers: { authorization } });
1198
+ const wrongConsumer = await post(o1Header('POST', `${API}/2/tweets`, { ...O1_APP, secret: 'wrong' }));
1199
+ const wrongToken = await post(o1Header('POST', `${API}/2/tweets`, O1_APP, { ...O1_TOKEN, secret: 'wrong' }));
1200
+ const otherUrl = await post(o1Header('POST', `${API}/2/tweets/other`));
1201
+ const q = `/2/users/by/username/orgvoice`;
1202
+ const otherQuery = await h({ m: 'GET', p: `${q}?user.fields=description`, headers: { authorization: o1Header('GET', `${API}${q}`) } });
1203
+ // a repeated key (X accepts none) and a query realm (signed like any parameter) do not ride on a valid signature
1204
+ const signedQ = o1Header('GET', `${API}${q}?user.fields=description`);
1205
+ const plainQ = await h({ m: 'GET', p: `${q}?user.fields=description`, headers: { authorization: signedQ } });
1206
+ const dup = `${q}?user.fields=description&user.fields=description`;
1207
+ const repeated = await h({ m: 'GET', p: dup, headers: { authorization: o1Header('GET', `${API}${dup}`) } });
1208
+ const realm = await h({ m: 'GET', p: `${q}?user.fields=description&realm=x`, headers: { authorization: signedQ } });
1209
+ const timeline = await h({ m: 'GET', p: `/2/users/${ORG}/tweets` });
1210
+ return plainQ.status === 200 && [wrongConsumer, wrongToken, otherUrl, otherQuery, repeated, realm].every((r) => r.status === 401 && (r.body as Body).type === 'about:blank')
1211
+ && (timeline.body as Body).meta?.result_count === 0;
1212
+ })),
1213
+ 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', () =>
1214
+ withWorld(async (h, root) => {
1215
+ await seedOAuth1(root);
1216
+ const revoked = { key: `${ORG}-RevokedAccessToken000000000000000000000000`, secret: 'RevokedSecret' };
1217
+ await seedOAuth1(root, revoked, { revoked: true });
1218
+ const other = { key: `${ORG}-OtherAppsToken00000000000000000000000000000`, secret: 'OtherSecret' };
1219
+ await seedOAuth1(root, other, { consumerKey: 'SomeOtherApp' });
1220
+ const post = (token: { key: string; secret: string }) => h({ m: 'POST', p: '/2/tweets', b: { text: 'x' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`, O1_APP, token) } });
1221
+ const good = await post(O1_TOKEN);
1222
+ return good.status === 201 && (await post({ key: `${ORG}-NeverIssued`, secret: O1_TOKEN.secret })).status === 401
1223
+ && (await post(revoked)).status === 401 && (await post(other)).status === 401;
1224
+ })),
1225
+ 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', () =>
1226
+ withWorld(async (h, root) => {
1227
+ await seedOAuth1(root, O1_TOKEN, { accessLevel: 'read' });
1228
+ const q = '/2/users/by/username/orgvoice';
1229
+ const read = await h({ m: 'GET', p: q, headers: { authorization: o1Header('GET', `${API}${q}`) } });
1230
+ const post = await h({ m: 'POST', p: '/2/tweets', b: { text: 'read-only' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`) } });
1231
+ 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`) } });
1232
+ const isPermissions = (r: { status: number; body: unknown }) => r.status === 403 && (r.body as Body).type === 'https://api.twitter.com/2/problems/oauth1-permissions';
1233
+ return read.status === 200 && (read.body as Body).data?.id === ORG && isPermissions(post) && isPermissions(upload);
1234
+ })),
1235
+ 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', () =>
1236
+ withWorld(async (_h, root) => {
1237
+ await seedOAuth1(root);
1238
+ const fetchTwin = createXTwinFetch({ root });
1239
+ const q = '/2/users/by/username/orgvoice';
1240
+ 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}`) } }));
1241
+ const direct = await fetchTwin(new Request(`http://127.0.0.1:9${q}`, { headers: { authorization: o1Header('GET', `http://127.0.0.1:9${q}`) } }));
1242
+ const mismatch = await fetchTwin(new Request(`http://127.0.0.1:9${q}`, { headers: { authorization: o1Header('GET', `https://api.x.com${q}`) } }));
1243
+ return named.status === 200 && direct.status === 200 && mismatch.status === 401;
1244
+ })),
1245
+ 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', () =>
1246
+ withWorld(async (h, root) => {
1247
+ const names = ['X_API_KEY', 'X_API_SECRET', 'VOLTER_WORLD_ENV_NAMES'];
1248
+ const saved = Object.fromEntries(names.map((n) => [n, process.env[n]]));
1249
+ const app = { key: 'WorldConsumerKey000000001', secret: 'WorldConsumerSecret0000000000000000000000000001' };
1250
+ 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);
1251
+ const post = () => h({ m: 'POST', p: '/2/tweets', b: { text: 'from the World app' }, headers: { authorization: o1Header('POST', `${API}/2/tweets`, app) } });
1252
+ try {
1253
+ Object.assign(process.env, { X_API_KEY: app.key, X_API_SECRET: app.secret });
1254
+ delete process.env.VOLTER_WORLD_ENV_NAMES;
1255
+ const shellOnly = await post();
1256
+ process.env.VOLTER_WORLD_ENV_NAMES = JSON.stringify(['X_API_KEY', 'X_API_SECRET']);
1257
+ const world = await post();
1258
+ return shellOnly.status === 401 && world.status === 201;
1259
+ } finally {
1260
+ for (const [n, v] of Object.entries(saved)) { if (v === undefined) delete process.env[n]; else process.env[n] = v; }
1261
+ }
1262
+ })),
1263
+ 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'),
1264
+ todo('x.auth.oauth1_timestamp_window', 'auth', "Refuse an OAuth 1.0a oauth_timestamp outside X's window and a replayed nonce", 'api', 'niche'),
1091
1265
  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
1266
  // ══ MIRROR (dimension: 'ui' — every one is DATA-COUPLED: seed through the handler, read the
1093
1267
  // SAME route the screen reads, render the mirror's OWN component over it) ══════════════════════
@@ -14,12 +14,12 @@
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.ts';
20
20
 
21
- const CLIENT_ENTRY = () => new URL('../client/x-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
22
- const CLIENT_CSS = () => new URL('../client/x-mirror.css', import.meta.url).pathname; // lazy: same reason
21
+ const CLIENT_ENTRY = () => filePathOf(new URL('../client/x-mirror.tsx', import.meta.url)); // lazy: workerd rejects a top-level relative import.meta.url
22
+ const CLIENT_CSS = () => filePathOf(new URL('../client/x-mirror.css', import.meta.url)); // lazy: same reason
23
23
 
24
24
  // ---------------------------------------------------------------------------
25
25
  // Pure, dependency-free render/format helpers (importable by the React client; Bun tree-shakes
@@ -261,3 +261,20 @@ export function xMirrorHtml(): string {
261
261
  export function xMirrorStyles(): Promise<string> {
262
262
  return readFile(CLIENT_CSS(), 'utf8');
263
263
  }
264
+
265
+ /** The account `as` names (a username, with or without its @) and a fresh token minted for it through the twin's own
266
+ * `/_twin/tokens` door, as the World's seed issues one: what X's screens sign in with when the World's config says
267
+ * `signIn: { as }` (the World hands them over, served-world.ts). A stored token (the app's own, perhaps) is never read
268
+ * back; this one carries only what the screens do: read posts and people, and post. Null when the World holds no such
269
+ * account, or the door refuses the token. */
270
+ export async function xMirrorSignIn(as: string, ctx: { root: string; twin: (path: string, init?: RequestInit) => Promise<Response> }): Promise<{ account: string; token: string } | null> {
271
+ const handle = as.replace(/^@/, '').toLowerCase();
272
+ const account = twinResources('x', ctx.root).find((r) => r.type === 'account' && String(r.username ?? '').toLowerCase() === handle);
273
+ if (!account) return null;
274
+ const token = `volter-view-${crypto.randomUUID()}`;
275
+ const minted = await ctx.twin('/_twin/tokens', {
276
+ method: 'POST', headers: { 'content-type': 'application/json' },
277
+ body: JSON.stringify({ token, account_id: account.id, scopes: ['tweet.read', 'users.read', 'tweet.write'] }),
278
+ });
279
+ return minted.ok ? { account: String(account.username), token } : null;
280
+ }