@volter/twin-x 0.1.0
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/LICENSE +202 -0
- package/README.md +138 -0
- package/dist/client/x-mirror.bundle.js +321 -0
- package/dist/client/x-mirror.d.ts +45 -0
- package/dist/client/x-mirror.js +417 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +29 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +68 -0
- package/dist/src/x-budget.d.ts +54 -0
- package/dist/src/x-budget.js +123 -0
- package/dist/src/x-capabilities.d.ts +3 -0
- package/dist/src/x-capabilities.js +1106 -0
- package/dist/src/x-conformance.d.ts +8 -0
- package/dist/src/x-conformance.js +91 -0
- package/dist/src/x-connector.d.ts +125 -0
- package/dist/src/x-connector.js +546 -0
- package/dist/src/x-media.d.ts +87 -0
- package/dist/src/x-media.js +275 -0
- package/dist/src/x-mirror-ui.d.ts +61 -0
- package/dist/src/x-mirror-ui.js +253 -0
- package/dist/src/x-problems.d.ts +38 -0
- package/dist/src/x-problems.js +130 -0
- package/dist/src/x-scopes.d.ts +7 -0
- package/dist/src/x-scopes.js +62 -0
- package/dist/src/x-server.d.ts +14 -0
- package/dist/src/x-server.js +127 -0
- package/dist/src/x-twin.d.ts +21 -0
- package/dist/src/x-twin.js +1534 -0
- package/package.json +58 -0
- package/src/cli.ts +27 -0
- package/src/index.ts +132 -0
- package/src/x-budget.ts +150 -0
- package/src/x-capabilities.ts +1161 -0
- package/src/x-conformance.ts +113 -0
- package/src/x-connector.ts +546 -0
- package/src/x-media.ts +295 -0
- package/src/x-mirror-ui.ts +263 -0
- package/src/x-problems.ts +143 -0
- package/src/x-scopes.ts +67 -0
- package/src/x-server.ts +126 -0
- package/src/x-twin.ts +1545 -0
package/src/x-twin.ts
ADDED
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
// The X (Twitter) API v2 POSTING surface, twinned: an org's public voice can post, quote, reply
|
|
2
|
+
// to a mention, retract a post, look a post back up, read its own and its mentions' timelines,
|
|
3
|
+
// and search the corpus — offline, against local state.
|
|
4
|
+
//
|
|
5
|
+
// SCOPE. The demo this pack exists for (DEMO-AUTONOMOUS-ORG §9b) names one job: "the org's
|
|
6
|
+
// public voice posts through the X pack's boundary", and §5 graduates mention-REPLIES before
|
|
7
|
+
// ORIGINAL POSTS. That is the write half. The READ half exists because a public voice that
|
|
8
|
+
// cannot read back what it said has no way to check itself: post lookup, the user timeline, the
|
|
9
|
+
// home timeline and recent search all fold the same projection the writes append to. A post can
|
|
10
|
+
// carry IMAGES or a VIDEO: `POST /2/media/upload` (one-shot, or initialize/append/finalize, with a
|
|
11
|
+
// video's processing walked through STATUS) stages the bytes (x-media.ts — not a kernel action),
|
|
12
|
+
// and the post that attaches the media id records each one by key, id and digest. Spaces, lists,
|
|
13
|
+
// DMs, GIFs, likes/reposts/bookmarks and streaming are real
|
|
14
|
+
// X surface this pack does not model; they stay `unmapped` in x-spec-census.json rather than
|
|
15
|
+
// absent, which is what a denominator sensor is for.
|
|
16
|
+
//
|
|
17
|
+
// THE ONE THING THIS FILE EXISTS TO GET RIGHT. An original post, a QUOTE and a reply are the
|
|
18
|
+
// SAME HTTP operation at X — `POST /2/tweets`, differing only by a field in the body (grounded
|
|
19
|
+
// from the pinned `twitter-api-v2@1.29.1` client's own source: `tweet()` posts `{text}` to
|
|
20
|
+
// `tweets`, `reply()` posts `{text, reply:{in_reply_to_tweet_id}}` and `quote()` posts
|
|
21
|
+
// `{text, quote_tweet_id}` to that same `tweets`; dist/cjs/v2/client.v2.write.js:89, :180, :187 —
|
|
22
|
+
// see test-fixtures/x-openapi-operations.SOURCE.md). This handler therefore dispatches on those
|
|
23
|
+
// body fields, and the census (`x-spec-census.json`, requestKey "method+path+body-field") names
|
|
24
|
+
// the reply/original split as two capability ids so a grant can cover one and refuse the other.
|
|
25
|
+
// The discriminator is the PACK'S OWN, not a new vocabulary invented for the census: it is the
|
|
26
|
+
// field this file branches on to decide what to write. (A QUOTE is an ORIGINAL post for that
|
|
27
|
+
// split — the org speaking under its own name, not answering someone — so it lands on the
|
|
28
|
+
// census's `x.tweets.create` side rather than adding a third entry to a two-sided partition.)
|
|
29
|
+
//
|
|
30
|
+
// FIELD PROJECTION IS NOT DECORATION. X returns `id`, `text` and `edit_history_tweet_ids` and
|
|
31
|
+
// NOTHING ELSE unless the caller asks: `author_id`, `created_at`, `conversation_id`,
|
|
32
|
+
// `referenced_tweets` and the rest arrive only under `tweet.fields`, and related objects only
|
|
33
|
+
// under `expansions`. A twin that returns everything always is not more generous, it is wrong —
|
|
34
|
+
// an integrator whose client forgets `tweet.fields` passes here and breaks in production. So the
|
|
35
|
+
// default projection is the vendor's three fields, and `projectPost` is the ONE place a stored
|
|
36
|
+
// post becomes a response body.
|
|
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.
|
|
41
|
+
//
|
|
42
|
+
// DETERMINISM. Every served response is a pure function of (request, stored state). Nothing on
|
|
43
|
+
// the read path reads a clock: `created_at` is what the WRITE stored, and the `next_token` a page
|
|
44
|
+
// 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 {
|
|
47
|
+
forbiddenProblem,
|
|
48
|
+
invalidRequestProblem,
|
|
49
|
+
lookupResult,
|
|
50
|
+
notFoundProblem,
|
|
51
|
+
rateLimitExceeded,
|
|
52
|
+
readOnlyRefusal,
|
|
53
|
+
resourceNotFoundError,
|
|
54
|
+
resourceNotFoundProblem,
|
|
55
|
+
unauthorizedProblem,
|
|
56
|
+
type XResponse,
|
|
57
|
+
} from './x-problems.ts';
|
|
58
|
+
import { missingScopes, parseScopeList, REQUIRED_SCOPES } from './x-scopes.ts';
|
|
59
|
+
import {
|
|
60
|
+
clearSegments,
|
|
61
|
+
listMediaIds,
|
|
62
|
+
MAX_IMAGE_BYTES,
|
|
63
|
+
MAX_TWEET_VIDEO_MS,
|
|
64
|
+
MAX_VIDEO_BYTES,
|
|
65
|
+
MEDIA_EXPIRES_AFTER_SECS,
|
|
66
|
+
mediaExtension,
|
|
67
|
+
putSegment,
|
|
68
|
+
putXBlob,
|
|
69
|
+
readMediaRecord,
|
|
70
|
+
readSegments,
|
|
71
|
+
sniffImage,
|
|
72
|
+
sniffVideo,
|
|
73
|
+
writeMediaRecord,
|
|
74
|
+
type XMediaRecord,
|
|
75
|
+
} from './x-media.ts';
|
|
76
|
+
|
|
77
|
+
const SERVICE = 'x';
|
|
78
|
+
|
|
79
|
+
/** X's post ids are snowflakes. A minted id must never collide with one PULLED from the real
|
|
80
|
+
* vendor, and it must never be derived from a ROW COUNT (a count-mint silently clobbers a
|
|
81
|
+
* pulled id sitting in a gap above the count — ADDING_A_TWIN.md §5). So: scan the id SET
|
|
82
|
+
* already in state and step past its maximum, starting above today's real snowflake space. */
|
|
83
|
+
const ID_FLOOR = 9_000_000_000_000_000_000n;
|
|
84
|
+
|
|
85
|
+
/** X's default post length. An account on X Premium may exceed it, so the twin carries that
|
|
86
|
+
* entitlement as a seeded per-account number rather than pretending every account is the same.
|
|
87
|
+
* The COUNT here is code points; X's WEIGHTED count (CJK and emoji cost two) is a documented
|
|
88
|
+
* refinement this build does not model and files as `x.errors.text_length_weighting`. */
|
|
89
|
+
const DEFAULT_POST_CHARACTER_LIMIT = 280;
|
|
90
|
+
const MAX_POST_CHARACTER_LIMIT = 25_000;
|
|
91
|
+
|
|
92
|
+
export type XRequest = {
|
|
93
|
+
method: string;
|
|
94
|
+
path: string;
|
|
95
|
+
body?: string;
|
|
96
|
+
headers?: Record<string, string>;
|
|
97
|
+
readOnly?: boolean;
|
|
98
|
+
occurredAt?: string;
|
|
99
|
+
root?: string;
|
|
100
|
+
/** A multipart body, parsed by the server (x-server.ts): its text fields and the `media` part's
|
|
101
|
+
* bytes. The JSON adapter reads bodies as text, which would corrupt binary; so a multipart
|
|
102
|
+
* upload arrives here instead of in `body`. */
|
|
103
|
+
form?: { fields: Record<string, string>; media?: Uint8Array };
|
|
104
|
+
/** Where this twin is reached (`twinPublicBase`: origin plus any World mount path). A media `url`
|
|
105
|
+
* is minted from it; a direct handler call with none mints X's own pbs.twimg.com form. */
|
|
106
|
+
publicBase?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type Resource = Record<string, any>;
|
|
110
|
+
|
|
111
|
+
function ok(body: unknown, headers: Record<string, string> = {}): XResponse {
|
|
112
|
+
return { status: 200, body, headers: { 'content-type': 'application/json', ...headers } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseJsonBody(body?: string): { object: Record<string, any> } | { malformed: true } {
|
|
116
|
+
if (body === undefined || body.trim() === '') return { object: {} };
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(body);
|
|
119
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { malformed: true };
|
|
120
|
+
return { object: parsed as Record<string, any> };
|
|
121
|
+
} catch {
|
|
122
|
+
return { malformed: true };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resources(root?: string): Resource[] {
|
|
127
|
+
return projectResources(SERVICE, root) as unknown as Resource[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function ofType(all: Resource[], type: string): Resource[] {
|
|
131
|
+
return all.filter((r) => r.type === type);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// X draws every object id from ONE snowflake space: an account and a post never share an id,
|
|
135
|
+
// and a client that resolves `author_id` against a post id would be reading the wrong object.
|
|
136
|
+
// So the high-water mark is taken across every seeded resource, not just posts — scanning one
|
|
137
|
+
// type while minting for two let a seeded account collide with an earlier account AND with a
|
|
138
|
+
// post, and the mentions timeline for that id then answered about the wrong subject.
|
|
139
|
+
function mintSnowflakeId(all: Resource[], alsoTaken: string[] = []): string {
|
|
140
|
+
let max = ID_FLOOR;
|
|
141
|
+
for (const id of [...all.map((r) => String(r.id)), ...alsoTaken]) {
|
|
142
|
+
const asBigInt = /^\d+$/.test(id) ? BigInt(id) : 0n;
|
|
143
|
+
if (asBigInt > max) max = asBigInt;
|
|
144
|
+
}
|
|
145
|
+
return String(max + 1n);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const livePosts = (all: Resource[]): Resource[] => ofType(all, 'post').filter((p) => p.deleted !== true);
|
|
149
|
+
const findPost = (all: Resource[], id: string): Resource | undefined => livePosts(all).find((p) => String(p.id) === id);
|
|
150
|
+
const findAccount = (all: Resource[], id: string): Resource | undefined => ofType(all, 'account').find((a) => String(a.id) === id);
|
|
151
|
+
|
|
152
|
+
/** Newest first, by snowflake id — X orders every timeline by id, not by a parsed timestamp. */
|
|
153
|
+
function newestFirst(posts: Resource[]): Resource[] {
|
|
154
|
+
return [...posts].sort((a, b) => (BigInt(String(b.id)) > BigInt(String(a.id)) ? 1 : -1));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── auth ────────────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
// A registered token resolves to the account it acts for and the scopes it carries. An
|
|
159
|
+
// unregistered, malformed or absent bearer is the vendor's about:blank 401 — never a local
|
|
160
|
+
// exception, and never a pass.
|
|
161
|
+
|
|
162
|
+
type Authorized = { accountId: string; scopes: string[]; token: string };
|
|
163
|
+
|
|
164
|
+
function bearerFrom(headers: Record<string, string> | undefined): string | undefined {
|
|
165
|
+
if (!headers) return undefined;
|
|
166
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
167
|
+
if (name.toLowerCase() !== 'authorization') continue;
|
|
168
|
+
const match = /^Bearer\s+(.+)$/i.exec(value.trim());
|
|
169
|
+
return match?.[1]?.trim();
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function authorize(all: Resource[], headers: Record<string, string> | undefined): Authorized | XResponse {
|
|
175
|
+
const token = bearerFrom(headers);
|
|
176
|
+
if (!token) return unauthorizedProblem();
|
|
177
|
+
const row = ofType(all, 'token').find((t) => String(t.id) === token);
|
|
178
|
+
if (!row) return unauthorizedProblem();
|
|
179
|
+
return { accountId: String(row.account_id), scopes: Array.isArray(row.scopes) ? row.scopes.map(String) : [], token };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const isResponse = (value: Authorized | XResponse): value is XResponse => 'status' in value;
|
|
183
|
+
|
|
184
|
+
function scopeRefusal(auth: Authorized, operation: keyof typeof REQUIRED_SCOPES): XResponse | undefined {
|
|
185
|
+
const missing = missingScopes(auth.scopes, REQUIRED_SCOPES[operation]);
|
|
186
|
+
if (missing.length === 0) return undefined;
|
|
187
|
+
return forbiddenProblem(`This request requires the ${missing.join(', ')} scope(s), which this token does not hold.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── tweet.fields / expansions: the projection X actually serves ──────────────────────────────
|
|
191
|
+
//
|
|
192
|
+
// Two closed sets, written as LITERALS. X documents exactly which values `tweet.fields` and
|
|
193
|
+
// `expansions` accept, so an allowlist is a real oracle rather than the twin agreeing with
|
|
194
|
+
// itself: a value outside the documented set is the vendor's own invalid-request refusal, and a
|
|
195
|
+
// value INSIDE it that this twin holds no state for is refused BY NAME rather than silently
|
|
196
|
+
// omitted. Silence would be the worse failure — a caller that asked for `public_metrics` and got
|
|
197
|
+
// a post without it cannot tell "this twin does not model metrics" from "this post has none".
|
|
198
|
+
// (ADDING_A_TWIN.md §6: only an option the endpoint actually DECLARES may be an unmodelled
|
|
199
|
+
// option. Each unmodelled value below has its own manifest todo: x.fields.entities,
|
|
200
|
+
// x.fields.public_metrics, x.fields.user_fields, x.tweets.reply_settings, x.tweets.geo_place, …)
|
|
201
|
+
|
|
202
|
+
const TWEET_FIELDS_DOCUMENTED = new Set([
|
|
203
|
+
'attachments', 'author_id', 'card_uri', 'context_annotations', 'conversation_id', 'created_at',
|
|
204
|
+
'edit_controls', 'edit_history_tweet_ids', 'entities', 'geo', 'id', 'in_reply_to_user_id', 'lang',
|
|
205
|
+
'non_public_metrics', 'note_tweet', 'organic_metrics', 'possibly_sensitive', 'promoted_metrics',
|
|
206
|
+
'public_metrics', 'referenced_tweets', 'reply_settings', 'scopes', 'source', 'text', 'withheld',
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
/** The subset the twin holds state for. `id`, `text` and `edit_history_tweet_ids` are in X's
|
|
210
|
+
* DEFAULT projection and are served whether or not they are asked for, exactly as at the vendor. */
|
|
211
|
+
const TWEET_FIELDS_MODELLED = new Set([
|
|
212
|
+
'attachments', 'author_id', 'conversation_id', 'created_at', 'edit_history_tweet_ids', 'id', 'in_reply_to_user_id',
|
|
213
|
+
'referenced_tweets', 'text',
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
const EXPANSIONS_DOCUMENTED = new Set([
|
|
217
|
+
'attachments.media_keys', 'attachments.poll_ids', 'author_id', 'edit_history_tweet_ids',
|
|
218
|
+
'entities.mentions.username', 'geo.place_id', 'in_reply_to_user_id', 'referenced_tweets.id',
|
|
219
|
+
'referenced_tweets.id.author_id',
|
|
220
|
+
]);
|
|
221
|
+
|
|
222
|
+
const EXPANSIONS_MODELLED = new Set(['attachments.media_keys', 'author_id', 'in_reply_to_user_id', 'referenced_tweets.id', 'referenced_tweets.id.author_id']);
|
|
223
|
+
|
|
224
|
+
/** An expansion RETURNS its source field on the post as well as the expanded object in
|
|
225
|
+
* `includes` — asking to expand `author_id` and getting back a post with no `author_id` would
|
|
226
|
+
* leave the sidecar unjoinable. */
|
|
227
|
+
const EXPANSION_IMPLIES_FIELD: Record<string, string> = {
|
|
228
|
+
'attachments.media_keys': 'attachments',
|
|
229
|
+
author_id: 'author_id',
|
|
230
|
+
in_reply_to_user_id: 'in_reply_to_user_id',
|
|
231
|
+
'referenced_tweets.id': 'referenced_tweets',
|
|
232
|
+
'referenced_tweets.id.author_id': 'referenced_tweets',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type ReadShape = { fields: Set<string>; expansions: Set<string>; userFields: Set<string>; mediaFields: Set<string>; publicBase: string };
|
|
236
|
+
|
|
237
|
+
// `media.fields`: the documented set is docs.x.com's Media object (fundamentals/data-dictionary);
|
|
238
|
+
// `media_key` and `type` are its DEFAULT projection. The modelled subset is what an uploaded image
|
|
239
|
+
// or video has state for — a photo's `url`, `width`, `height`; a video's `duration_ms`,
|
|
240
|
+
// `preview_image_url` and `variants` (whose answer for a photo is X's own: absent). `alt_text`
|
|
241
|
+
// (the metadata endpoint) and every metrics field have no state here and are refused by name.
|
|
242
|
+
const MEDIA_FIELDS_DOCUMENTED = new Set([
|
|
243
|
+
'alt_text', 'duration_ms', 'height', 'media_key', 'non_public_metrics', 'organic_metrics', 'preview_image_url',
|
|
244
|
+
'promoted_metrics', 'public_metrics', 'type', 'url', 'variants', 'width',
|
|
245
|
+
]);
|
|
246
|
+
const MEDIA_FIELDS_MODELLED = new Set(['duration_ms', 'height', 'media_key', 'preview_image_url', 'type', 'url', 'variants', 'width']);
|
|
247
|
+
|
|
248
|
+
/** The hosts X serves media from — what a direct handler call (no served base) mints: images and
|
|
249
|
+
* video thumbnails on pbs.twimg.com, video files on video.twimg.com. Served over HTTP, every one
|
|
250
|
+
* of them is this twin's own base instead (x-server.ts serves the same paths). */
|
|
251
|
+
const PBS_BASE = 'https://pbs.twimg.com';
|
|
252
|
+
const VIDEO_BASE = 'https://video.twimg.com';
|
|
253
|
+
|
|
254
|
+
// `user.fields`: the documented set is the pinned client's own `TTweetv2UserField = keyof UserV2`
|
|
255
|
+
// (twitter-api-v2@1.29.1 dist/cjs/types/v2/user.v2.types.d.ts), the same grounding as the routes.
|
|
256
|
+
// `id`, `name` and `username` are X's DEFAULT user object and are served whether asked or not.
|
|
257
|
+
const USER_FIELDS_DOCUMENTED = new Set([
|
|
258
|
+
'affiliation', 'confirmed_email', 'connection_status', 'created_at', 'description', 'entities', 'id',
|
|
259
|
+
'is_identity_verified', 'location', 'most_recent_tweet_id', 'name', 'parody', 'pinned_tweet_id',
|
|
260
|
+
'profile_banner_url', 'profile_image_url', 'protected', 'public_metrics', 'receives_your_dm',
|
|
261
|
+
'subscription', 'subscription_type', 'url', 'username', 'verified', 'verified_followers_count',
|
|
262
|
+
'verified_type', 'withheld',
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
/** The subset the twin holds state for: what `/_twin/accounts` seeds, plus `most_recent_tweet_id`,
|
|
266
|
+
* which is a pure function of the account's live posts. `public_metrics` is NOT here: two of its
|
|
267
|
+
* counts (listed, like) have no state behind them, and a zero the twin cannot know is a fabrication. */
|
|
268
|
+
const USER_FIELDS_MODELLED = new Set([
|
|
269
|
+
'created_at', 'description', 'id', 'location', 'most_recent_tweet_id', 'name', 'protected', 'url', 'username', 'verified',
|
|
270
|
+
]);
|
|
271
|
+
|
|
272
|
+
/** Read `user.fields` off the query string, refusing the way `tweet.fields` refuses. */
|
|
273
|
+
function parseUserFields(url: URL): Set<string> | XResponse {
|
|
274
|
+
const userFields = new Set<string>();
|
|
275
|
+
for (const value of csv(url.searchParams.get('user.fields'))) {
|
|
276
|
+
if (!USER_FIELDS_DOCUMENTED.has(value)) {
|
|
277
|
+
return invalidRequestProblem({ 'user.fields': [value] }, `The \`user.fields\` query parameter value [${value}] is not one of the documented User fields`);
|
|
278
|
+
}
|
|
279
|
+
if (!USER_FIELDS_MODELLED.has(value)) return unmodelledOption('user.fields', value);
|
|
280
|
+
userFields.add(value);
|
|
281
|
+
}
|
|
282
|
+
return userFields;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isFieldSet = (value: Set<string> | XResponse): value is Set<string> => value instanceof Set;
|
|
286
|
+
|
|
287
|
+
function csv(raw: string | null): string[] {
|
|
288
|
+
if (raw === null) return [];
|
|
289
|
+
return raw.split(',').map((v) => v.trim()).filter((v) => v !== '');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function unmodelledOption(parameter: string, value: string): XResponse {
|
|
293
|
+
return invalidRequestProblem(
|
|
294
|
+
{ [parameter]: [value] },
|
|
295
|
+
`The \`${parameter}\` value [${value}] is real X surface this twin does not model. It is refused rather than silently dropped, so a caller never mistakes an unmodelled field for an absent value.`,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Read `tweet.fields` + `expansions` off the query string, or refuse the way X refuses. */
|
|
300
|
+
function parseReadShape(url: URL, publicBase: string): ReadShape | XResponse {
|
|
301
|
+
const fields = new Set<string>();
|
|
302
|
+
for (const value of csv(url.searchParams.get('tweet.fields'))) {
|
|
303
|
+
if (!TWEET_FIELDS_DOCUMENTED.has(value)) {
|
|
304
|
+
return invalidRequestProblem({ 'tweet.fields': [value] }, `The \`tweet.fields\` query parameter value [${value}] is not one of the documented Post fields`);
|
|
305
|
+
}
|
|
306
|
+
if (!TWEET_FIELDS_MODELLED.has(value)) return unmodelledOption('tweet.fields', value);
|
|
307
|
+
fields.add(value);
|
|
308
|
+
}
|
|
309
|
+
const expansions = new Set<string>();
|
|
310
|
+
for (const value of csv(url.searchParams.get('expansions'))) {
|
|
311
|
+
if (!EXPANSIONS_DOCUMENTED.has(value)) {
|
|
312
|
+
return invalidRequestProblem({ expansions: [value] }, `The \`expansions\` query parameter value [${value}] is not one of the documented Post expansions`);
|
|
313
|
+
}
|
|
314
|
+
if (!EXPANSIONS_MODELLED.has(value)) return unmodelledOption('expansions', value);
|
|
315
|
+
expansions.add(value);
|
|
316
|
+
const implied = EXPANSION_IMPLIES_FIELD[value];
|
|
317
|
+
if (implied !== undefined) fields.add(implied);
|
|
318
|
+
}
|
|
319
|
+
// `user.fields` shapes the users in `includes`, with the same closed-set refusal as the user
|
|
320
|
+
// lookup routes: one parser, so the two can never disagree about a field.
|
|
321
|
+
const userFields = parseUserFields(url);
|
|
322
|
+
if (!isFieldSet(userFields)) return userFields;
|
|
323
|
+
const mediaFields = new Set<string>();
|
|
324
|
+
for (const value of csv(url.searchParams.get('media.fields'))) {
|
|
325
|
+
if (!MEDIA_FIELDS_DOCUMENTED.has(value)) {
|
|
326
|
+
return invalidRequestProblem({ 'media.fields': [value] }, `The \`media.fields\` query parameter value [${value}] is not one of the documented Media fields`);
|
|
327
|
+
}
|
|
328
|
+
if (!MEDIA_FIELDS_MODELLED.has(value)) return unmodelledOption('media.fields', value);
|
|
329
|
+
mediaFields.add(value);
|
|
330
|
+
}
|
|
331
|
+
return { fields, expansions, userFields, mediaFields, publicBase };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const isShape = (value: ReadShape | XResponse): value is ReadShape => !('status' in value);
|
|
335
|
+
|
|
336
|
+
/** THE ONE PLACE a stored post becomes a response body. Default projection = X's three fields. */
|
|
337
|
+
function projectPost(post: Resource, fields: Set<string>): Record<string, unknown> {
|
|
338
|
+
const out: Record<string, unknown> = {
|
|
339
|
+
id: String(post.id),
|
|
340
|
+
text: String(post.text),
|
|
341
|
+
edit_history_tweet_ids: [String(post.id)],
|
|
342
|
+
};
|
|
343
|
+
if (fields.has('author_id') && post.author_id !== undefined) out.author_id = String(post.author_id);
|
|
344
|
+
if (fields.has('created_at') && post.created_at !== undefined) out.created_at = String(post.created_at);
|
|
345
|
+
if (fields.has('conversation_id') && post.conversation_id !== undefined) out.conversation_id = String(post.conversation_id);
|
|
346
|
+
if (fields.has('in_reply_to_user_id') && post.in_reply_to_user_id !== undefined) out.in_reply_to_user_id = String(post.in_reply_to_user_id);
|
|
347
|
+
if (fields.has('referenced_tweets') && Array.isArray(post.referenced_tweets) && post.referenced_tweets.length > 0) out.referenced_tweets = post.referenced_tweets;
|
|
348
|
+
// X serves `attachments` only on a post that has some — a text-only post carries no empty object.
|
|
349
|
+
const mediaKeys = attachedMedia(post).map((m) => m.media_key);
|
|
350
|
+
if (fields.has('attachments') && mediaKeys.length > 0) out.attachments = { media_keys: mediaKeys };
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** One image or video a post carries, as its entry recorded it at create time (see createPost). */
|
|
355
|
+
type AttachedMedia = {
|
|
356
|
+
media_key: string; media_id: string; type: 'photo' | 'video'; media_type: string; media_category: string; sha256: string; size: number;
|
|
357
|
+
width?: number; height?: number; duration_ms?: number;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
function attachedMedia(post: Resource): AttachedMedia[] {
|
|
361
|
+
return Array.isArray(post.media) ? (post.media as AttachedMedia[]).filter((m) => m && typeof m.media_key === 'string') : [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** THE ONE PLACE an attached image becomes an `includes.media` object. Default = X's two fields. */
|
|
365
|
+
function projectMedia(media: AttachedMedia, mediaFields: Set<string>, publicBase: string): Record<string, unknown> {
|
|
366
|
+
const out: Record<string, unknown> = { media_key: media.media_key, type: media.type };
|
|
367
|
+
// `url` is a photo's; a video or GIF answers with `preview_image_url` instead. The path is
|
|
368
|
+
// pbs.twimg.com's `/media/<name>.<ext>`, served by this twin (x-server.ts) from the blob seam.
|
|
369
|
+
if (mediaFields.has('url') && media.type === 'photo') out.url = `${publicBase}/media/${media.sha256}.${mediaExtension(media.media_type)}`;
|
|
370
|
+
if (mediaFields.has('width') && typeof media.width === 'number') out.width = media.width;
|
|
371
|
+
if (mediaFields.has('height') && typeof media.height === 'number') out.height = media.height;
|
|
372
|
+
if (media.type === 'video') {
|
|
373
|
+
// video.twimg.com's `/ext_tw_video/<id>/pu/vid/avc1/<w>x<h>/<name>.mp4` and pbs's
|
|
374
|
+
// `/ext_tw_video_thumb/<id>/pu/img/<name>.<ext>` shapes, served by this twin.
|
|
375
|
+
const videoBase = publicBase === PBS_BASE ? VIDEO_BASE : publicBase;
|
|
376
|
+
const file = `${videoBase}/ext_tw_video/${media.media_id}/pu/vid/avc1/${media.width ?? 0}x${media.height ?? 0}/${media.sha256}.mp4`;
|
|
377
|
+
if (mediaFields.has('duration_ms') && typeof media.duration_ms === 'number') out.duration_ms = media.duration_ms;
|
|
378
|
+
if (mediaFields.has('preview_image_url')) out.preview_image_url = `${publicBase}/ext_tw_video_thumb/${media.media_id}/pu/img/${media.sha256}.svg`;
|
|
379
|
+
if (mediaFields.has('variants')) {
|
|
380
|
+
// ONE variant: the file as uploaded. X transcodes to an HLS playlist and several MP4 bit
|
|
381
|
+
// rates; the twin transcodes nothing, so it lists the one rendition it holds, with the bit
|
|
382
|
+
// rate that file actually has (bytes over duration), never an invented ladder.
|
|
383
|
+
const bitRate = typeof media.duration_ms === 'number' && media.duration_ms > 0 ? Math.round((media.size * 8 * 1000) / media.duration_ms) : undefined;
|
|
384
|
+
out.variants = [{ ...(bitRate !== undefined ? { bit_rate: bitRate } : {}), content_type: 'video/mp4', url: file }];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** THE ONE PLACE a stored account becomes a user object. Default projection = X's three fields;
|
|
391
|
+
* every other field only when asked for, and only when the twin holds state for it. */
|
|
392
|
+
function projectUser(account: Resource, userFields: Set<string>, all: Resource[]): Record<string, unknown> {
|
|
393
|
+
const out: Record<string, unknown> = { id: String(account.id), name: String(account.name ?? account.username), username: String(account.username) };
|
|
394
|
+
if (userFields.has('created_at') && typeof account.created_at === 'string') out.created_at = account.created_at;
|
|
395
|
+
if (userFields.has('description')) out.description = typeof account.description === 'string' ? account.description : '';
|
|
396
|
+
if (userFields.has('location') && typeof account.location === 'string' && account.location !== '') out.location = account.location;
|
|
397
|
+
if (userFields.has('url')) out.url = typeof account.url === 'string' ? account.url : '';
|
|
398
|
+
if (userFields.has('protected')) out.protected = account.protected === true;
|
|
399
|
+
if (userFields.has('verified')) out.verified = account.verified === true;
|
|
400
|
+
if (userFields.has('most_recent_tweet_id')) {
|
|
401
|
+
const newest = newestFirst(livePosts(all).filter((p) => String(p.author_id) === String(account.id)))[0];
|
|
402
|
+
if (newest !== undefined) out.most_recent_tweet_id = String(newest.id);
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The `includes` sidecar for a page of posts. Ordering is FIXED (users by id ascending, tweets
|
|
409
|
+
* by id descending — the same order the page itself uses) so two identical requests over
|
|
410
|
+
* identical state serve identical bytes.
|
|
411
|
+
*/
|
|
412
|
+
function buildIncludes(rows: Resource[], all: Resource[], shape: ReadShape): Record<string, unknown> {
|
|
413
|
+
const includes: Record<string, unknown> = {};
|
|
414
|
+
const userIds = new Set<string>();
|
|
415
|
+
const tweetIds = new Set<string>();
|
|
416
|
+
for (const post of rows) {
|
|
417
|
+
if (shape.expansions.has('author_id') && post.author_id !== undefined) userIds.add(String(post.author_id));
|
|
418
|
+
if (shape.expansions.has('in_reply_to_user_id') && post.in_reply_to_user_id !== undefined) userIds.add(String(post.in_reply_to_user_id));
|
|
419
|
+
if (!Array.isArray(post.referenced_tweets)) continue;
|
|
420
|
+
for (const reference of post.referenced_tweets) {
|
|
421
|
+
const referencedId = String((reference as Record<string, unknown>)?.id ?? '');
|
|
422
|
+
if (referencedId === '') continue;
|
|
423
|
+
if (shape.expansions.has('referenced_tweets.id')) tweetIds.add(referencedId);
|
|
424
|
+
if (shape.expansions.has('referenced_tweets.id.author_id')) {
|
|
425
|
+
const referenced = findPost(all, referencedId);
|
|
426
|
+
if (referenced?.author_id !== undefined) userIds.add(String(referenced.author_id));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const tweets = [...tweetIds]
|
|
431
|
+
.map((id) => findPost(all, id))
|
|
432
|
+
.filter((p): p is Resource => p !== undefined)
|
|
433
|
+
.sort((a, b) => (BigInt(String(b.id)) > BigInt(String(a.id)) ? 1 : -1))
|
|
434
|
+
.map((p) => projectPost(p, shape.fields));
|
|
435
|
+
const users = [...userIds]
|
|
436
|
+
.map((id) => findAccount(all, id))
|
|
437
|
+
.filter((a): a is Resource => a !== undefined)
|
|
438
|
+
.sort((a, b) => (BigInt(String(a.id)) > BigInt(String(b.id)) ? 1 : -1))
|
|
439
|
+
.map((a) => projectUser(a, shape.userFields, all));
|
|
440
|
+
if (tweets.length > 0) includes.tweets = tweets;
|
|
441
|
+
if (users.length > 0) includes.users = users;
|
|
442
|
+
// Media in the order the page first mentions it, each key once.
|
|
443
|
+
if (shape.expansions.has('attachments.media_keys')) {
|
|
444
|
+
const seen = new Set<string>();
|
|
445
|
+
const media: Array<Record<string, unknown>> = [];
|
|
446
|
+
for (const post of rows) {
|
|
447
|
+
for (const m of attachedMedia(post)) {
|
|
448
|
+
if (seen.has(m.media_key)) continue;
|
|
449
|
+
seen.add(m.media_key);
|
|
450
|
+
media.push(projectMedia(m, shape.mediaFields, shape.publicBase));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (media.length > 0) includes.media = media;
|
|
454
|
+
}
|
|
455
|
+
return includes;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── timeline windowing and paging ────────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
type Window = { sinceId?: string; untilId?: string; startTime?: string; endTime?: string };
|
|
461
|
+
|
|
462
|
+
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* since_id / until_id / start_time / end_time, with X's own PRECEDENCE: an id bound and a time
|
|
466
|
+
* bound on the same side of the window cannot both apply, and the vendor says the id wins
|
|
467
|
+
* ("If included with the same request as a since_id parameter, only since_id will be used", and
|
|
468
|
+
* the mirror statement for until_id / end_time). Modelling that is the difference between a
|
|
469
|
+
* window and a guess — a twin that intersected all four would quietly return fewer rows than X.
|
|
470
|
+
*/
|
|
471
|
+
function parseWindow(url: URL): Window | XResponse {
|
|
472
|
+
const window: Window = {};
|
|
473
|
+
for (const name of ['since_id', 'until_id'] as const) {
|
|
474
|
+
const raw = url.searchParams.get(name);
|
|
475
|
+
if (raw === null) continue;
|
|
476
|
+
if (!/^\d+$/.test(raw)) return invalidRequestProblem({ [name]: [raw] }, `The \`${name}\` query parameter value [${raw}] is not a valid Post id`);
|
|
477
|
+
if (name === 'since_id') window.sinceId = raw;
|
|
478
|
+
else window.untilId = raw;
|
|
479
|
+
}
|
|
480
|
+
for (const name of ['start_time', 'end_time'] as const) {
|
|
481
|
+
const raw = url.searchParams.get(name);
|
|
482
|
+
if (raw === null) continue;
|
|
483
|
+
if (!ISO_8601.test(raw) || Number.isNaN(Date.parse(raw))) {
|
|
484
|
+
return invalidRequestProblem({ [name]: [raw] }, `The \`${name}\` query parameter value [${raw}] is not a valid ISO 8601 date (YYYY-MM-DDTHH:mm:ssZ)`);
|
|
485
|
+
}
|
|
486
|
+
if (name === 'start_time') window.startTime = raw;
|
|
487
|
+
else window.endTime = raw;
|
|
488
|
+
}
|
|
489
|
+
if (window.sinceId !== undefined) delete window.startTime;
|
|
490
|
+
if (window.untilId !== undefined) delete window.endTime;
|
|
491
|
+
return window;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const isWindow = (value: Window | XResponse): value is Window => !('status' in value);
|
|
495
|
+
|
|
496
|
+
function applyWindow(posts: Resource[], window: Window): Resource[] {
|
|
497
|
+
return posts.filter((post) => {
|
|
498
|
+
const id = BigInt(String(post.id));
|
|
499
|
+
// X EXCLUDES the bound itself on both id bounds.
|
|
500
|
+
if (window.sinceId !== undefined && id <= BigInt(window.sinceId)) return false;
|
|
501
|
+
if (window.untilId !== undefined && id >= BigInt(window.untilId)) return false;
|
|
502
|
+
const createdAt = typeof post.created_at === 'string' ? Date.parse(post.created_at) : Number.NaN;
|
|
503
|
+
if (window.startTime !== undefined && !(createdAt >= Date.parse(window.startTime))) return false;
|
|
504
|
+
if (window.endTime !== undefined && !(createdAt < Date.parse(window.endTime))) return false;
|
|
505
|
+
return true;
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* A pagination token that is a PURE FUNCTION OF STORED STATE: the id of the last row served on
|
|
511
|
+
* the page it came from, opaque on the wire the way X's is, carrying no clock and no entropy.
|
|
512
|
+
* The same page over the same state always hands back the same token.
|
|
513
|
+
*/
|
|
514
|
+
function encodeCursor(lastId: string): string {
|
|
515
|
+
return Buffer.from(`x:${lastId}`, 'utf8').toString('base64url');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function parseCursor(url: URL): { before?: string } | XResponse {
|
|
519
|
+
const raw = url.searchParams.get('pagination_token');
|
|
520
|
+
if (raw === null || raw === '') return {};
|
|
521
|
+
let decoded = '';
|
|
522
|
+
try {
|
|
523
|
+
decoded = Buffer.from(raw, 'base64url').toString('utf8');
|
|
524
|
+
} catch {
|
|
525
|
+
decoded = '';
|
|
526
|
+
}
|
|
527
|
+
const match = /^x:(\d+)$/.exec(decoded);
|
|
528
|
+
if (!match) return invalidRequestProblem({ pagination_token: [raw] }, `The \`pagination_token\` query parameter value [${raw}] is not a valid pagination token`);
|
|
529
|
+
return { before: match[1]! };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const isCursor = (value: { before?: string } | XResponse): value is { before?: string } => !('status' in value);
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Page size. The timeline endpoints bound it 5..100; recent SEARCH bounds it 10..100. Both
|
|
536
|
+
* default to 10. Out of range is the invalid-request envelope, never a silent clamp.
|
|
537
|
+
*/
|
|
538
|
+
function parsePageSize(url: URL, min: number, max: number): number | XResponse {
|
|
539
|
+
const raw = url.searchParams.get('max_results');
|
|
540
|
+
if (raw === null) return 10;
|
|
541
|
+
const parsed = Number(raw);
|
|
542
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
|
|
543
|
+
return invalidRequestProblem({ max_results: [raw] }, 'The `max_results` query parameter value [' + raw + '] is not between ' + min + ' and ' + max);
|
|
544
|
+
}
|
|
545
|
+
return parsed;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const isSize = (value: number | XResponse): value is number => typeof value === 'number';
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Turn a matched post set into the vendor's timeline envelope: the page, its `includes` sidecar,
|
|
552
|
+
* and the `meta` X actually serves. X OMITS `data` entirely on an empty page and answers with
|
|
553
|
+
* `meta` alone — a client that reads `response.data.length` breaks against the real vendor if the
|
|
554
|
+
* twin invents `[]`, so faithfulness here is the ABSENCE.
|
|
555
|
+
*/
|
|
556
|
+
function timelinePage(matched: Resource[], all: Resource[], shape: ReadShape, size: number, cursor: { before?: string }): XResponse {
|
|
557
|
+
const ordered = newestFirst(matched);
|
|
558
|
+
const afterCursor = cursor.before === undefined ? ordered : ordered.filter((p) => BigInt(String(p.id)) < BigInt(cursor.before!));
|
|
559
|
+
const page = afterCursor.slice(0, size);
|
|
560
|
+
if (page.length === 0) return ok({ meta: { result_count: 0 } });
|
|
561
|
+
const rows = page.map((p) => projectPost(p, shape.fields));
|
|
562
|
+
const includes = buildIncludes(page, all, shape);
|
|
563
|
+
const meta: Record<string, unknown> = {
|
|
564
|
+
result_count: rows.length,
|
|
565
|
+
newest_id: String(page[0]!.id),
|
|
566
|
+
oldest_id: String(page[page.length - 1]!.id),
|
|
567
|
+
};
|
|
568
|
+
// A next_token is served ONLY when a further row actually exists — an unconditional token
|
|
569
|
+
// walks a client into an empty page forever.
|
|
570
|
+
if (afterCursor.length > page.length) meta.next_token = encodeCursor(String(page[page.length - 1]!.id));
|
|
571
|
+
return ok({ data: rows, ...(Object.keys(includes).length > 0 ? { includes } : {}), meta });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Everything a timeline/search read needs off the query string, refused as one. */
|
|
575
|
+
type ReadRequest = { shape: ReadShape; window: Window; size: number; cursor: { before?: string } };
|
|
576
|
+
|
|
577
|
+
function parseReadRequest(url: URL, minSize: number, publicBase: string): ReadRequest | XResponse {
|
|
578
|
+
const shape = parseReadShape(url, publicBase);
|
|
579
|
+
if (!isShape(shape)) return shape;
|
|
580
|
+
const window = parseWindow(url);
|
|
581
|
+
if (!isWindow(window)) return window;
|
|
582
|
+
const size = parsePageSize(url, minSize, 100);
|
|
583
|
+
if (!isSize(size)) return size;
|
|
584
|
+
const cursor = parseCursor(url);
|
|
585
|
+
if (!isCursor(cursor)) return cursor;
|
|
586
|
+
return { shape, window, size, cursor };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const isReadRequest = (value: ReadRequest | XResponse): value is ReadRequest => !('status' in value);
|
|
590
|
+
|
|
591
|
+
/** X's mentions timeline is "posts mentioning this account". The twin reads the mention out of
|
|
592
|
+
* the post's own text, the way the vendor's entity extraction does, so a seeded post that says
|
|
593
|
+
* `@handle` IS a mention and nothing has to declare it separately. */
|
|
594
|
+
function mentionsHandle(text: string, username: string): boolean {
|
|
595
|
+
return new RegExp(`(^|[^A-Za-z0-9_])@${username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z0-9_]|$)`, 'i').test(text);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── the search query grammar this twin models ────────────────────────────────────────────────
|
|
599
|
+
//
|
|
600
|
+
// X's search grammar is large (boolean operators, grouping, negation, dozens of field operators)
|
|
601
|
+
// and this twin models a SUBSET: bare keywords and quoted phrases ANDed together, plus `from:`.
|
|
602
|
+
// Everything else is REFUSED, loudly, on the `query` parameter — because the alternative is
|
|
603
|
+
// worse than a refusal: silently ignoring `-spam` or `OR` would answer a query the twin did not
|
|
604
|
+
// honour, which is a fake success wearing a 200. The unmodelled remainder is
|
|
605
|
+
// `x.search.query_grammar` in the manifest, and stays a todo until it is really built.
|
|
606
|
+
|
|
607
|
+
type SearchTerm = { kind: 'text'; value: string } | { kind: 'from'; username: string };
|
|
608
|
+
|
|
609
|
+
function parseSearchQuery(query: string): { terms: SearchTerm[] } | { unsupported: string } {
|
|
610
|
+
const terms: SearchTerm[] = [];
|
|
611
|
+
const tokens = query.match(/"[^"]*"|\S+/g) ?? [];
|
|
612
|
+
for (const token of tokens) {
|
|
613
|
+
if (token.startsWith('"')) {
|
|
614
|
+
const phrase = token.slice(1, -1).trim();
|
|
615
|
+
if (phrase !== '') terms.push({ kind: 'text', value: phrase });
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (token === 'OR' || token.startsWith('-') || token.includes('(') || token.includes(')')) return { unsupported: token };
|
|
619
|
+
const from = /^from:([A-Za-z0-9_]{1,15})$/.exec(token);
|
|
620
|
+
if (from) {
|
|
621
|
+
terms.push({ kind: 'from', username: from[1]! });
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (token.includes(':')) return { unsupported: token };
|
|
625
|
+
terms.push({ kind: 'text', value: token });
|
|
626
|
+
}
|
|
627
|
+
return { terms };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* X's keyword operator matches TOKENS, not substrings. A substring match answers `cat` with a post
|
|
632
|
+
* that says `catalog` — a confident row for a query the vendor returns nothing for, which is worse
|
|
633
|
+
* than refusing the query outright. So both the post and the term are tokenised the same way, and
|
|
634
|
+
* a phrase matches a CONTIGUOUS run of tokens. `@handle` and `#hashtag` survive tokenisation as
|
|
635
|
+
* single tokens, because at X they are single entities.
|
|
636
|
+
*/
|
|
637
|
+
function searchTokens(text: string): string[] {
|
|
638
|
+
return text.toLowerCase().split(/[^a-z0-9_@#]+/i).filter((t) => t !== '');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function containsRun(haystack: string[], needle: string[]): boolean {
|
|
642
|
+
if (needle.length === 0) return true;
|
|
643
|
+
for (let i = 0; i + needle.length <= haystack.length; i += 1) {
|
|
644
|
+
if (needle.every((tok, k) => haystack[i + k] === tok)) return true;
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function matchesSearch(post: Resource, all: Resource[], terms: SearchTerm[]): boolean {
|
|
650
|
+
const tokens = searchTokens(String(post.text));
|
|
651
|
+
for (const term of terms) {
|
|
652
|
+
if (term.kind === 'text') {
|
|
653
|
+
if (!containsRun(tokens, searchTokens(term.value))) return false;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const author = findAccount(all, String(post.author_id));
|
|
657
|
+
if (!author || String(author.username).toLowerCase() !== term.username.toLowerCase()) return false;
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── the modelled operations ─────────────────────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
function postCharacterLimit(account: Resource | undefined): number {
|
|
665
|
+
const declared = account?.post_character_limit;
|
|
666
|
+
return typeof declared === 'number' && Number.isInteger(declared) && declared >= DEFAULT_POST_CHARACTER_LIMIT && declared <= MAX_POST_CHARACTER_LIMIT
|
|
667
|
+
? declared
|
|
668
|
+
: DEFAULT_POST_CHARACTER_LIMIT;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** POST /2/tweets' body is a CLOSED schema. The modelled fields are read below; every other field
|
|
672
|
+
* X documents is refused by name (a silently ignored `poll` would be a post X would not have
|
|
673
|
+
* made); anything else is not a parameter at all. */
|
|
674
|
+
const POST_FIELDS_MODELLED = new Set(['text', 'reply', 'quote_tweet_id', 'media']);
|
|
675
|
+
const POST_FIELDS_UNMODELLED = new Set([
|
|
676
|
+
'card_uri', 'community_id', 'direct_message_deep_link', 'edit_options', 'for_super_followers_only', 'geo', 'made_with_ai',
|
|
677
|
+
'nullcast', 'paid_partnership', 'poll', 'reply_settings', 'share_with_followers',
|
|
678
|
+
]);
|
|
679
|
+
const REPLY_FIELDS_UNMODELLED = new Set(['exclude_reply_user_ids', 'auto_populate_reply_metadata']);
|
|
680
|
+
|
|
681
|
+
/** A post id in a request body: a STRING matching ^[0-9]{1,19}$, as the schema types it. */
|
|
682
|
+
function postIdField(value: unknown, parameter: string): string | XResponse {
|
|
683
|
+
if (typeof value !== 'string' || !/^\d{1,19}$/.test(value)) {
|
|
684
|
+
return invalidRequestProblem({ [parameter]: [String(value)] }, `The \`${parameter}\` value [${String(value)}] is not a string matching ^[0-9]{1,19}$`);
|
|
685
|
+
}
|
|
686
|
+
return value;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function createPost(request: XRequest, all: Resource[], auth: Authorized, payload: Record<string, any>): Promise<XResponse> {
|
|
690
|
+
for (const key of Object.keys(payload)) {
|
|
691
|
+
if (POST_FIELDS_MODELLED.has(key)) continue;
|
|
692
|
+
if (POST_FIELDS_UNMODELLED.has(key)) return unmodelledOption(key, JSON.stringify(payload[key]));
|
|
693
|
+
return invalidRequestProblem({ [key]: [JSON.stringify(payload[key])] }, `The \`${key}\` field is not a parameter of this request.`);
|
|
694
|
+
}
|
|
695
|
+
if (payload.reply !== undefined && payload.reply !== null) {
|
|
696
|
+
if (typeof payload.reply !== 'object' || Array.isArray(payload.reply)) return invalidRequestProblem({ reply: [JSON.stringify(payload.reply)] }, 'The `reply` field must be an object with `in_reply_to_tweet_id`.');
|
|
697
|
+
for (const key of Object.keys(payload.reply)) {
|
|
698
|
+
if (key === 'in_reply_to_tweet_id') continue;
|
|
699
|
+
if (REPLY_FIELDS_UNMODELLED.has(key)) return unmodelledOption(`reply.${key}`, JSON.stringify(payload.reply[key]));
|
|
700
|
+
return invalidRequestProblem({ [`reply.${key}`]: [JSON.stringify(payload.reply[key])] }, `The \`reply.${key}\` field is not a parameter of this request.`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// MEDIA first: X's `text` is "required unless media is provided", so whether a post may be
|
|
704
|
+
// text-less depends on its media. Every id must name media THIS account may use, finished and
|
|
705
|
+
// unexpired; anything else is X's own refusal, "Your media IDs are invalid.", and nothing lands.
|
|
706
|
+
const attached = await resolveAttachedMedia(request, auth, payload.media);
|
|
707
|
+
if ('status' in attached) return attached;
|
|
708
|
+
const text = payload.text ?? (attached.media.length > 0 ? '' : undefined);
|
|
709
|
+
if (typeof text !== 'string' || (text.trim() === '' && attached.media.length === 0)) {
|
|
710
|
+
return invalidRequestProblem({ text: [String(text ?? '')] }, 'The `text` field is required when no other content is supplied.');
|
|
711
|
+
}
|
|
712
|
+
// THE DISCRIMINATOR. Its presence is what makes this request a reply rather than an original
|
|
713
|
+
// post, at the vendor and here. Everything downstream — which capability the census names,
|
|
714
|
+
// which conversation the post joins, whose mention was answered — follows from this one read.
|
|
715
|
+
const inReplyTo = payload.reply?.in_reply_to_tweet_id;
|
|
716
|
+
const isReply = inReplyTo !== undefined && inReplyTo !== null;
|
|
717
|
+
if (isReply) { const checked = postIdField(inReplyTo, 'reply.in_reply_to_tweet_id'); if (typeof checked !== 'string') return checked; }
|
|
718
|
+
// The QUOTE discriminator: a sibling top-level body field (client.v2.write.js:187). A quote is
|
|
719
|
+
// an ORIGINAL post that REFERENCES another, so it needs the same scope and starts its own
|
|
720
|
+
// conversation — the quoted post is cited, not answered.
|
|
721
|
+
const quoteOf = payload.quote_tweet_id;
|
|
722
|
+
const isQuote = quoteOf !== undefined && quoteOf !== null;
|
|
723
|
+
if (isQuote) { const checked = postIdField(quoteOf, 'quote_tweet_id'); if (typeof checked !== 'string') return checked; }
|
|
724
|
+
const refusal = scopeRefusal(auth, isReply ? 'tweets.reply' : 'tweets.create');
|
|
725
|
+
if (refusal) return refusal;
|
|
726
|
+
|
|
727
|
+
// LENGTH, before anything is written. A post the vendor would refuse must never land here.
|
|
728
|
+
const limit = postCharacterLimit(findAccount(all, auth.accountId));
|
|
729
|
+
const length = Array.from(text).length;
|
|
730
|
+
if (length > limit) {
|
|
731
|
+
return invalidRequestProblem(
|
|
732
|
+
{ text: [String(length)] },
|
|
733
|
+
`The \`text\` field is ${length} characters, over this account's ${limit}-character limit for a Post.`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
let conversationId: string | undefined;
|
|
738
|
+
let inReplyToUserId: string | undefined;
|
|
739
|
+
const referenced: Array<{ type: string; id: string }> = [];
|
|
740
|
+
if (isReply) {
|
|
741
|
+
const parent = findPost(all, String(inReplyTo));
|
|
742
|
+
if (!parent) return resourceNotFoundProblem(String(inReplyTo), 'tweet', 'reply.in_reply_to_tweet_id');
|
|
743
|
+
conversationId = String(parent.conversation_id ?? parent.id);
|
|
744
|
+
inReplyToUserId = String(parent.author_id);
|
|
745
|
+
referenced.push({ type: 'replied_to', id: String(parent.id) });
|
|
746
|
+
}
|
|
747
|
+
if (isQuote) {
|
|
748
|
+
const quoted = findPost(all, String(quoteOf));
|
|
749
|
+
if (!quoted) return resourceNotFoundProblem(String(quoteOf), 'tweet', 'quote_tweet_id');
|
|
750
|
+
referenced.push({ type: 'quoted', id: String(quoted.id) });
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// a post's id is the snowflake high-water mark + 1: timelines, since_id and pagination order by it
|
|
754
|
+
const id = mintSnowflakeId(all, await listMediaIds(request.root));
|
|
755
|
+
const createdAt = request.occurredAt ?? new Date().toISOString();
|
|
756
|
+
await applyTwinWrite(SERVICE, {
|
|
757
|
+
operation: isReply ? 'x.post.reply' : isQuote ? 'x.post.quote' : 'x.post.create',
|
|
758
|
+
subjectType: 'post',
|
|
759
|
+
subjectId: id,
|
|
760
|
+
fields: {
|
|
761
|
+
text,
|
|
762
|
+
author_id: auth.accountId,
|
|
763
|
+
created_at: createdAt,
|
|
764
|
+
conversation_id: conversationId ?? id,
|
|
765
|
+
...(inReplyToUserId ? { in_reply_to_user_id: inReplyToUserId } : {}),
|
|
766
|
+
...(referenced.length > 0 ? { referenced_tweets: referenced } : {}),
|
|
767
|
+
// The post's entry names each image by key, id and DIGEST: the digest is how the mirror's
|
|
768
|
+
// url and a deploy's upload find the bytes on the blob seam, with no staged record needed.
|
|
769
|
+
...(attached.media.length > 0 ? { media: attached.media } : {}),
|
|
770
|
+
deleted: false,
|
|
771
|
+
},
|
|
772
|
+
occurredAt: createdAt,
|
|
773
|
+
actor: { kind: 'agent', id: auth.accountId },
|
|
774
|
+
}, request.root);
|
|
775
|
+
|
|
776
|
+
// X's create response is deliberately thin: id, text, edit_history_tweet_ids. Reading the post
|
|
777
|
+
// back is a separate call, exactly as at the vendor.
|
|
778
|
+
return { status: 201, body: { data: { id, text, edit_history_tweet_ids: [id] } }, headers: { 'content-type': 'application/json' } };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function deletePost(request: XRequest, all: Resource[], auth: Authorized, id: string): Promise<XResponse> {
|
|
782
|
+
const refusal = scopeRefusal(auth, 'tweets.delete');
|
|
783
|
+
if (refusal) return refusal;
|
|
784
|
+
const post = findPost(all, id);
|
|
785
|
+
if (!post) return resourceNotFoundProblem(id, 'tweet', 'id');
|
|
786
|
+
// A public voice may only retract its OWN words. Deleting someone else's post is a refusal at
|
|
787
|
+
// the vendor and a refusal here.
|
|
788
|
+
if (String(post.author_id) !== auth.accountId) return forbiddenProblem('You are not permitted to delete this Post.');
|
|
789
|
+
await applyTwinWrite(SERVICE, {
|
|
790
|
+
operation: 'x.post.delete',
|
|
791
|
+
subjectType: 'post',
|
|
792
|
+
subjectId: id,
|
|
793
|
+
fields: { ...Object.fromEntries(Object.entries(post).filter(([k]) => k !== 'type' && k !== 'id' && k !== 'updatedAt')), deleted: true },
|
|
794
|
+
occurredAt: request.occurredAt ?? new Date().toISOString(),
|
|
795
|
+
actor: { kind: 'agent', id: auth.accountId },
|
|
796
|
+
}, request.root);
|
|
797
|
+
return ok({ data: { deleted: true } });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ── media: upload (one-shot and chunked), processing, and attachment ─────────────────────────
|
|
801
|
+
//
|
|
802
|
+
// docs.x.com/x-api/media: `POST /2/media/upload` takes `media` ("base64-encoded in JSON bodies,
|
|
803
|
+
// raw bytes in multipart bodies") and `media_category`; the chunked form is
|
|
804
|
+
// `POST /2/media/upload/initialize` {media_type, total_bytes, media_category},
|
|
805
|
+
// `POST /2/media/upload/{id}/append` {media, segment_index} and `POST /2/media/upload/{id}/finalize`,
|
|
806
|
+
// with `GET /2/media/upload?command=STATUS&media_id=` for the processing state. Every one needs
|
|
807
|
+
// `media.write`. The twin models STILL IMAGES (`tweet_image`, `dm_image`) and MP4 VIDEO
|
|
808
|
+
// (`tweet_video`, `amplify_video`); GIF, DM video and subtitle categories are documented X surface
|
|
809
|
+
// it refuses by name.
|
|
810
|
+
//
|
|
811
|
+
// PROCESSING. An image is usable the moment its bytes are whole. A video is not: FINALIZE answers
|
|
812
|
+
// `processing_info` {state: "pending", check_after_secs}, and the client polls STATUS until the
|
|
813
|
+
// state is `succeeded` (or `failed`). The twin walks the same states, one per STATUS read —
|
|
814
|
+
// pending → in_progress → succeeded — so a client that polls as X asks reaches `succeeded`, and a
|
|
815
|
+
// client that posts without polling is refused exactly as X refuses it. The step is kept on the
|
|
816
|
+
// upload's pointer record (not kernel state: nothing about it is ever performed). Time moves it
|
|
817
|
+
// too: one step per `check_after_secs` of World-clock time since FINALIZE, whichever is sooner —
|
|
818
|
+
// so a client that sleeps as told and then posts, without polling, is not refused.
|
|
819
|
+
|
|
820
|
+
const MEDIA_CATEGORIES_DOCUMENTED = new Set(['amplify_video', 'dm_gif', 'dm_image', 'dm_video', 'subtitles', 'tweet_gif', 'tweet_image', 'tweet_video']);
|
|
821
|
+
/** The one-shot upload's own enum: every category but `amplify_video`, which only initialize takes. */
|
|
822
|
+
const ONE_SHOT_CATEGORIES_DOCUMENTED = new Set([...MEDIA_CATEGORIES_DOCUMENTED].filter((c) => c !== 'amplify_video'));
|
|
823
|
+
/** The body fields each upload endpoint declares; any other is refused (the schemas are closed). */
|
|
824
|
+
const ONE_SHOT_FIELDS = new Set(['media', 'media_category', 'media_type', 'additional_owners', 'shared']);
|
|
825
|
+
const INITIALIZE_FIELDS = new Set(['media_type', 'total_bytes', 'media_category', 'additional_owners', 'shared']);
|
|
826
|
+
const APPEND_FIELDS = new Set(['media', 'segment_index']);
|
|
827
|
+
/** A post's `media` object: `media_ids` is modelled; `tagged_user_ids` and the Amplify fields are
|
|
828
|
+
* X's documented shape this twin does not model, refused by name; anything else is unknown. */
|
|
829
|
+
const POST_MEDIA_FIELDS_UNMODELLED = new Set(['tagged_user_ids', 'title', 'description', 'preview_media_id', 'embeddable', 'call_to_actions']);
|
|
830
|
+
const IMAGE_CATEGORIES = new Set(['tweet_image', 'dm_image']);
|
|
831
|
+
const VIDEO_CATEGORIES = new Set(['tweet_video', 'amplify_video']);
|
|
832
|
+
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
833
|
+
const VIDEO_TYPES = new Set(['video/mp4']);
|
|
834
|
+
const MEDIA_TYPES_DOCUMENTED = new Set([
|
|
835
|
+
'video/mp4', 'video/webm', 'video/mp2t', 'video/quicktime', 'text/srt', 'text/vtt', 'image/jpeg', 'image/gif', 'image/bmp',
|
|
836
|
+
'image/png', 'image/webp', 'image/pjpeg', 'image/tiff', 'model/gltf-binary', 'model/vnd.usdz+zip',
|
|
837
|
+
]);
|
|
838
|
+
|
|
839
|
+
const INVALID_MEDIA_IDS = 'Your media IDs are invalid.';
|
|
840
|
+
/** How long X tells a client to wait between STATUS reads while a video processes. */
|
|
841
|
+
const CHECK_AFTER_SECS = 1;
|
|
842
|
+
|
|
843
|
+
const isVideoCategory = (category: string): boolean => VIDEO_CATEGORIES.has(category);
|
|
844
|
+
|
|
845
|
+
function nowSeconds(request: XRequest): number {
|
|
846
|
+
return Math.floor(Date.parse(request.occurredAt ?? new Date().toISOString()) / 1000);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** A field of the upload body, from the multipart form or the JSON object. */
|
|
850
|
+
function uploadField(request: XRequest, payload: Record<string, any>, name: string): unknown {
|
|
851
|
+
if (request.form) return request.form.fields[name];
|
|
852
|
+
return payload[name];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** The `media` bytes: the multipart part as sent, or the JSON body's base64 decoded. */
|
|
856
|
+
function uploadBytes(request: XRequest, payload: Record<string, any>): Uint8Array | XResponse {
|
|
857
|
+
if (request.form) {
|
|
858
|
+
if (request.form.media && request.form.media.length > 0) return request.form.media;
|
|
859
|
+
return invalidRequestProblem({ media: [''] }, 'The `media` field is required.');
|
|
860
|
+
}
|
|
861
|
+
const raw = payload.media;
|
|
862
|
+
if (typeof raw !== 'string' || raw.trim() === '') return invalidRequestProblem({ media: [String(raw ?? '')] }, 'The `media` field is required.');
|
|
863
|
+
const clean = raw.replace(/\s+/g, '');
|
|
864
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(clean)) return invalidRequestProblem({ media: ['(not base64)'] }, 'The `media` field of a JSON body must be base64-encoded bytes.');
|
|
865
|
+
return new Uint8Array(Buffer.from(clean, 'base64'));
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function parseCategory(value: unknown, documented: Set<string> = MEDIA_CATEGORIES_DOCUMENTED): string | XResponse {
|
|
869
|
+
if (typeof value !== 'string' || value === '') return invalidRequestProblem({ media_category: [String(value ?? '')] }, 'The `media_category` field is required.');
|
|
870
|
+
if (!documented.has(value)) {
|
|
871
|
+
return invalidRequestProblem({ media_category: [value] }, `The \`media_category\` value [${value}] is not one of [${[...documented].join(', ')}]`);
|
|
872
|
+
}
|
|
873
|
+
if (!IMAGE_CATEGORIES.has(value) && !VIDEO_CATEGORIES.has(value)) return unmodelledOption('media_category', value);
|
|
874
|
+
return value;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** `additional_owners`: comma-separated on the one-shot, an array on initialize. */
|
|
878
|
+
function parseOwners(value: unknown): string[] | XResponse {
|
|
879
|
+
if (value === undefined || value === null || value === '') return [];
|
|
880
|
+
const list = Array.isArray(value) ? value.map(String) : String(value).split(',').map((v) => v.trim()).filter((v) => v !== '');
|
|
881
|
+
for (const id of list) {
|
|
882
|
+
if (!/^\d{1,19}$/.test(id)) return invalidRequestProblem({ additional_owners: [id] }, `The \`additional_owners\` value [${id}] does not match ^[0-9]{1,19}$`);
|
|
883
|
+
}
|
|
884
|
+
return list;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const isOwners = (value: string[] | XResponse): value is string[] => Array.isArray(value);
|
|
888
|
+
|
|
889
|
+
/** A field the endpoint's closed schema does not declare — the vendor's invalid-request refusal. */
|
|
890
|
+
function unknownField(present: string[], allowed: Set<string>): XResponse | undefined {
|
|
891
|
+
const extra = present.find((name) => !allowed.has(name));
|
|
892
|
+
return extra === undefined ? undefined : invalidRequestProblem({ [extra]: [extra] }, `The \`${extra}\` field is not a parameter of this request.`);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function bodyFieldNames(request: XRequest, payload: Record<string, any>): string[] {
|
|
896
|
+
return request.form ? [...Object.keys(request.form.fields), ...(request.form.media ? ['media'] : [])] : Object.keys(payload);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** The step a processing video has reached: counted STATUS reads, or elapsed World-clock time
|
|
900
|
+
* since FINALIZE in `check_after_secs` steps, whichever is further along. */
|
|
901
|
+
function processingStep(record: XMediaRecord, request: XRequest): number {
|
|
902
|
+
const byReads = record.processing_step ?? 0;
|
|
903
|
+
const byTime = record.finalized_at === undefined ? 0 : Math.floor((nowSeconds(request) - record.finalized_at) / CHECK_AFTER_SECS);
|
|
904
|
+
return Math.max(byReads, byTime);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** A record as it stands NOW: a processing video two steps along has succeeded. */
|
|
908
|
+
function settled(record: XMediaRecord, request: XRequest): XMediaRecord {
|
|
909
|
+
if (record.state !== 'processing') return record;
|
|
910
|
+
const step = processingStep(record, request);
|
|
911
|
+
return step >= 2 ? { ...record, state: 'succeeded', processing_step: step } : { ...record, processing_step: step };
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** X's `processing_info` — state, progress_percent and check_after_secs, nothing else (the v2
|
|
915
|
+
* schema's three fields). A failure's reason rides the response's `errors`, not in here. */
|
|
916
|
+
function processingInfo(record: XMediaRecord): Record<string, unknown> | undefined {
|
|
917
|
+
if (record.state === 'failed') return { state: 'failed', progress_percent: 0 };
|
|
918
|
+
if (!isVideoCategory(record.media_category)) return record.state === 'succeeded' ? undefined : { state: 'pending', progress_percent: 0, check_after_secs: CHECK_AFTER_SECS };
|
|
919
|
+
if (record.state === 'succeeded') return { state: 'succeeded', progress_percent: 100 };
|
|
920
|
+
if (record.state === 'processing' && (record.processing_step ?? 0) >= 1) return { state: 'in_progress', progress_percent: 50, check_after_secs: CHECK_AFTER_SECS };
|
|
921
|
+
return { state: 'pending', progress_percent: 0, check_after_secs: CHECK_AFTER_SECS };
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** What X answers about a media object: its id, key, remaining lifetime, and (once whole) its size,
|
|
925
|
+
* its image or video facts and — while a video processes, and on STATUS — `processing_info`. An
|
|
926
|
+
* image that is already usable carries no `processing_info` except on the STATUS read. */
|
|
927
|
+
function mediaAnswer(record: XMediaRecord, request: XRequest, status = false): Record<string, unknown> {
|
|
928
|
+
const data: Record<string, unknown> = {
|
|
929
|
+
id: record.id,
|
|
930
|
+
media_key: record.media_key,
|
|
931
|
+
expires_after_secs: Math.max(0, record.expires_at - nowSeconds(request)),
|
|
932
|
+
};
|
|
933
|
+
if (record.size !== undefined) data.size = record.size;
|
|
934
|
+
if (record.sha256 && !isVideoCategory(record.media_category)) data.image = { image_type: record.media_type, w: record.width, h: record.height };
|
|
935
|
+
if (record.sha256 && isVideoCategory(record.media_category) && record.state !== 'failed') data.video = { video_type: record.media_type };
|
|
936
|
+
const info = processingInfo(record) ?? (status ? { state: 'succeeded', progress_percent: 100 } : undefined);
|
|
937
|
+
if (info && (status || isVideoCategory(record.media_category) || record.state === 'failed')) data.processing_info = info;
|
|
938
|
+
// A failed processing run is the response's `errors` — the one place the v2 schema puts a reason.
|
|
939
|
+
const errors = record.state === 'failed'
|
|
940
|
+
? [{ title: 'Invalid Request', detail: record.error ?? 'Invalid or Unsupported media.', type: 'https://api.x.com/2/problems/invalid-request' }]
|
|
941
|
+
: undefined;
|
|
942
|
+
return { data, ...(errors ? { errors } : {}) };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Whole bytes against the category: what they are, or why X would not take them. An image that
|
|
946
|
+
* is not one is refused on the request; a video X would take the upload of and then FAIL in
|
|
947
|
+
* processing, so a bad video becomes a `failed` record rather than a refused request. */
|
|
948
|
+
function finishUpload(record: XMediaRecord, bytes: Uint8Array, sha256: string): XMediaRecord | XResponse {
|
|
949
|
+
if (!isVideoCategory(record.media_category)) {
|
|
950
|
+
if (bytes.length > MAX_IMAGE_BYTES) {
|
|
951
|
+
return invalidRequestProblem({ media: [String(bytes.length)] }, `The image is ${bytes.length} bytes, over X's ${MAX_IMAGE_BYTES}-byte limit for an image.`);
|
|
952
|
+
}
|
|
953
|
+
const info = sniffImage(bytes);
|
|
954
|
+
if (!info) return invalidRequestProblem({ media: [`(${bytes.length} bytes)`] }, 'The `media` bytes are not a supported image (JPEG, PNG, GIF or WebP).');
|
|
955
|
+
return { ...record, state: 'succeeded', media_type: info.mediaType, sha256, size: bytes.length, width: info.width, height: info.height };
|
|
956
|
+
}
|
|
957
|
+
if (bytes.length > MAX_VIDEO_BYTES) {
|
|
958
|
+
return invalidRequestProblem({ media: [String(bytes.length)] }, `The video is ${bytes.length} bytes, over X's ${MAX_VIDEO_BYTES}-byte limit for a video.`);
|
|
959
|
+
}
|
|
960
|
+
const video = sniffVideo(bytes);
|
|
961
|
+
const failed = (message: string): XMediaRecord => ({ ...record, state: 'failed', sha256, size: bytes.length, error: message });
|
|
962
|
+
if (!video) return failed('Invalid or Unsupported media, Reason: the file is not an MP4 with a video track.');
|
|
963
|
+
if (record.media_category === 'tweet_video' && video.durationMs > MAX_TWEET_VIDEO_MS) {
|
|
964
|
+
return failed(`Invalid or Unsupported media, Reason: Duration too long, maximum:${MAX_TWEET_VIDEO_MS / 1000}, actual:${(video.durationMs / 1000).toFixed(3)}`);
|
|
965
|
+
}
|
|
966
|
+
return { ...record, state: 'processing', processing_step: 0, media_type: video.mediaType, sha256, size: bytes.length, width: video.width, height: video.height, duration_ms: video.durationMs };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function mintMediaId(request: XRequest, all: Resource[]): Promise<string> {
|
|
970
|
+
// RANDOM, not the high-water mark + 1: two clones of one World each upload before either pushes, and
|
|
971
|
+
// a counter hands both the same id, so the origin would hold one clone's file under the other's post
|
|
972
|
+
// (a push carries the media record by its id). 19 digits, as X's own snowflakes are.
|
|
973
|
+
const taken = new Set([...all.map((r) => String(r.id)), ...(await listMediaIds(request.root))]);
|
|
974
|
+
for (;;) {
|
|
975
|
+
const bytes = crypto.getRandomValues(new Uint8Array(8));
|
|
976
|
+
const n = (BigInt('0x' + [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')) % 8_000_000_000_000_000_000n) + 1_000_000_000_000_000_000n;
|
|
977
|
+
const id = String(n);
|
|
978
|
+
if (!taken.has(id)) return id;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** X's media-key prefix names the kind: 3_ a photo, 7_ a posted video, 13_ an Amplify video. */
|
|
983
|
+
function mediaKeyFor(category: string, id: string): string {
|
|
984
|
+
return `${category === 'tweet_video' ? '7' : category === 'amplify_video' ? '13' : '3'}_${id}`;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** The one-shot upload: the whole file in one request. */
|
|
988
|
+
async function uploadMedia(request: XRequest, all: Resource[], auth: Authorized, payload: Record<string, any>): Promise<XResponse> {
|
|
989
|
+
const refusal = scopeRefusal(auth, 'media.upload');
|
|
990
|
+
if (refusal) return refusal;
|
|
991
|
+
const extra = unknownField(bodyFieldNames(request, payload), ONE_SHOT_FIELDS);
|
|
992
|
+
if (extra) return extra;
|
|
993
|
+
const category = parseCategory(uploadField(request, payload, 'media_category'), ONE_SHOT_CATEGORIES_DOCUMENTED);
|
|
994
|
+
if (typeof category !== 'string') return category;
|
|
995
|
+
const owners = parseOwners(uploadField(request, payload, 'additional_owners'));
|
|
996
|
+
if (!isOwners(owners)) return owners;
|
|
997
|
+
const bytes = uploadBytes(request, payload);
|
|
998
|
+
if (!(bytes instanceof Uint8Array)) return bytes;
|
|
999
|
+
const id = await mintMediaId(request, all);
|
|
1000
|
+
const opened: XMediaRecord = {
|
|
1001
|
+
id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: 'application/octet-stream', state: 'initialized',
|
|
1002
|
+
...(owners.length > 0 ? { additional_owners: owners } : {}),
|
|
1003
|
+
created_at: request.occurredAt ?? new Date().toISOString(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
|
|
1004
|
+
};
|
|
1005
|
+
// Check before storing: a refused image leaves no bytes behind.
|
|
1006
|
+
const checked = finishUpload(opened, bytes, '');
|
|
1007
|
+
if ('status' in checked) return checked;
|
|
1008
|
+
const stored = await putXBlob(bytes, checked.media_type, request.root);
|
|
1009
|
+
const record: XMediaRecord = { ...checked, sha256: stored.sha256, ...(checked.state === 'processing' ? { finalized_at: nowSeconds(request) } : {}) };
|
|
1010
|
+
await writeMediaRecord(record, request.root);
|
|
1011
|
+
return ok(mediaAnswer(record, request));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function initializeUpload(request: XRequest, all: Resource[], auth: Authorized, payload: Record<string, any>): Promise<XResponse> {
|
|
1015
|
+
const refusal = scopeRefusal(auth, 'media.upload');
|
|
1016
|
+
if (refusal) return refusal;
|
|
1017
|
+
const extra = unknownField(Object.keys(payload), INITIALIZE_FIELDS);
|
|
1018
|
+
if (extra) return extra;
|
|
1019
|
+
const category = parseCategory(payload.media_category);
|
|
1020
|
+
if (typeof category !== 'string') return category;
|
|
1021
|
+
const mediaType = payload.media_type;
|
|
1022
|
+
if (typeof mediaType !== 'string' || !MEDIA_TYPES_DOCUMENTED.has(mediaType)) {
|
|
1023
|
+
return invalidRequestProblem({ media_type: [String(mediaType ?? '')] }, `The \`media_type\` value [${String(mediaType ?? '')}] is not one of the documented media types`);
|
|
1024
|
+
}
|
|
1025
|
+
const video = isVideoCategory(category);
|
|
1026
|
+
if (!IMAGE_TYPES.has(mediaType) && !VIDEO_TYPES.has(mediaType)) return unmodelledOption('media_type', mediaType);
|
|
1027
|
+
if (video !== VIDEO_TYPES.has(mediaType)) {
|
|
1028
|
+
return invalidRequestProblem({ media_type: [mediaType] }, `The \`media_type\` [${mediaType}] does not match the \`media_category\` [${category}].`);
|
|
1029
|
+
}
|
|
1030
|
+
const total = payload.total_bytes;
|
|
1031
|
+
if (typeof total !== 'number' || !Number.isInteger(total) || total < 0) {
|
|
1032
|
+
return invalidRequestProblem({ total_bytes: [String(total ?? '')] }, 'The `total_bytes` field is required and must be a non-negative integer.');
|
|
1033
|
+
}
|
|
1034
|
+
const ceiling = video ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
1035
|
+
if (total > ceiling) return invalidRequestProblem({ total_bytes: [String(total)] }, `A${video ? ' video' : 'n image'} may be at most ${ceiling} bytes.`);
|
|
1036
|
+
const owners = parseOwners(payload.additional_owners);
|
|
1037
|
+
if (!isOwners(owners)) return owners;
|
|
1038
|
+
const id = await mintMediaId(request, all);
|
|
1039
|
+
const record: XMediaRecord = {
|
|
1040
|
+
id, media_key: mediaKeyFor(category, id), account_id: auth.accountId, media_category: category, media_type: mediaType, state: 'initialized', total_bytes: total,
|
|
1041
|
+
...(owners.length > 0 ? { additional_owners: owners } : {}),
|
|
1042
|
+
created_at: request.occurredAt ?? new Date().toISOString(), expires_at: nowSeconds(request) + MEDIA_EXPIRES_AFTER_SECS,
|
|
1043
|
+
};
|
|
1044
|
+
await writeMediaRecord(record, request.root);
|
|
1045
|
+
return ok({ data: { id, media_key: record.media_key, expires_after_secs: MEDIA_EXPIRES_AFTER_SECS } });
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** An upload session this account started and has not finished — or X's refusal of the id. */
|
|
1049
|
+
async function openSession(request: XRequest, auth: Authorized, id: string): Promise<XMediaRecord | XResponse> {
|
|
1050
|
+
const record = await readMediaRecord(id, request.root);
|
|
1051
|
+
if (!record || record.account_id !== auth.accountId || record.expires_at <= nowSeconds(request)) {
|
|
1052
|
+
return invalidRequestProblem({ media_id: [id] }, INVALID_MEDIA_IDS);
|
|
1053
|
+
}
|
|
1054
|
+
if (record.state !== 'initialized') return invalidRequestProblem({ media_id: [id] }, `Media ${id} is already finalized.`);
|
|
1055
|
+
return record;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async function appendUpload(request: XRequest, auth: Authorized, id: string, payload: Record<string, any>): Promise<XResponse> {
|
|
1059
|
+
const refusal = scopeRefusal(auth, 'media.upload');
|
|
1060
|
+
if (refusal) return refusal;
|
|
1061
|
+
const extra = unknownField(bodyFieldNames(request, payload), APPEND_FIELDS);
|
|
1062
|
+
if (extra) return extra;
|
|
1063
|
+
const record = await openSession(request, auth, id);
|
|
1064
|
+
if ('status' in record) return record;
|
|
1065
|
+
const rawIndex = uploadField(request, payload, 'segment_index');
|
|
1066
|
+
const index = typeof rawIndex === 'number' ? rawIndex : typeof rawIndex === 'string' && /^\d+$/.test(rawIndex) ? Number(rawIndex) : Number.NaN;
|
|
1067
|
+
if (!Number.isInteger(index) || index < 0 || index > 999) {
|
|
1068
|
+
return invalidRequestProblem({ segment_index: [String(rawIndex ?? '')] }, 'The `segment_index` field is required and must be an integer between 0 and 999.');
|
|
1069
|
+
}
|
|
1070
|
+
const bytes = uploadBytes(request, payload);
|
|
1071
|
+
if (!(bytes instanceof Uint8Array)) return bytes;
|
|
1072
|
+
await putSegment(id, index, bytes, request.root);
|
|
1073
|
+
return ok({ data: { expires_at: record.expires_at } });
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async function finalizeUpload(request: XRequest, auth: Authorized, id: string): Promise<XResponse> {
|
|
1077
|
+
const refusal = scopeRefusal(auth, 'media.upload');
|
|
1078
|
+
if (refusal) return refusal;
|
|
1079
|
+
const record = await openSession(request, auth, id);
|
|
1080
|
+
if ('status' in record) return record;
|
|
1081
|
+
const segments = await readSegments(id, request.root);
|
|
1082
|
+
const size = segments.reduce((n, s) => n + s.length, 0);
|
|
1083
|
+
if (segments.length === 0 || size !== record.total_bytes) {
|
|
1084
|
+
return invalidRequestProblem({ media_id: [id] }, `The uploaded segments total ${size} bytes; initialize declared ${record.total_bytes}.`);
|
|
1085
|
+
}
|
|
1086
|
+
const whole = new Uint8Array(size);
|
|
1087
|
+
let at = 0;
|
|
1088
|
+
for (const segment of segments) { whole.set(segment, at); at += segment.length; }
|
|
1089
|
+
const checked = finishUpload(record, whole, '');
|
|
1090
|
+
if ('status' in checked) return checked;
|
|
1091
|
+
const stored = await putXBlob(whole, checked.media_type, request.root);
|
|
1092
|
+
const finished: XMediaRecord = { ...checked, sha256: stored.sha256, ...(checked.state === 'processing' ? { finalized_at: nowSeconds(request) } : {}) };
|
|
1093
|
+
await writeMediaRecord(finished, request.root);
|
|
1094
|
+
await clearSegments(id, request.root);
|
|
1095
|
+
return ok(mediaAnswer(finished, request));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function uploadStatus(request: XRequest, auth: Authorized, url: URL): Promise<XResponse> {
|
|
1099
|
+
const refusal = scopeRefusal(auth, 'media.upload');
|
|
1100
|
+
if (refusal) return refusal;
|
|
1101
|
+
const command = url.searchParams.get('command');
|
|
1102
|
+
if (command !== null && command !== 'STATUS') return invalidRequestProblem({ command: [command] }, `The \`command\` query parameter value [${command}] is not one of [STATUS]`);
|
|
1103
|
+
const id = url.searchParams.get('media_id') ?? '';
|
|
1104
|
+
if (!/^\d{1,19}$/.test(id)) return invalidRequestProblem({ media_id: [id] }, `The \`media_id\` query parameter value [${id}] does not match ^[0-9]{1,19}$`);
|
|
1105
|
+
const stored = await readMediaRecord(id, request.root);
|
|
1106
|
+
if (!stored || !canUse(stored, auth) || stored.expires_at <= nowSeconds(request)) return invalidRequestProblem({ media_id: [id] }, INVALID_MEDIA_IDS);
|
|
1107
|
+
// A read of a processing video advances it one state (pending → in_progress → succeeded), on top
|
|
1108
|
+
// of whatever the clock has already moved it; read-only, the clock alone moves it.
|
|
1109
|
+
let record = settled(stored, request);
|
|
1110
|
+
if (record.state === 'processing' && !request.readOnly) {
|
|
1111
|
+
record = settled({ ...record, processing_step: (stored.processing_step ?? 0) + 1 }, request);
|
|
1112
|
+
}
|
|
1113
|
+
if (!request.readOnly && (record.state !== stored.state || record.processing_step !== stored.processing_step)) await writeMediaRecord(record, request.root);
|
|
1114
|
+
return ok(mediaAnswer(record, request, true));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function canUse(record: XMediaRecord, auth: Authorized): boolean {
|
|
1118
|
+
return record.account_id === auth.accountId || (record.additional_owners ?? []).includes(auth.accountId);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** `media.media_ids` on a create, resolved to what the post will carry — or X's refusal. A post
|
|
1122
|
+
* carries up to four images or one video; a video still processing (or failed) is not usable. */
|
|
1123
|
+
async function resolveAttachedMedia(request: XRequest, auth: Authorized, media: unknown): Promise<{ media: AttachedMedia[] } | XResponse> {
|
|
1124
|
+
if (media === undefined || media === null) return { media: [] };
|
|
1125
|
+
if (typeof media !== 'object' || Array.isArray(media)) return invalidRequestProblem({ media: [JSON.stringify(media)] }, 'The `media` field must be an object with `media_ids`.');
|
|
1126
|
+
const body = media as Record<string, unknown>;
|
|
1127
|
+
for (const key of Object.keys(body)) {
|
|
1128
|
+
if (key === 'media_ids') continue;
|
|
1129
|
+
if (POST_MEDIA_FIELDS_UNMODELLED.has(key)) return unmodelledOption(`media.${key}`, JSON.stringify(body[key]));
|
|
1130
|
+
return invalidRequestProblem({ [`media.${key}`]: [JSON.stringify(body[key])] }, `The \`media.${key}\` field is not a parameter of this request.`);
|
|
1131
|
+
}
|
|
1132
|
+
const ids = body.media_ids;
|
|
1133
|
+
if (!Array.isArray(ids) || ids.length < 1 || ids.length > 4) {
|
|
1134
|
+
return invalidRequestProblem({ 'media.media_ids': [JSON.stringify(ids ?? null)] }, 'The `media.media_ids` field must list between 1 and 4 media ids.');
|
|
1135
|
+
}
|
|
1136
|
+
const out: AttachedMedia[] = [];
|
|
1137
|
+
for (const raw of ids) {
|
|
1138
|
+
// the schema's items are STRINGS matching ^[0-9]{1,19}$: a JSON number is refused, not coerced
|
|
1139
|
+
if (typeof raw !== 'string' || !/^\d{1,19}$/.test(raw)) {
|
|
1140
|
+
return invalidRequestProblem({ 'media.media_ids': [String(raw)] }, `The \`media.media_ids\` value [${String(raw)}] is not a string matching ^[0-9]{1,19}$`);
|
|
1141
|
+
}
|
|
1142
|
+
const id = raw;
|
|
1143
|
+
const stored = await readMediaRecord(id, request.root);
|
|
1144
|
+
const record = stored ? settled(stored, request) : undefined;
|
|
1145
|
+
// a DM category is a Direct Message's, never a post's
|
|
1146
|
+
if (!record || record.state !== 'succeeded' || !canUse(record, auth) || record.expires_at <= nowSeconds(request) || !record.sha256 || record.media_category.startsWith('dm_')) {
|
|
1147
|
+
return invalidRequestProblem({ 'media.media_ids': [id] }, INVALID_MEDIA_IDS);
|
|
1148
|
+
}
|
|
1149
|
+
out.push({
|
|
1150
|
+
media_key: record.media_key, media_id: record.id, type: isVideoCategory(record.media_category) ? 'video' : 'photo',
|
|
1151
|
+
media_type: record.media_type, media_category: record.media_category, sha256: record.sha256, size: record.size ?? 0,
|
|
1152
|
+
...(typeof record.width === 'number' ? { width: record.width } : {}), ...(typeof record.height === 'number' ? { height: record.height } : {}),
|
|
1153
|
+
...(typeof record.duration_ms === 'number' ? { duration_ms: record.duration_ms } : {}),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
if (out.some((m) => m.type === 'video') && out.length > 1) {
|
|
1157
|
+
return invalidRequestProblem({ 'media.media_ids': ids.map(String) }, 'A Post may carry up to four images or one video, not both.');
|
|
1158
|
+
}
|
|
1159
|
+
return { media: out };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// ── the reads ────────────────────────────────────────────────────────────────────────────────
|
|
1163
|
+
|
|
1164
|
+
/** Post lookup by id. A post nobody created comes back inside `errors`, NEVER as a fabricated
|
|
1165
|
+
* `data` object — the whole point of a twin over a mock. */
|
|
1166
|
+
function lookupById(all: Resource[], auth: Authorized, id: string, url: URL, publicBase: string): XResponse {
|
|
1167
|
+
const refusal = scopeRefusal(auth, 'tweets.lookup');
|
|
1168
|
+
if (refusal) return refusal;
|
|
1169
|
+
if (!/^\d+$/.test(id)) return invalidRequestProblem({ id: [id] }, `The \`id\` query parameter value [${id}] is not a valid Post id`);
|
|
1170
|
+
const shape = parseReadShape(url, publicBase);
|
|
1171
|
+
if (!isShape(shape)) return shape;
|
|
1172
|
+
const post = findPost(all, id);
|
|
1173
|
+
if (!post) return lookupResult(undefined, [resourceNotFoundError(id, 'tweet', 'id')]);
|
|
1174
|
+
const includes = buildIncludes([post], all, shape);
|
|
1175
|
+
return lookupResult(projectPost(post, shape.fields), [], includes);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Bulk lookup. X answers PARTIALLY: the ids that exist ride in `data`, the ones that do not ride
|
|
1179
|
+
* in `errors`, in one 200. Order follows the caller's `ids` list. */
|
|
1180
|
+
function lookupByIds(all: Resource[], auth: Authorized, url: URL, publicBase: string): XResponse {
|
|
1181
|
+
const refusal = scopeRefusal(auth, 'tweets.lookup');
|
|
1182
|
+
if (refusal) return refusal;
|
|
1183
|
+
const raw = url.searchParams.get('ids');
|
|
1184
|
+
const ids = csv(raw);
|
|
1185
|
+
if (raw === null || ids.length === 0) return invalidRequestProblem({ ids: [String(raw ?? '')] }, 'The `ids` query parameter is required and must name at least one Post id.');
|
|
1186
|
+
if (ids.length > 100) return invalidRequestProblem({ ids: [String(ids.length)] }, 'The `ids` query parameter accepts at most 100 Post ids.');
|
|
1187
|
+
for (const id of ids) {
|
|
1188
|
+
if (!/^\d+$/.test(id)) return invalidRequestProblem({ ids: [id] }, `The \`ids\` query parameter value [${id}] is not a valid Post id`);
|
|
1189
|
+
}
|
|
1190
|
+
const shape = parseReadShape(url, publicBase);
|
|
1191
|
+
if (!isShape(shape)) return shape;
|
|
1192
|
+
const found: Resource[] = [];
|
|
1193
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
1194
|
+
for (const id of ids) {
|
|
1195
|
+
const post = findPost(all, id);
|
|
1196
|
+
if (post) found.push(post);
|
|
1197
|
+
else errors.push(resourceNotFoundError(id, 'tweet', 'ids'));
|
|
1198
|
+
}
|
|
1199
|
+
const includes = buildIncludes(found, all, shape);
|
|
1200
|
+
return lookupResult(found.length > 0 ? found.map((p) => projectPost(p, shape.fields)) : undefined, errors, includes);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ── user lookup ─────────────────────────────────────────────────────────────────────────────
|
|
1204
|
+
// GET /2/users/:id and GET /2/users/by/username/:username — the pinned client's `user()` and
|
|
1205
|
+
// `userByUsername()` (dist/cjs/v2/client.v2.read.js:243, :258). A missing user is X's partial-
|
|
1206
|
+
// error 200 (`errors` with resource_type "user"), the same envelope a missing post uses.
|
|
1207
|
+
// GET /2/users/me is NOT here: it is xidentity's route, and the host claim keeps the two disjoint.
|
|
1208
|
+
|
|
1209
|
+
const USER_EXPANSIONS_DOCUMENTED = new Set(['affiliation.user_id', 'most_recent_tweet_id', 'pinned_tweet_id']);
|
|
1210
|
+
|
|
1211
|
+
function parseUserLookup(url: URL): Set<string> | XResponse {
|
|
1212
|
+
for (const value of csv(url.searchParams.get('expansions'))) {
|
|
1213
|
+
if (!USER_EXPANSIONS_DOCUMENTED.has(value)) {
|
|
1214
|
+
return invalidRequestProblem({ expansions: [value] }, `The \`expansions\` query parameter value [${value}] is not one of the documented User expansions`);
|
|
1215
|
+
}
|
|
1216
|
+
return unmodelledOption('expansions', value);
|
|
1217
|
+
}
|
|
1218
|
+
// `tweet.fields` shapes an expanded pinned/most-recent post; with no user expansion modelled
|
|
1219
|
+
// there is nothing for it to shape, so a request for it is refused by name, never ignored.
|
|
1220
|
+
const tweetFields = url.searchParams.get('tweet.fields');
|
|
1221
|
+
if (tweetFields !== null && tweetFields.trim() !== '') return unmodelledOption('tweet.fields', tweetFields);
|
|
1222
|
+
return parseUserFields(url);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function lookupUserById(all: Resource[], auth: Authorized, id: string, url: URL): XResponse {
|
|
1226
|
+
const refusal = scopeRefusal(auth, 'users.lookup');
|
|
1227
|
+
if (refusal) return refusal;
|
|
1228
|
+
if (!/^\d{1,19}$/.test(id)) return invalidRequestProblem({ id: [id] }, `The \`id\` query parameter value [${id}] is not a valid User id`);
|
|
1229
|
+
const userFields = parseUserLookup(url);
|
|
1230
|
+
if (!isFieldSet(userFields)) return userFields;
|
|
1231
|
+
const account = findAccount(all, id);
|
|
1232
|
+
if (!account) return lookupResult(undefined, [resourceNotFoundError(id, 'user', 'id')]);
|
|
1233
|
+
return lookupResult(projectUser(account, userFields, all), []);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function lookupUserByUsername(all: Resource[], auth: Authorized, username: string, url: URL): XResponse {
|
|
1237
|
+
const refusal = scopeRefusal(auth, 'users.lookup');
|
|
1238
|
+
if (refusal) return refusal;
|
|
1239
|
+
if (!/^[A-Za-z0-9_]{1,15}$/.test(username)) {
|
|
1240
|
+
return invalidRequestProblem({ username: [username] }, `The \`username\` query parameter value [${username}] does not match ^[A-Za-z0-9_]{1,15}$`);
|
|
1241
|
+
}
|
|
1242
|
+
const userFields = parseUserLookup(url);
|
|
1243
|
+
if (!isFieldSet(userFields)) return userFields;
|
|
1244
|
+
// X handles are case-insensitive: @Volter and @volter are one account.
|
|
1245
|
+
const account = ofType(all, 'account').find((a) => String(a.username).toLowerCase() === username.toLowerCase());
|
|
1246
|
+
if (!account) return lookupResult(undefined, [resourceNotFoundError(username, 'user', 'username')]);
|
|
1247
|
+
return lookupResult(projectUser(account, userFields, all), []);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function listMentions(all: Resource[], auth: Authorized, userId: string, url: URL, publicBase: string): XResponse {
|
|
1251
|
+
const refusal = scopeRefusal(auth, 'mentions.list');
|
|
1252
|
+
if (refusal) return refusal;
|
|
1253
|
+
const account = findAccount(all, userId);
|
|
1254
|
+
if (!account) return resourceNotFoundProblem(userId, 'user', 'id');
|
|
1255
|
+
const read = parseReadRequest(url, 5, publicBase);
|
|
1256
|
+
if (!isReadRequest(read)) return read;
|
|
1257
|
+
const username = String(account.username);
|
|
1258
|
+
const matched = livePosts(all).filter((p) => String(p.author_id) !== userId && mentionsHandle(String(p.text), username));
|
|
1259
|
+
return timelinePage(applyWindow(matched, read.window), all, read.shape, read.size, read.cursor);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/** The user's own posted timeline — what the org actually said, in its own voice. */
|
|
1263
|
+
function listUserTweets(all: Resource[], auth: Authorized, userId: string, url: URL, publicBase: string): XResponse {
|
|
1264
|
+
const refusal = scopeRefusal(auth, 'timelines.read');
|
|
1265
|
+
if (refusal) return refusal;
|
|
1266
|
+
const account = findAccount(all, userId);
|
|
1267
|
+
if (!account) return resourceNotFoundProblem(userId, 'user', 'id');
|
|
1268
|
+
const read = parseReadRequest(url, 5, publicBase);
|
|
1269
|
+
if (!isReadRequest(read)) return read;
|
|
1270
|
+
const matched = livePosts(all).filter((p) => String(p.author_id) === userId);
|
|
1271
|
+
return timelinePage(applyWindow(matched, read.window), all, read.shape, read.size, read.cursor);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* The HOME timeline: posts by the authenticated account and by the accounts it FOLLOWS. The
|
|
1276
|
+
* follow graph is real state a world seeds through `/_twin/follows` — without it this endpoint
|
|
1277
|
+
* would have to invent an audience, and a home timeline nobody follows into is a fabrication.
|
|
1278
|
+
* X serves this route only for the authenticated user's own id.
|
|
1279
|
+
*/
|
|
1280
|
+
function listHomeTimeline(all: Resource[], auth: Authorized, userId: string, url: URL, publicBase: string): XResponse {
|
|
1281
|
+
const refusal = scopeRefusal(auth, 'timelines.read');
|
|
1282
|
+
if (refusal) return refusal;
|
|
1283
|
+
const account = findAccount(all, userId);
|
|
1284
|
+
if (!account) return resourceNotFoundProblem(userId, 'user', 'id');
|
|
1285
|
+
if (userId !== auth.accountId) return forbiddenProblem('You are not permitted to access the home timeline of another account.');
|
|
1286
|
+
const read = parseReadRequest(url, 5, publicBase);
|
|
1287
|
+
if (!isReadRequest(read)) return read;
|
|
1288
|
+
const following = new Set(
|
|
1289
|
+
ofType(all, 'follow')
|
|
1290
|
+
.filter((f) => String(f.follower_id) === userId)
|
|
1291
|
+
.map((f) => String(f.followed_id)),
|
|
1292
|
+
);
|
|
1293
|
+
const matched = livePosts(all).filter((p) => String(p.author_id) === userId || following.has(String(p.author_id)));
|
|
1294
|
+
return timelinePage(applyWindow(matched, read.window), all, read.shape, read.size, read.cursor);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function searchRecent(all: Resource[], auth: Authorized, url: URL, publicBase: string): XResponse {
|
|
1298
|
+
const refusal = scopeRefusal(auth, 'search.recent');
|
|
1299
|
+
if (refusal) return refusal;
|
|
1300
|
+
const query = url.searchParams.get('query');
|
|
1301
|
+
if (query === null || query.trim() === '') return invalidRequestProblem({ query: [String(query ?? '')] }, 'The `query` query parameter is required.');
|
|
1302
|
+
const parsed = parseSearchQuery(query);
|
|
1303
|
+
if ('unsupported' in parsed) {
|
|
1304
|
+
return invalidRequestProblem(
|
|
1305
|
+
{ query: [parsed.unsupported] },
|
|
1306
|
+
`The \`query\` operator [${parsed.unsupported}] is real X search grammar this twin does not model. It is refused rather than ignored, so a caller never receives results for a query the twin did not honour.`,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
// Recent search bounds max_results at 10, unlike the 5 the timelines allow.
|
|
1310
|
+
const read = parseReadRequest(url, 10, publicBase);
|
|
1311
|
+
if (!isReadRequest(read)) return read;
|
|
1312
|
+
const matched = livePosts(all).filter((p) => matchesSearch(p, all, parsed.terms));
|
|
1313
|
+
return timelinePage(applyWindow(matched, read.window), all, read.shape, read.size, read.cursor);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// ── the twin control plane ──────────────────────────────────────────────────────────────────
|
|
1317
|
+
// Not vendor surface. `/_twin/*` is how a WORLD seeds the state a rehearsal needs — the org's
|
|
1318
|
+
// account, the bearer xidentity's flow would have minted, the inbound mention the reply class
|
|
1319
|
+
// answers, and the follow graph a home timeline reads — without running an OAuth leg or waiting
|
|
1320
|
+
// for a member of the public to post.
|
|
1321
|
+
|
|
1322
|
+
async function handleTwinControl(request: XRequest, all: Resource[], path: string, payload: Record<string, any>): Promise<XResponse> {
|
|
1323
|
+
if (request.method === 'POST' && path === '/_twin/accounts') {
|
|
1324
|
+
const username = payload.username;
|
|
1325
|
+
if (typeof username !== 'string' || !/^[A-Za-z0-9_]{1,15}$/.test(username)) {
|
|
1326
|
+
return invalidRequestProblem({ username: [String(username ?? '')] }, 'A twin account requires an X-shaped username (1-15 of A-Z a-z 0-9 _).');
|
|
1327
|
+
}
|
|
1328
|
+
// The X Premium long-post entitlement, seeded rather than assumed. Out of range is a refusal,
|
|
1329
|
+
// so a world cannot quietly hand an account a limit X does not offer.
|
|
1330
|
+
const declaredLimit = payload.post_character_limit;
|
|
1331
|
+
if (declaredLimit !== undefined
|
|
1332
|
+
&& (typeof declaredLimit !== 'number' || !Number.isInteger(declaredLimit) || declaredLimit < DEFAULT_POST_CHARACTER_LIMIT || declaredLimit > MAX_POST_CHARACTER_LIMIT)) {
|
|
1333
|
+
return invalidRequestProblem({ post_character_limit: [String(declaredLimit)] }, `A seeded post_character_limit must be an integer between ${DEFAULT_POST_CHARACTER_LIMIT} and ${MAX_POST_CHARACTER_LIMIT}.`);
|
|
1334
|
+
}
|
|
1335
|
+
// An explicit id must be an X-SHAPED numeric snowflake. Everything downstream orders and
|
|
1336
|
+
// joins on `BigInt(id)`, so a seeded `"abc"` would take a timeline read down at read time
|
|
1337
|
+
// rather than being refused at the write that caused it.
|
|
1338
|
+
if (payload.id !== undefined && (typeof payload.id !== 'string' || !/^\d+$/.test(payload.id.trim()))) {
|
|
1339
|
+
return invalidRequestProblem({ id: [String(payload.id)] }, 'A seeded account id must be an X-shaped numeric snowflake.');
|
|
1340
|
+
}
|
|
1341
|
+
const id = typeof payload.id === 'string' && payload.id.trim() !== '' ? payload.id.trim() : mintSnowflakeId(all);
|
|
1342
|
+
const name = typeof payload.name === 'string' ? payload.name : username;
|
|
1343
|
+
// The profile an X account carries and `user.fields` serves back: optional, typed, seeded.
|
|
1344
|
+
const profile: Record<string, unknown> = {};
|
|
1345
|
+
for (const key of ['description', 'location', 'url'] as const) {
|
|
1346
|
+
if (payload[key] === undefined) continue;
|
|
1347
|
+
if (typeof payload[key] !== 'string') return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a string.`);
|
|
1348
|
+
profile[key] = payload[key];
|
|
1349
|
+
}
|
|
1350
|
+
for (const key of ['protected', 'verified'] as const) {
|
|
1351
|
+
if (payload[key] === undefined) continue;
|
|
1352
|
+
if (typeof payload[key] !== 'boolean') return invalidRequestProblem({ [key]: [String(payload[key])] }, `A seeded account's \`${key}\` must be a boolean.`);
|
|
1353
|
+
profile[key] = payload[key];
|
|
1354
|
+
}
|
|
1355
|
+
const createdAt = request.occurredAt ?? new Date().toISOString();
|
|
1356
|
+
await applyTwinWrite(SERVICE, {
|
|
1357
|
+
operation: 'x.twin.seed_account',
|
|
1358
|
+
subjectType: 'account',
|
|
1359
|
+
subjectId: id,
|
|
1360
|
+
fields: { username, name, created_at: createdAt, ...profile, ...(declaredLimit !== undefined ? { post_character_limit: declaredLimit } : {}) },
|
|
1361
|
+
occurredAt: createdAt,
|
|
1362
|
+
}, request.root);
|
|
1363
|
+
return ok({ data: { id, username, name, ...profile, ...(declaredLimit !== undefined ? { post_character_limit: declaredLimit } : {}) } });
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (request.method === 'POST' && path === '/_twin/tokens') {
|
|
1367
|
+
const token = payload.token;
|
|
1368
|
+
const accountId = payload.account_id;
|
|
1369
|
+
if (typeof token !== 'string' || token.trim() === '') return invalidRequestProblem({ token: [String(token ?? '')] }, 'A twin token requires a non-empty `token`.');
|
|
1370
|
+
if (typeof accountId !== 'string' || findAccount(all, accountId) === undefined) {
|
|
1371
|
+
return invalidRequestProblem({ account_id: [String(accountId ?? '')] }, 'A twin token must name an account seeded through /_twin/accounts.');
|
|
1372
|
+
}
|
|
1373
|
+
const scopes = Array.isArray(payload.scopes) ? payload.scopes.map(String) : parseScopeList(typeof payload.scopes === 'string' ? payload.scopes : '');
|
|
1374
|
+
await applyTwinWrite(SERVICE, {
|
|
1375
|
+
operation: 'x.twin.seed_token',
|
|
1376
|
+
subjectType: 'token',
|
|
1377
|
+
subjectId: token,
|
|
1378
|
+
fields: { account_id: accountId, scopes },
|
|
1379
|
+
occurredAt: request.occurredAt ?? new Date().toISOString(),
|
|
1380
|
+
}, request.root);
|
|
1381
|
+
return ok({ data: { token, account_id: accountId, scopes } });
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (request.method === 'POST' && path === '/_twin/posts') {
|
|
1385
|
+
const text = payload.text;
|
|
1386
|
+
const authorId = payload.author_id;
|
|
1387
|
+
if (typeof text !== 'string' || text.trim() === '') return invalidRequestProblem({ text: [String(text ?? '')] }, 'A seeded post requires `text`.');
|
|
1388
|
+
if (typeof authorId !== 'string' || findAccount(all, authorId) === undefined) {
|
|
1389
|
+
return invalidRequestProblem({ author_id: [String(authorId ?? '')] }, 'A seeded post must name an account seeded through /_twin/accounts.');
|
|
1390
|
+
}
|
|
1391
|
+
const id = mintSnowflakeId(all);
|
|
1392
|
+
const createdAt = request.occurredAt ?? new Date().toISOString();
|
|
1393
|
+
await applyTwinWrite(SERVICE, {
|
|
1394
|
+
operation: 'x.twin.seed_post',
|
|
1395
|
+
subjectType: 'post',
|
|
1396
|
+
subjectId: id,
|
|
1397
|
+
fields: { text, author_id: authorId, created_at: createdAt, conversation_id: id, deleted: false },
|
|
1398
|
+
occurredAt: createdAt,
|
|
1399
|
+
}, request.root);
|
|
1400
|
+
return ok({ data: { id, text, author_id: authorId } });
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// The follow graph the HOME timeline reads. Seeded, never inferred: X's home timeline is
|
|
1404
|
+
// "you and the accounts you follow", and a twin with no graph would have to invent an audience.
|
|
1405
|
+
if (request.method === 'POST' && path === '/_twin/follows') {
|
|
1406
|
+
const followerId = payload.follower_id;
|
|
1407
|
+
const followedId = payload.followed_id;
|
|
1408
|
+
for (const [name, value] of [['follower_id', followerId], ['followed_id', followedId]] as const) {
|
|
1409
|
+
if (typeof value !== 'string' || findAccount(all, value) === undefined) {
|
|
1410
|
+
return invalidRequestProblem({ [name]: [String(value ?? '')] }, `A twin follow must name an account seeded through /_twin/accounts (${name}).`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (followerId === followedId) return invalidRequestProblem({ followed_id: [String(followedId)] }, 'An account cannot follow itself.');
|
|
1414
|
+
await applyTwinWrite(SERVICE, {
|
|
1415
|
+
operation: 'x.twin.seed_follow',
|
|
1416
|
+
subjectType: 'follow',
|
|
1417
|
+
subjectId: `${followerId}:${followedId}`,
|
|
1418
|
+
fields: { follower_id: followerId, followed_id: followedId },
|
|
1419
|
+
occurredAt: request.occurredAt ?? new Date().toISOString(),
|
|
1420
|
+
}, request.root);
|
|
1421
|
+
return ok({ data: { follower_id: followerId, followed_id: followedId } });
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Arms the vendor's 15-minute-window refusal for the NEXT /2 request, so an integration can
|
|
1425
|
+
// rehearse the 429 + legacy code 88 pair without waiting out a real window.
|
|
1426
|
+
if (request.method === 'POST' && path === '/_twin/rate_limit') {
|
|
1427
|
+
const resetAt = typeof payload.reset === 'number' ? payload.reset : 900;
|
|
1428
|
+
await applyTwinWrite(SERVICE, {
|
|
1429
|
+
operation: 'x.twin.arm_rate_limit',
|
|
1430
|
+
subjectType: 'rate_limit',
|
|
1431
|
+
subjectId: 'armed',
|
|
1432
|
+
fields: { armed: payload.armed !== false, reset: resetAt, limit: typeof payload.limit === 'number' ? payload.limit : 200 },
|
|
1433
|
+
occurredAt: request.occurredAt ?? new Date().toISOString(),
|
|
1434
|
+
}, request.root);
|
|
1435
|
+
return ok({ data: { armed: payload.armed !== false, reset: resetAt } });
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
return notFoundProblem();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function armedRateRefusal(all: Resource[]): XResponse | undefined {
|
|
1442
|
+
const armed = ofType(all, 'rate_limit').find((r) => String(r.id) === 'armed');
|
|
1443
|
+
if (!armed || armed.armed !== true) return undefined;
|
|
1444
|
+
const limit = typeof armed.limit === 'number' ? armed.limit : 200;
|
|
1445
|
+
const reset = typeof armed.reset === 'number' ? armed.reset : 900;
|
|
1446
|
+
return rateLimitExceeded({
|
|
1447
|
+
'x-rate-limit-limit': String(limit),
|
|
1448
|
+
'x-rate-limit-remaining': '0',
|
|
1449
|
+
'x-rate-limit-reset': String(Math.floor(Date.parse('1970-01-01T00:00:00.000Z') / 1000) + reset),
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// ── the router ──────────────────────────────────────────────────────────────────────────────
|
|
1454
|
+
|
|
1455
|
+
const TWEETS_PATH = '/2/tweets';
|
|
1456
|
+
const MEDIA_UPLOAD_PATH = '/2/media/upload';
|
|
1457
|
+
const MEDIA_INITIALIZE_PATH = '/2/media/upload/initialize';
|
|
1458
|
+
const MEDIA_SESSION_PATH = /^\/2\/media\/upload\/(\d{1,19})\/(append|finalize)$/;
|
|
1459
|
+
const SEARCH_RECENT_PATH = '/2/tweets/search/recent';
|
|
1460
|
+
const TWEET_ID_PATH = /^\/2\/tweets\/([^/]+)$/;
|
|
1461
|
+
const MENTIONS_PATH = /^\/2\/users\/([^/]+)\/mentions$/;
|
|
1462
|
+
const USER_TWEETS_PATH = /^\/2\/users\/([^/]+)\/tweets$/;
|
|
1463
|
+
const HOME_TIMELINE_PATH = /^\/2\/users\/([^/]+)\/timelines\/reverse_chronological$/;
|
|
1464
|
+
// `me` is excluded: GET /2/users/me is xidentity's route, never a user id here.
|
|
1465
|
+
const USER_ID_PATH = /^\/2\/users\/(?!me$)([^/]+)$/;
|
|
1466
|
+
const USER_BY_USERNAME_PATH = /^\/2\/users\/by\/username\/([^/]+)$/;
|
|
1467
|
+
|
|
1468
|
+
export async function handleXTwinRequest(request: XRequest): Promise<XResponse> {
|
|
1469
|
+
const url = new URL(request.path, 'http://twin.local');
|
|
1470
|
+
const path = url.pathname;
|
|
1471
|
+
const parsed = parseJsonBody(request.body);
|
|
1472
|
+
if ('malformed' in parsed) return invalidRequestProblem({ body: ['(unparseable)'] }, 'The request body could not be parsed as a JSON object.');
|
|
1473
|
+
const payload = parsed.object;
|
|
1474
|
+
const all = resources(request.root);
|
|
1475
|
+
|
|
1476
|
+
if (path.startsWith('/_twin/')) {
|
|
1477
|
+
if (request.readOnly) return readOnlyRefusal();
|
|
1478
|
+
return handleTwinControl(request, all, path, payload);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Everything below is vendor surface. An UNMODELLED /2 route fails like the vendor (404
|
|
1482
|
+
// problem) rather than reporting a fake success — the rule the whole estate is held to.
|
|
1483
|
+
if (!path.startsWith('/2/')) return notFoundProblem();
|
|
1484
|
+
|
|
1485
|
+
const refusal = armedRateRefusal(all);
|
|
1486
|
+
if (refusal) return refusal;
|
|
1487
|
+
|
|
1488
|
+
const auth = authorize(all, request.headers);
|
|
1489
|
+
if (isResponse(auth)) return auth;
|
|
1490
|
+
const publicBase = (request.publicBase ?? PBS_BASE).replace(/\/+$/, '');
|
|
1491
|
+
|
|
1492
|
+
// MEDIA UPLOAD — staged bytes, not kernel state (x-media.ts).
|
|
1493
|
+
if (path === MEDIA_UPLOAD_PATH) {
|
|
1494
|
+
if (request.method === 'POST') {
|
|
1495
|
+
if (request.readOnly) return readOnlyRefusal();
|
|
1496
|
+
return uploadMedia(request, all, auth, payload);
|
|
1497
|
+
}
|
|
1498
|
+
if (request.method === 'GET') return uploadStatus(request, auth, url);
|
|
1499
|
+
}
|
|
1500
|
+
if (path === MEDIA_INITIALIZE_PATH && request.method === 'POST') {
|
|
1501
|
+
if (request.readOnly) return readOnlyRefusal();
|
|
1502
|
+
return initializeUpload(request, all, auth, payload);
|
|
1503
|
+
}
|
|
1504
|
+
const mediaSession = MEDIA_SESSION_PATH.exec(path);
|
|
1505
|
+
if (mediaSession && request.method === 'POST') {
|
|
1506
|
+
if (request.readOnly) return readOnlyRefusal();
|
|
1507
|
+
return mediaSession[2] === 'append'
|
|
1508
|
+
? appendUpload(request, auth, mediaSession[1]!, payload)
|
|
1509
|
+
: finalizeUpload(request, auth, mediaSession[1]!);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (path === TWEETS_PATH) {
|
|
1513
|
+
if (request.method === 'POST') {
|
|
1514
|
+
if (request.readOnly) return readOnlyRefusal();
|
|
1515
|
+
return createPost(request, all, auth, payload);
|
|
1516
|
+
}
|
|
1517
|
+
if (request.method === 'GET') return lookupByIds(all, auth, url, publicBase);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
if (path === SEARCH_RECENT_PATH && request.method === 'GET') return searchRecent(all, auth, url, publicBase);
|
|
1521
|
+
|
|
1522
|
+
const byId = TWEET_ID_PATH.exec(path);
|
|
1523
|
+
if (byId && request.method === 'DELETE') {
|
|
1524
|
+
if (request.readOnly) return readOnlyRefusal();
|
|
1525
|
+
return deletePost(request, all, auth, byId[1]!);
|
|
1526
|
+
}
|
|
1527
|
+
if (byId && request.method === 'GET') return lookupById(all, auth, byId[1]!, url, publicBase);
|
|
1528
|
+
|
|
1529
|
+
const mentions = MENTIONS_PATH.exec(path);
|
|
1530
|
+
if (mentions && request.method === 'GET') return listMentions(all, auth, mentions[1]!, url, publicBase);
|
|
1531
|
+
|
|
1532
|
+
const userTweets = USER_TWEETS_PATH.exec(path);
|
|
1533
|
+
if (userTweets && request.method === 'GET') return listUserTweets(all, auth, userTweets[1]!, url, publicBase);
|
|
1534
|
+
|
|
1535
|
+
const home = HOME_TIMELINE_PATH.exec(path);
|
|
1536
|
+
if (home && request.method === 'GET') return listHomeTimeline(all, auth, home[1]!, url, publicBase);
|
|
1537
|
+
|
|
1538
|
+
const userById = USER_ID_PATH.exec(path);
|
|
1539
|
+
if (userById && request.method === 'GET') return lookupUserById(all, auth, decodeURIComponent(userById[1]!), url);
|
|
1540
|
+
|
|
1541
|
+
const userByUsername = USER_BY_USERNAME_PATH.exec(path);
|
|
1542
|
+
if (userByUsername && request.method === 'GET') return lookupUserByUsername(all, auth, decodeURIComponent(userByUsername[1]!), url);
|
|
1543
|
+
|
|
1544
|
+
return notFoundProblem();
|
|
1545
|
+
}
|