@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/src/x-problems.ts CHANGED
@@ -55,6 +55,25 @@ export function forbiddenProblem(detail: string): XResponse {
55
55
  };
56
56
  }
57
57
 
58
+ /**
59
+ * An OAuth 1.0a token whose App permission does not reach the endpoint (a Read-only token posting).
60
+ * X's v2 answers this with its own problem type rather than the scope wording: the type and detail
61
+ * are the widely-reported wire strings, not a fetched page — EXTRAPOLATION, pinned by
62
+ * `x.auth.oauth1_permissions_wording`.
63
+ */
64
+ export function oauth1PermissionsProblem(): XResponse {
65
+ return {
66
+ status: 403,
67
+ body: {
68
+ title: 'Forbidden',
69
+ type: 'https://api.twitter.com/2/problems/oauth1-permissions',
70
+ status: 403,
71
+ detail: 'Your client app is not configured with the appropriate oauth1 app permissions for this endpoint.',
72
+ },
73
+ headers: { ...PROBLEM },
74
+ };
75
+ }
76
+
58
77
  /** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
59
78
  export function notFoundProblem(): XResponse {
60
79
  return {
package/src/x-server.ts CHANGED
@@ -59,6 +59,46 @@ function bytesResponse(request: Request, bytes: Uint8Array, contentType: string)
59
59
  });
60
60
  }
61
61
 
62
+ /**
63
+ * An RFC 7578 multipart body, read as BYTES. The `media` part is binary whether or not its
64
+ * disposition names a filename: twitter-api-v2@1.29.1's FormDataHelper — what Postiz's
65
+ * `client.v2.uploadMedia` sends each APPEND with — writes `Content-Disposition: form-data;
66
+ * name="media"` and `Content-Type: application/octet-stream` with NO filename, and a WHATWG
67
+ * `formData()` parse turns such a part into a UTF-8-decoded string, corrupting the bytes. Every other
68
+ * part is a text field. Null when the body is not the multipart its content type declares.
69
+ */
70
+ function parseMultipart(body: Uint8Array, contentType: string): { fields: Record<string, string>; media?: Uint8Array } | null {
71
+ const b = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
72
+ if (!b) return null;
73
+ const boundary = b[1] ?? b[2];
74
+ const buf = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
75
+ // A delimiter is CRLF "--boundary" (RFC 2046 §5.1.1); the first may open the body with no CRLF
76
+ // before it, anything ahead of it is preamble. A boundary string not at a line start is content.
77
+ const delimiter = Buffer.from(`\r\n--${boundary}`);
78
+ const first = Buffer.from(`--${boundary}`);
79
+ let pos = buf.subarray(0, first.length).equals(first) ? first.length : (() => { const i = buf.indexOf(delimiter); return i < 0 ? -1 : i + delimiter.length; })();
80
+ if (pos < 0) return null;
81
+ const fields: Record<string, string> = {};
82
+ let media: Uint8Array | undefined;
83
+ for (;;) {
84
+ if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break; // the closing delimiter
85
+ while (buf[pos] === 0x20 || buf[pos] === 0x09) pos += 1; // transport padding
86
+ if (buf[pos] !== 0x0d || buf[pos + 1] !== 0x0a) return null;
87
+ const headEnd = buf.indexOf('\r\n\r\n', pos + 2);
88
+ if (headEnd < 0) return null;
89
+ const head = buf.subarray(pos + 2, headEnd).toString('utf8');
90
+ const next = buf.indexOf(delimiter, headEnd + 2);
91
+ if (next < 0) return null;
92
+ const content = buf.subarray(Math.min(headEnd + 4, next), next);
93
+ const disposition = head.split('\r\n').find((line) => /^content-disposition\s*:/i.test(line)) ?? '';
94
+ const name = /;\s*name="([^"]*)"/i.exec(disposition)?.[1];
95
+ if (name === 'media') media = new Uint8Array(content);
96
+ else if (name !== undefined) fields[name] = content.toString('utf8');
97
+ pos = next + delimiter.length;
98
+ }
99
+ return { fields, ...(media ? { media } : {}) };
100
+ }
101
+
62
102
  export function createXTwinFetch(options: XTwinFetchOptions = {}): (request: Request) => Promise<Response> {
63
103
  const adapted = createTwinFetchFromHandler(handleXTwinRequest, {
64
104
  ...options,
@@ -86,16 +126,11 @@ export function createXTwinFetch(options: XTwinFetchOptions = {}): (request: Req
86
126
  }
87
127
  const type = request.headers.get('content-type') ?? '';
88
128
  if (request.method === 'POST' && MULTIPART_UPLOAD_PATH.test(url.pathname) && /^multipart\/form-data/i.test(type)) {
89
- let form: FormData;
90
- try { form = await request.formData(); } catch {
129
+ const parsed = parseMultipart(new Uint8Array(await request.arrayBuffer()), type);
130
+ if (!parsed) {
91
131
  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
132
  }
93
- const fields: Record<string, string> = {};
94
- let media: Uint8Array | undefined;
95
- for (const [name, value] of form.entries() as Iterable<[string, unknown]>) {
96
- if (typeof value === 'string') fields[name] = value;
97
- else if (name === 'media' && value instanceof Blob) media = new Uint8Array(await value.arrayBuffer());
98
- }
133
+ const { fields, media } = parsed;
99
134
  const headers: Record<string, string> = {};
100
135
  request.headers.forEach((value, key) => { headers[key] = value; });
101
136
  const invoke = () => handleXTwinRequest({
package/src/x-twin.ts CHANGED
@@ -35,19 +35,21 @@
35
35
  // default projection is the vendor's three fields, and `projectPost` is the ONE place a stored
36
36
  // post becomes a response body.
37
37
  //
38
- // AUTH. OAuth 2.0 is xidentity's and stays there. This pack CONSUMES the user access token that
39
- // flow mints: a bearer, opaque, scope-carrying. `/_twin/tokens` registers one locally, which is
40
- // what a world does instead of running the authorize leg for every test.
38
+ // AUTH. OAuth is xidentity's and stays there. This pack CONSUMES what it mints: an OAuth 2.0 user
39
+ // bearer — opaque, scope-carrying; `/_twin/tokens` registers one locally, which is what a world
40
+ // does instead of running the authorize leg for every test — or an OAuth 1.0a User Context
41
+ // signature over an access token xidentity's three legs issued (x-oauth1.ts).
41
42
  //
42
43
  // DETERMINISM. Every served response is a pure function of (request, stored state). Nothing on
43
44
  // the read path reads a clock: `created_at` is what the WRITE stored, and the `next_token` a page
44
45
  // hands back is derived from the id of the last row on that page, never from entropy or time.
45
- import { applyTwinWrite, projectResources } from '@volter/world-core';
46
+ import { applyTwinWrite, projectResources, worldNow } from '@volter/world-core';
46
47
  import {
47
48
  forbiddenProblem,
48
49
  invalidRequestProblem,
49
50
  lookupResult,
50
51
  notFoundProblem,
52
+ oauth1PermissionsProblem,
51
53
  rateLimitExceeded,
52
54
  readOnlyRefusal,
53
55
  resourceNotFoundError,
@@ -56,6 +58,7 @@ import {
56
58
  type XResponse,
57
59
  } from './x-problems.ts';
58
60
  import { missingScopes, parseScopeList, REQUIRED_SCOPES } from './x-scopes.ts';
61
+ import { isOAuth1Header, scopesOfAccessLevel, verifyOAuth1 } from './x-oauth1.ts';
59
62
  import {
60
63
  clearSegments,
61
64
  listMediaIds,
@@ -159,7 +162,7 @@ function newestFirst(posts: Resource[]): Resource[] {
159
162
  // unregistered, malformed or absent bearer is the vendor's about:blank 401 — never a local
160
163
  // exception, and never a pass.
161
164
 
162
- type Authorized = { accountId: string; scopes: string[]; token: string };
165
+ type Authorized = { accountId: string; scopes: string[]; token: string; oauth1?: true };
163
166
 
164
167
  function bearerFrom(headers: Record<string, string> | undefined): string | undefined {
165
168
  if (!headers) return undefined;
@@ -171,7 +174,16 @@ function bearerFrom(headers: Record<string, string> | undefined): string | undef
171
174
  return undefined;
172
175
  }
173
176
 
174
- function authorize(all: Resource[], headers: Record<string, string> | undefined): Authorized | XResponse {
177
+ function authorize(all: Resource[], request: XRequest): Authorized | XResponse {
178
+ const headers = request.headers;
179
+ const authorization = headers ? Object.entries(headers).find(([k]) => k.toLowerCase() === 'authorization')?.[1] : undefined;
180
+ if (isOAuth1Header(authorization)) {
181
+ // X's alternate scheme for every route here: a request signed with an access token the
182
+ // person approved at xidentity's screen. Anything that does not verify is the same 401.
183
+ const user = verifyOAuth1({ method: request.method, path: request.path, headers: headers ?? {}, root: request.root, publicBase: request.publicBase });
184
+ if (!user) return unauthorizedProblem();
185
+ return { accountId: user.userId, scopes: scopesOfAccessLevel(user.accessLevel), token: user.token, oauth1: true };
186
+ }
175
187
  const token = bearerFrom(headers);
176
188
  if (!token) return unauthorizedProblem();
177
189
  const row = ofType(all, 'token').find((t) => String(t.id) === token);
@@ -184,6 +196,7 @@ const isResponse = (value: Authorized | XResponse): value is XResponse => 'statu
184
196
  function scopeRefusal(auth: Authorized, operation: keyof typeof REQUIRED_SCOPES): XResponse | undefined {
185
197
  const missing = missingScopes(auth.scopes, REQUIRED_SCOPES[operation]);
186
198
  if (missing.length === 0) return undefined;
199
+ if (auth.oauth1) return oauth1PermissionsProblem();
187
200
  return forbiddenProblem(`This request requires the ${missing.join(', ')} scope(s), which this token does not hold.`);
188
201
  }
189
202
 
@@ -266,9 +279,14 @@ const USER_FIELDS_DOCUMENTED = new Set([
266
279
  * which is a pure function of the account's live posts. `public_metrics` is NOT here: two of its
267
280
  * counts (listed, like) have no state behind them, and a zero the twin cannot know is a fabrication. */
268
281
  const USER_FIELDS_MODELLED = new Set([
269
- 'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'protected', 'url', 'username', 'verified',
282
+ 'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'profile_image_url', 'protected', 'url', 'username', 'verified',
270
283
  ]);
271
284
 
285
+ /** The picture X shows for an account that never uploaded one: every X account has a
286
+ * `profile_image_url`, and this is its value until the person sets a photo. The twin serves the
287
+ * URL, not the bytes (it claims no abs.twimg.com), as it serves no profile photos at all. */
288
+ export const X_DEFAULT_PROFILE_IMAGE_URL = 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png';
289
+
272
290
  /** Read `user.fields` off the query string, refusing the way `tweet.fields` refuses. */
273
291
  function parseUserFields(url: URL): Set<string> | XResponse {
274
292
  const userFields = new Set<string>();
@@ -395,6 +413,9 @@ function projectUser(account: Resource, userFields: Set<string>, all: Resource[]
395
413
  if (userFields.has('description')) out.description = typeof account.description === 'string' ? account.description : '';
396
414
  if (userFields.has('location') && typeof account.location === 'string' && account.location !== '') out.location = account.location;
397
415
  if (userFields.has('url')) out.url = typeof account.url === 'string' ? account.url : '';
416
+ if (userFields.has('profile_image_url')) {
417
+ out.profile_image_url = typeof account.profile_image_url === 'string' && account.profile_image_url !== '' ? account.profile_image_url : X_DEFAULT_PROFILE_IMAGE_URL;
418
+ }
398
419
  if (userFields.has('protected')) out.protected = account.protected === true;
399
420
  if (userFields.has('verified')) out.verified = account.verified === true;
400
421
  if (userFields.has('most_recent_tweet_id')) {
@@ -677,6 +698,13 @@ const POST_FIELDS_UNMODELLED = new Set([
677
698
  'nullcast', 'paid_partnership', 'poll', 'reply_settings', 'share_with_followers',
678
699
  ]);
679
700
  const REPLY_FIELDS_UNMODELLED = new Set(['exclude_reply_user_ids', 'auto_populate_reply_metadata']);
701
+ /** The two disclosure labels, when they label nothing. `made_with_ai` ("this post contains AI-generated
702
+ * media") and `paid_partnership` ("this post is a paid partnership") are booleans whose `false` is the
703
+ * post every create here already makes, unlabelled; Postiz sends both on every post and reply, `false`
704
+ * unless the person ticked the box (x.provider.ts, `assetBoolean(...) || false`). `false` is read as the
705
+ * absent value it is; `true` — a label this twin does not store or show — stays refused by name, and
706
+ * so does a non-boolean. */
707
+ const POST_DISCLOSURE_FIELDS = new Set(['made_with_ai', 'paid_partnership']);
680
708
 
681
709
  /** A post id in a request body: a STRING matching ^[0-9]{1,19}$, as the schema types it. */
682
710
  function postIdField(value: unknown, parameter: string): string | XResponse {
@@ -689,6 +717,10 @@ function postIdField(value: unknown, parameter: string): string | XResponse {
689
717
  async function createPost(request: XRequest, all: Resource[], auth: Authorized, payload: Record<string, any>): Promise<XResponse> {
690
718
  for (const key of Object.keys(payload)) {
691
719
  if (POST_FIELDS_MODELLED.has(key)) continue;
720
+ if (POST_DISCLOSURE_FIELDS.has(key) && payload[key] === false) continue;
721
+ if (POST_DISCLOSURE_FIELDS.has(key) && payload[key] !== true) {
722
+ return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field must be a boolean.`);
723
+ }
692
724
  if (POST_FIELDS_UNMODELLED.has(key)) return unmodelledOption(key, JSON.stringify(payload[key]));
693
725
  return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field is not a parameter of this request.`);
694
726
  }
@@ -752,7 +784,7 @@ async function createPost(request: XRequest, all: Resource[], auth: Authorized,
752
784
 
753
785
  // a post's id is the snowflake high-water mark + 1: timelines, since_id and pagination order by it
754
786
  const id = mintSnowflakeId(all, await listMediaIds(request.root));
755
- const createdAt = request.occurredAt ?? new Date().toISOString();
787
+ const createdAt = request.occurredAt ?? worldNow();
756
788
  await applyTwinWrite(SERVICE, {
757
789
  operation: isReply ? 'x.post.reply' : isQuote ? 'x.post.quote' : 'x.post.create',
758
790
  subjectType: 'post',
@@ -791,7 +823,7 @@ async function deletePost(request: XRequest, all: Resource[], auth: Authorized,
791
823
  subjectType: 'post',
792
824
  subjectId: id,
793
825
  fields: { ...Object.fromEntries(Object.entries(post).filter(([k]) => k !== 'type' && k !== 'id' && k !== 'updatedAt')), deleted: true },
794
- occurredAt: request.occurredAt ?? new Date().toISOString(),
826
+ occurredAt: request.occurredAt ?? worldNow(),
795
827
  actor: { kind: 'agent', id: auth.accountId },
796
828
  }, request.root);
797
829
  return ok({ data: { deleted: true } });
@@ -843,7 +875,7 @@ const CHECK_AFTER_SECS = 1;
843
875
  const isVideoCategory = (category: string): boolean => VIDEO_CATEGORIES.has(category);
844
876
 
845
877
  function nowSeconds(request: XRequest): number {
846
- return Math.floor(Date.parse(request.occurredAt ?? new Date().toISOString()) / 1000);
878
+ return Math.floor(Date.parse(request.occurredAt ?? worldNow()) / 1000);
847
879
  }
848
880
 
849
881
  /** A field of the upload body, from the multipart form or the JSON object. */
@@ -1000,7 +1032,7 @@ async function uploadMedia(request: XRequest, all: Resource[], auth: Authorized,
1000
1032
  const opened: XMediaRecord = {
1001
1033
  id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: 'application/octet-stream', state: 'initialized',
1002
1034
  ...(owners.length > 0 ? { additional_owners: owners } : {}),
1003
- created_at: request.occurredAt ?? new Date().toISOString(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
1035
+ created_at: request.occurredAt ?? worldNow(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
1004
1036
  };
1005
1037
  // Check before storing: a refused image leaves no bytes behind.
1006
1038
  const checked = finishUpload(opened, bytes, '');
@@ -1039,7 +1071,7 @@ async function initializeUpload(request: XRequest, all: Resource[], auth: Author
1039
1071
  const record: XMediaRecord = {
1040
1072
  id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: mediaType, state: 'initialized', total_bytes: total,
1041
1073
  ...(owners.length > 0 ? { additional_owners: owners } : {}),
1042
- created_at: request.occurredAt ?? new Date().toISOString(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
1074
+ created_at: request.occurredAt ?? worldNow(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
1043
1075
  };
1044
1076
  await writeMediaRecord(record, request.root);
1045
1077
  return ok({ data: { id, media_key: record.media_key, expires_after_secs: MEDIA_EXPIRES_AFTER_SECS } });
@@ -1347,12 +1379,19 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
1347
1379
  if (typeof payload[key] !== 'string') return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a string.`);
1348
1380
  profile[key] = payload[key];
1349
1381
  }
1382
+ // A photo the person uploaded: X serves it from pbs.twimg.com/profile_images/…, an https URL.
1383
+ if (payload.profile_image_url !== undefined) {
1384
+ if (typeof payload.profile_image_url !== 'string' || !/^https:\/\/\S+$/.test(payload.profile_image_url)) {
1385
+ return invalidRequestProblem({ profile_image_url: [String(payload.profile_image_url)] }, "A seeded account's `profile_image_url` must be an https URL.");
1386
+ }
1387
+ profile.profile_image_url = payload.profile_image_url;
1388
+ }
1350
1389
  for (const key of ['protected', 'verified'] as const) {
1351
1390
  if (payload[key] === undefined) continue;
1352
1391
  if (typeof payload[key] !== 'boolean') return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a boolean.`);
1353
1392
  profile[key] = payload[key];
1354
1393
  }
1355
- const createdAt = request.occurredAt ?? new Date().toISOString();
1394
+ const createdAt = request.occurredAt ?? worldNow();
1356
1395
  await applyTwinWrite(SERVICE, {
1357
1396
  operation: 'x.twin.seed_account',
1358
1397
  subjectType: 'account',
@@ -1376,7 +1415,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
1376
1415
  subjectType: 'token',
1377
1416
  subjectId: token,
1378
1417
  fields: { account_id: accountId, scopes },
1379
- occurredAt: request.occurredAt ?? new Date().toISOString(),
1418
+ occurredAt: request.occurredAt ?? worldNow(),
1380
1419
  }, request.root);
1381
1420
  return ok({ data: { token, account_id: accountId, scopes } });
1382
1421
  }
@@ -1389,7 +1428,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
1389
1428
  return invalidRequestProblem({ author_id: [String(authorId ?? '')] }, 'A seeded post must name an account seeded through /_twin/accounts.');
1390
1429
  }
1391
1430
  const id = mintSnowflakeId(all);
1392
- const createdAt = request.occurredAt ?? new Date().toISOString();
1431
+ const createdAt = request.occurredAt ?? worldNow();
1393
1432
  await applyTwinWrite(SERVICE, {
1394
1433
  operation: 'x.twin.seed_post',
1395
1434
  subjectType: 'post',
@@ -1416,7 +1455,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
1416
1455
  subjectType: 'follow',
1417
1456
  subjectId: `${followerId}:${followedId}`,
1418
1457
  fields: { follower_id: followerId, followed_id: followedId },
1419
- occurredAt: request.occurredAt ?? new Date().toISOString(),
1458
+ occurredAt: request.occurredAt ?? worldNow(),
1420
1459
  }, request.root);
1421
1460
  return ok({ data: { follower_id: followerId, followed_id: followedId } });
1422
1461
  }
@@ -1430,7 +1469,7 @@ async function handleTwinControl(request: XRequest, all: Resource[], path: strin
1430
1469
  subjectType: 'rate_limit',
1431
1470
  subjectId: 'armed',
1432
1471
  fields: { armed: payload.armed !== false, reset: resetAt, limit: typeof payload.limit === 'number' ? payload.limit : 200 },
1433
- occurredAt: request.occurredAt ?? new Date().toISOString(),
1472
+ occurredAt: request.occurredAt ?? worldNow(),
1434
1473
  }, request.root);
1435
1474
  return ok({ data: { armed: payload.armed !== false, reset: resetAt } });
1436
1475
  }
@@ -1485,7 +1524,7 @@ export async function handleXTwinRequest(request: XRequest): Promise<XResponse>
1485
1524
  const refusal = armedRateRefusal(all);
1486
1525
  if (refusal) return refusal;
1487
1526
 
1488
- const auth = authorize(all, request.headers);
1527
+ const auth = authorize(all, request);
1489
1528
  if (isResponse(auth)) return auth;
1490
1529
  const publicBase = (request.publicBase ?? PBS_BASE).replace(/\/+$/, '');
1491
1530