@volter/twin-x 0.1.0 → 0.1.1
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/README.md +22 -8
- package/dist/client/x-mirror.bundle.js +90 -90
- package/dist/src/index.js +4 -2
- package/dist/src/x-capabilities.js +168 -1
- package/dist/src/x-oauth1.d.ts +24 -0
- package/dist/src/x-oauth1.js +145 -0
- package/dist/src/x-problems.d.ts +7 -0
- package/dist/src/x-problems.js +18 -0
- package/dist/src/x-server.js +51 -13
- package/dist/src/x-twin.d.ts +4 -0
- package/dist/src/x-twin.js +58 -18
- package/package.json +3 -3
- package/src/index.ts +4 -2
- package/src/x-capabilities.ts +175 -1
- package/src/x-oauth1.ts +138 -0
- package/src/x-problems.ts +19 -0
- package/src/x-server.ts +43 -8
- package/src/x-twin.ts +57 -18
package/dist/src/x-twin.js
CHANGED
|
@@ -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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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,
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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 ??
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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.
|
|
39
|
+
"@volter/world-core": "2.0.1"
|
|
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.
|
|
46
|
+
"@volter/world-core": "2.0.1",
|
|
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.
|
|
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',
|
package/src/x-capabilities.ts
CHANGED
|
@@ -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
|
-
|
|
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) ══════════════════════
|
package/src/x-oauth1.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
|
|
23
|
+
/** Constant-time equality of two same-length byte strings. Not node:crypto's timingSafeEqual: the
|
|
24
|
+
* mirror's browser client bundles this module's chain, and Bun's browser polyfill has none. */
|
|
25
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
26
|
+
let difference = 0;
|
|
27
|
+
for (let i = 0; i < a.length; i++) difference |= a[i] ^ b[i];
|
|
28
|
+
return difference === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Row = Record<string, any>;
|
|
32
|
+
|
|
33
|
+
// ── RFC 5849, transcribed from xidentity-oauth1.ts (docs.x.com creating-a-signature) ─────────────
|
|
34
|
+
|
|
35
|
+
const pct = (s: string): string =>
|
|
36
|
+
encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
37
|
+
|
|
38
|
+
function parseOAuthHeader(value: string | undefined): Array<[string, string]> | null {
|
|
39
|
+
if (!value) return null;
|
|
40
|
+
const m = /^OAuth\s+(.*)$/is.exec(value.trim());
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
const out: Array<[string, string]> = [];
|
|
43
|
+
for (const part of m[1]!.split(',')) {
|
|
44
|
+
const kv = /^\s*([^=\s]+)\s*=\s*"([^"]*)"\s*$/.exec(part);
|
|
45
|
+
if (!kv) { if (part.trim() === '') continue; return null; }
|
|
46
|
+
try {
|
|
47
|
+
out.push([decodeURIComponent(kv[1]!), decodeURIComponent(kv[2]!)]);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function baseString(method: string, baseUrl: string, pairs: Array<[string, string]>): string {
|
|
56
|
+
const encoded = pairs.map(([k, v]) => [pct(k), pct(v)] as const).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
|
+
|
|
60
|
+
const X_HOSTS = new Set(['api.x.com', 'api.twitter.com']);
|
|
61
|
+
|
|
62
|
+
/** The URL the client signed: X's host when the request still names it (the injector keeps Host,
|
|
63
|
+
* or names it in `x-volter-twin-original-host`), else where it was sent (`publicBase`), else — a
|
|
64
|
+
* direct in-process call — X's own API origin. */
|
|
65
|
+
function signedBaseUrl(headers: Record<string, string>, publicBase: string | undefined, path: string): string {
|
|
66
|
+
const named = (headers['x-volter-twin-original-host'] ?? headers['host'] ?? '').toLowerCase().replace(/:\d+$/, '');
|
|
67
|
+
if (X_HOSTS.has(named)) return `https://${named}${path}`;
|
|
68
|
+
if (!publicBase) return `https://api.x.com${path}`;
|
|
69
|
+
let u: URL;
|
|
70
|
+
try { u = new URL(publicBase); } catch { return `https://api.x.com${path}`; }
|
|
71
|
+
const port = u.port && !((u.protocol === 'https:' && u.port === '443') || (u.protocol === 'http:' && u.port === '80')) ? `:${u.port}` : '';
|
|
72
|
+
return `${u.protocol}//${u.hostname.toLowerCase()}${port}${u.pathname.replace(/\/+$/, '')}${path}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── the World's own App (transcribed from xidentity-oauth1.ts worldOAuth1App) ────────────────────
|
|
76
|
+
|
|
77
|
+
const WORLD_APP_ENV: ReadonlyArray<readonly [string, string]> = [['X_API_KEY', 'X_API_SECRET'], ['TWITTER_API_KEY', 'TWITTER_API_SECRET']];
|
|
78
|
+
|
|
79
|
+
function consumerSecretOf(identity: Row[], consumerKey: string): string | undefined {
|
|
80
|
+
const row = identity.find((r) => r.type === 'oauth1_app' && String(r.id) === consumerKey);
|
|
81
|
+
if (row && row.deleted !== true) return String(row.secret);
|
|
82
|
+
for (const [keyName, secretName] of WORLD_APP_ENV) {
|
|
83
|
+
const key = worldEnvValue(keyName);
|
|
84
|
+
const secret = worldEnvValue(secretName);
|
|
85
|
+
if (key && secret) return key === consumerKey ? secret : undefined;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── the check ────────────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export type OAuth1User = { userId: string; token: string; accessLevel: 'read' | 'read-write' };
|
|
93
|
+
|
|
94
|
+
export const isOAuth1Header = (value: string | undefined): boolean => /^OAuth\s/i.test((value ?? '').trim());
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The user a request signed with OAuth 1.0a acts for, or null: an unparseable header, a method
|
|
98
|
+
* other than HMAC-SHA1, a token xidentity never issued or has revoked, a token presented by another
|
|
99
|
+
* App, or a signature that does not verify over (method, the signed URL, query + oauth params) with
|
|
100
|
+
* `consumer_secret&token_secret`. A JSON or multipart body is not signed (the guide; twitter-api-v2
|
|
101
|
+
* signs a body only when it is form-encoded, and this surface has no form-encoded route).
|
|
102
|
+
*/
|
|
103
|
+
export function verifyOAuth1(req: { method: string; path: string; headers: Record<string, string>; root?: string; publicBase?: string }): OAuth1User | null {
|
|
104
|
+
const header = parseOAuthHeader(req.headers['authorization'] ?? Object.entries(req.headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1]);
|
|
105
|
+
if (!header) return null;
|
|
106
|
+
const oauth = new Map(header);
|
|
107
|
+
if (oauth.get('oauth_signature_method') !== 'HMAC-SHA1' || (oauth.has('oauth_version') && oauth.get('oauth_version') !== '1.0')) return null;
|
|
108
|
+
for (const k of ['oauth_consumer_key', 'oauth_nonce', 'oauth_signature', 'oauth_timestamp', 'oauth_token']) if (!oauth.get(k)) return null;
|
|
109
|
+
let identity: Row[];
|
|
110
|
+
try {
|
|
111
|
+
// the store that issued this token (a World running xidentity twice: the one holding it)
|
|
112
|
+
identity = projectOwnerResources('xidentity', req.root, { type: 'oauth1_token', id: String(oauth.get('oauth_token')) }) as unknown as Row[];
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof OwnerStoreAmbiguousError) return null; // two stores issued it: not a token this World can resolve
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
const token = identity.find((r) => r.type === 'oauth1_token' && String(r.id) === oauth.get('oauth_token'));
|
|
118
|
+
if (!token || token.revoked === true || token.consumerKey !== oauth.get('oauth_consumer_key')) return null;
|
|
119
|
+
const consumerSecret = consumerSecretOf(identity, String(token.consumerKey));
|
|
120
|
+
if (consumerSecret === undefined) return null;
|
|
121
|
+
const [p, q = ''] = req.path.split('?');
|
|
122
|
+
const query = [...new URLSearchParams(q).entries()];
|
|
123
|
+
// X accepts no duplicate keys (creating-a-signature footnote 2); only the header's realm is unsigned (RFC 5849 §3.4.1.3.1)
|
|
124
|
+
if (new Set(query.map(([k]) => k)).size !== query.length) return null;
|
|
125
|
+
const pairs = [...header.filter(([k]) => k !== 'realm'), ...query].filter(([k]) => k !== 'oauth_signature');
|
|
126
|
+
const base = baseString(req.method, signedBaseUrl(req.headers, req.publicBase, p ?? '/'), pairs);
|
|
127
|
+
const expected = Buffer.from(createHmac('sha1', `${pct(consumerSecret)}&${pct(String(token.secret))}`).update(base).digest('base64'));
|
|
128
|
+
const given = Buffer.from(oauth.get('oauth_signature')!);
|
|
129
|
+
if (expected.length !== given.length || !sameBytes(expected, given)) return null;
|
|
130
|
+
return { userId: String(token.userId), token: String(token.id), accessLevel: token.accessLevel === 'read' ? 'read' : 'read-write' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** OAuth 1.0a has no scopes: the App's permission level decides (docs.x.com developer-apps, "App
|
|
134
|
+
* permissions (OAuth 1.0a)" — Read only: view; Read and write: also post and delete). As the OAuth
|
|
135
|
+
* 2.0 scopes those levels stand for on the routes this pack gates. */
|
|
136
|
+
export function scopesOfAccessLevel(level: OAuth1User['accessLevel']): string[] {
|
|
137
|
+
return level === 'read' ? ['tweet.read', 'users.read'] : ['tweet.read', 'users.read', 'tweet.write', 'media.write'];
|
|
138
|
+
}
|