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