@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.
Files changed (42) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +138 -0
  3. package/dist/client/x-mirror.bundle.js +321 -0
  4. package/dist/client/x-mirror.d.ts +45 -0
  5. package/dist/client/x-mirror.js +417 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +29 -0
  8. package/dist/src/index.d.ts +14 -0
  9. package/dist/src/index.js +68 -0
  10. package/dist/src/x-budget.d.ts +54 -0
  11. package/dist/src/x-budget.js +123 -0
  12. package/dist/src/x-capabilities.d.ts +3 -0
  13. package/dist/src/x-capabilities.js +1106 -0
  14. package/dist/src/x-conformance.d.ts +8 -0
  15. package/dist/src/x-conformance.js +91 -0
  16. package/dist/src/x-connector.d.ts +125 -0
  17. package/dist/src/x-connector.js +546 -0
  18. package/dist/src/x-media.d.ts +87 -0
  19. package/dist/src/x-media.js +275 -0
  20. package/dist/src/x-mirror-ui.d.ts +61 -0
  21. package/dist/src/x-mirror-ui.js +253 -0
  22. package/dist/src/x-problems.d.ts +38 -0
  23. package/dist/src/x-problems.js +130 -0
  24. package/dist/src/x-scopes.d.ts +7 -0
  25. package/dist/src/x-scopes.js +62 -0
  26. package/dist/src/x-server.d.ts +14 -0
  27. package/dist/src/x-server.js +127 -0
  28. package/dist/src/x-twin.d.ts +21 -0
  29. package/dist/src/x-twin.js +1534 -0
  30. package/package.json +58 -0
  31. package/src/cli.ts +27 -0
  32. package/src/index.ts +132 -0
  33. package/src/x-budget.ts +150 -0
  34. package/src/x-capabilities.ts +1161 -0
  35. package/src/x-conformance.ts +113 -0
  36. package/src/x-connector.ts +546 -0
  37. package/src/x-media.ts +295 -0
  38. package/src/x-mirror-ui.ts +263 -0
  39. package/src/x-problems.ts +143 -0
  40. package/src/x-scopes.ts +67 -0
  41. package/src/x-server.ts +126 -0
  42. package/src/x-twin.ts +1545 -0
@@ -0,0 +1,45 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type XRow } from '../src/x-mirror-ui.js';
3
+ export declare function Avatar({ user, size }: {
4
+ user: XRow | undefined;
5
+ size?: number;
6
+ }): import("react").JSX.Element;
7
+ export declare function PostText({ text, big }: {
8
+ text: unknown;
9
+ big?: boolean;
10
+ }): import("react").JSX.Element;
11
+ /**
12
+ * A post's media as x.com draws it: one rounded, hairline-bordered block under the text. One image
13
+ * keeps its own aspect (capped in height); two to four tile a 2-column grid; a video is the player —
14
+ * the file's own first frame until it plays, controls, and the duration badge. Clicks inside the
15
+ * block stay inside it, so playing a video does not open the post.
16
+ */
17
+ export declare function MediaBlock({ media, big }: {
18
+ media: XRow[];
19
+ big?: boolean;
20
+ }): import("react").JSX.Element | null;
21
+ /** One post as x.com's timeline draws it: avatar, name, @handle · time, text, the action row. */
22
+ export declare function PostRow({ post, users, tweets, media, nowMs }: {
23
+ post: XRow;
24
+ users: Map<string, XRow>;
25
+ tweets: Map<string, XRow>;
26
+ media?: Map<string, XRow>;
27
+ nowMs: number;
28
+ }): import("react").JSX.Element;
29
+ type Page = {
30
+ rows: XRow[];
31
+ users: Map<string, XRow>;
32
+ tweets: Map<string, XRow>;
33
+ media: Map<string, XRow>;
34
+ next?: string;
35
+ };
36
+ export declare function Timeline({ page, nowMs, empty, filter }: {
37
+ page: Page;
38
+ nowMs: number;
39
+ empty: ReactNode;
40
+ filter?: (p: XRow) => boolean;
41
+ }): import("react").JSX.Element | null;
42
+ export declare function ProfileHeader({ user }: {
43
+ user: XRow;
44
+ }): import("react").JSX.Element;
45
+ export {};
@@ -0,0 +1,417 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // X mirror — React/TSX client. x.com's own view of the twin's state: sign in as the org account,
3
+ // read its profile, its posts, a single post, its mentions and its home timeline, and compose a
4
+ // post. Every read is the twin's OWN X API v2 on the same origin; the composer writes through
5
+ // POST /2/tweets — the same path the handler serves to any SDK. Nothing is kept but the signed-in
6
+ // handle and bearer, in the tab's sessionStorage.
7
+ import { createRoot } from 'react-dom/client';
8
+ import { useCallback, useEffect, useState } from 'react';
9
+ import { avatarHue, avatarInitial, displayUrl, durationLabel, linkHref, fullTimestamp, joinedLabel, mediaByKey, POST_READ_QUERY, postMedia, PROFILE_READ_QUERY, referencedId, relativeTime, repliesTo, textSegments, tweetsById, usersById, videoSource, } from "../src/x-mirror-ui.js";
10
+ // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative);
11
+ // served under a path prefix with a <base> tag every read and write resolves inside that prefix.
12
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
13
+ const SESSION_HANDLE = 'x-mirror.handle';
14
+ const SESSION_TOKEN = 'x-mirror.token';
15
+ function readSession() {
16
+ if (typeof sessionStorage === 'undefined')
17
+ return null;
18
+ const handle = sessionStorage.getItem(SESSION_HANDLE);
19
+ const token = sessionStorage.getItem(SESSION_TOKEN);
20
+ return handle && token ? { handle, token } : null;
21
+ }
22
+ async function wire(token, path, init) {
23
+ const res = await fetch(`${WIRE_BASE}${path}`, {
24
+ method: init?.method ?? 'GET',
25
+ headers: { authorization: `Bearer ${token}`, ...(init?.body !== undefined ? { 'content-type': 'application/json' } : {}) },
26
+ ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
27
+ });
28
+ let body = {};
29
+ try {
30
+ body = await res.json();
31
+ }
32
+ catch {
33
+ body = {};
34
+ }
35
+ return { status: res.status, body };
36
+ }
37
+ /** X's problem envelope, as one sentence for the screen. */
38
+ function problemText(w) {
39
+ const b = w.body;
40
+ const first = Array.isArray(b.errors) ? b.errors[0] : undefined;
41
+ return String(first?.message ?? first?.detail ?? b.detail ?? b.title ?? `HTTP ${w.status}`);
42
+ }
43
+ function parseRoute(hash) {
44
+ const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean);
45
+ if (parts.length === 0)
46
+ return null;
47
+ if (parts[0] === 'i' && parts[1] === 'flow' && parts[2] === 'login')
48
+ return { page: 'login' };
49
+ if (parts[0] === 'home')
50
+ return { page: 'home' };
51
+ if (parts[0] === 'notifications')
52
+ return { page: 'notifications' };
53
+ if (parts[1] === 'status' && parts[2])
54
+ return { page: 'status', handle: parts[0], id: parts[2] };
55
+ return { page: 'profile', handle: parts[0], tab: parts[1] === 'with_replies' ? 'replies' : 'posts' };
56
+ }
57
+ const go = (path) => { location.hash = `#/${path}`; };
58
+ // ── icons (vendor mirror: x.com's look, drawn here) ─────────────────────────────────────────────
59
+ function Icon({ d, fill = false, size = 18, className }) {
60
+ return (_jsx("svg", { className: className, viewBox: "0 0 24 24", width: size, height: size, "aria-hidden": "true", fill: fill ? 'currentColor' : 'none', stroke: fill ? 'none' : 'currentColor', strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", children: d.map((p) => _jsx("path", { d: p }, p)) }));
61
+ }
62
+ const X_LOGO = ['M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z'];
63
+ const VERIFIED = ['M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.33 2.19c-1.4-.46-2.91-.2-3.92.81s-1.26 2.52-.8 3.91c-1.31.67-2.2 1.91-2.2 3.34s.89 2.67 2.2 3.34c-.46 1.39-.21 2.9.8 3.91s2.52 1.26 3.91.81c.67 1.31 1.91 2.19 3.34 2.19s2.68-.88 3.34-2.19c1.39.45 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.71 4.2L6.8 12.46l1.41-1.42 2.26 2.26 4.8-5.23 1.47 1.36-6.2 6.77z'];
64
+ const I_REPLY = ['M7.9 20A9 9 0 1 0 4 16.1L2 22Z'];
65
+ const I_REPOST = ['m2 9 3-3 3 3', 'M13 18H7a2 2 0 0 1-2-2V6', 'm22 15-3 3-3-3', 'M11 6h6a2 2 0 0 1 2 2v10'];
66
+ const I_LIKE = ['M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z'];
67
+ const I_VIEWS = ['M18 20V10', 'M12 20V4', 'M6 20v-6'];
68
+ const I_BOOKMARK = ['m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z'];
69
+ const I_SHARE = ['M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4', 'M17 8l-5-5-5 5', 'M12 3v12'];
70
+ const I_HOME = ['M3 9.5 12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z'];
71
+ const I_BELL = ['M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9', 'M10.3 21a1.94 1.94 0 0 0 3.4 0'];
72
+ const I_USER = ['M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2', 'M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z'];
73
+ const I_BACK = ['m12 19-7-7 7-7', 'M19 12H5'];
74
+ const I_CAL = ['M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M16 2v4', 'M8 2v4', 'M3 10h18'];
75
+ const I_PIN = ['M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z', 'M12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'];
76
+ const I_LINK = ['M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71', 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'];
77
+ const I_LOCK = ['M5 11h14a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2z', 'M7 11V7a5 5 0 0 1 10 0v4'];
78
+ // ── pieces ───────────────────────────────────────────────────────────────────────────────────────
79
+ export function Avatar({ user, size = 40 }) {
80
+ const hue = avatarHue(user?.username);
81
+ return (_jsx("div", { className: "avatar", style: { width: size, height: size, fontSize: size * 0.42, background: `hsl(${hue} 45% 38%)` }, "aria-hidden": "true", children: avatarInitial(user) }));
82
+ }
83
+ export function PostText({ text, big = false }) {
84
+ return (_jsx("div", { className: big ? 'post-text post-text-big' : 'post-text', children: textSegments(text).map((s, i) => {
85
+ if (s.kind === 'text')
86
+ return _jsx("span", { children: s.value }, i);
87
+ if (s.kind === 'url')
88
+ return _jsx("a", { className: "entity", href: linkHref(s.value), target: "_blank", rel: "noreferrer", onClick: (e) => e.stopPropagation(), children: displayUrl(s.value) }, i);
89
+ if (s.kind === 'mention')
90
+ return _jsx("a", { className: "entity", href: `#/${s.value.slice(1)}`, onClick: (e) => e.stopPropagation(), children: s.value }, i);
91
+ return _jsx("span", { className: "entity", children: s.value }, i);
92
+ }) }));
93
+ }
94
+ function NameLine({ user, children }) {
95
+ return (_jsxs("div", { className: "name-line", children: [_jsx("span", { className: "display-name", children: String(user?.name ?? 'Unknown') }), user?.verified === true ? _jsx(Icon, { d: VERIFIED, fill: true, size: 17, className: "badge" }) : null, user?.protected === true ? _jsx(Icon, { d: I_LOCK, size: 15, className: "lock" }) : null, _jsxs("span", { className: "muted handle", children: ["@", String(user?.username ?? 'unknown')] }), children] }));
96
+ }
97
+ /** The engagement row. The twin models no public_metrics, so the row carries NO counts — an
98
+ * empty slot where x.com prints a number, never an invented zero. */
99
+ function ActionRow({ big = false }) {
100
+ return (_jsxs("div", { className: big ? 'actions actions-big' : 'actions', children: [_jsx("span", { className: "action reply", title: "Reply", children: _jsx(Icon, { d: I_REPLY }) }), _jsx("span", { className: "action repost", title: "Repost", children: _jsx(Icon, { d: I_REPOST }) }), _jsx("span", { className: "action like", title: "Like", children: _jsx(Icon, { d: I_LIKE }) }), _jsx("span", { className: "action views", title: "View post analytics", children: _jsx(Icon, { d: I_VIEWS }) }), _jsxs("span", { className: "action-end", children: [_jsx("span", { className: "action", title: "Bookmark", children: _jsx(Icon, { d: I_BOOKMARK }) }), _jsx("span", { className: "action", title: "Share post", children: _jsx(Icon, { d: I_SHARE }) })] })] }));
101
+ }
102
+ function QuoteCard({ quoted, users, nowMs }) {
103
+ const author = users.get(String(quoted.author_id));
104
+ return (_jsxs("div", { className: "quote", onClick: (e) => { e.stopPropagation(); go(`${author?.username ?? 'i'}/status/${quoted.id}`); }, children: [_jsxs("div", { className: "quote-head", children: [_jsx(Avatar, { user: author, size: 20 }), _jsxs(NameLine, { user: author, children: [_jsx("span", { className: "muted", children: "\u00B7" }), _jsx("span", { className: "muted", children: relativeTime(quoted.created_at, nowMs) })] })] }), _jsx(PostText, { text: quoted.text })] }));
105
+ }
106
+ /**
107
+ * A post's media as x.com draws it: one rounded, hairline-bordered block under the text. One image
108
+ * keeps its own aspect (capped in height); two to four tile a 2-column grid; a video is the player —
109
+ * the file's own first frame until it plays, controls, and the duration badge. Clicks inside the
110
+ * block stay inside it, so playing a video does not open the post.
111
+ */
112
+ export function MediaBlock({ media, big = false }) {
113
+ if (media.length === 0)
114
+ return null;
115
+ const video = media.find((m) => m.type === 'video');
116
+ if (video)
117
+ return _jsx(VideoPlayer, { video: video, big: big });
118
+ const photos = media.filter((m) => m.type === 'photo' && typeof m.url === 'string').slice(0, 4);
119
+ if (photos.length === 0)
120
+ return null;
121
+ const one = photos.length === 1 ? photos[0] : undefined;
122
+ return (_jsx("div", { className: `${big ? 'media media-big' : 'media'} media-${photos.length}`, children: photos.map((m) => (_jsx("img", { className: "media-img", src: String(m.url), alt: "Image", loading: "lazy", style: one && Number(m.width) > 0 && Number(m.height) > 0 ? { aspectRatio: `${m.width} / ${m.height}` } : undefined }, String(m.media_key)))) }));
123
+ }
124
+ /** x.com's player before it plays: the still frame, a centred play button and the duration badge;
125
+ * once started, the player's own controls. No `poster`: the preview the twin serves is a stand-in,
126
+ * so the frame shown is the file's own first frame (`#t=`). */
127
+ function VideoPlayer({ video, big }) {
128
+ const [started, setStarted] = useState(false);
129
+ const src = videoSource(video);
130
+ const ratio = Number(video.width) > 0 && Number(video.height) > 0 ? `${video.width} / ${video.height}` : '16 / 9';
131
+ const start = (e) => {
132
+ e.stopPropagation();
133
+ const el = e.currentTarget.closest('.media')?.querySelector('video');
134
+ setStarted(true);
135
+ void el?.play().catch(() => undefined);
136
+ };
137
+ return (_jsxs("div", { className: big ? 'media media-big' : 'media', "data-media-key": String(video.media_key), onClick: (e) => e.stopPropagation(), children: [src ? _jsx("video", { className: "media-video", style: { aspectRatio: ratio }, src: `${src}#t=0.001`, controls: started, playsInline: true, preload: "metadata" }) : null, !started && src ? (_jsx("button", { className: "media-play", "aria-label": "Play", onClick: start, children: _jsx("svg", { viewBox: "0 0 24 24", width: "30", height: "30", "aria-hidden": "true", children: _jsx("path", { d: "M8 5v14l11-7z", fill: "currentColor" }) }) })) : null, !started && durationLabel(video.duration_ms) ? _jsx("span", { className: "media-badge", children: durationLabel(video.duration_ms) }) : null] }));
138
+ }
139
+ /** One post as x.com's timeline draws it: avatar, name, @handle · time, text, the action row. */
140
+ export function PostRow({ post, users, tweets, media = new Map(), nowMs }) {
141
+ const author = users.get(String(post.author_id));
142
+ const repliedTo = post.in_reply_to_user_id ? users.get(String(post.in_reply_to_user_id)) : undefined;
143
+ const quotedId = referencedId(post, 'quoted');
144
+ const quoted = quotedId ? tweets.get(quotedId) : undefined;
145
+ return (_jsxs("article", { className: "post-row", "data-post-id": String(post.id), onClick: () => go(`${author?.username ?? 'i'}/status/${post.id}`), children: [_jsx(Avatar, { user: author }), _jsxs("div", { className: "post-body", children: [_jsxs(NameLine, { user: author, children: [_jsx("span", { className: "muted", children: "\u00B7" }), _jsx("time", { className: "muted", dateTime: String(post.created_at ?? ''), title: fullTimestamp(post.created_at), children: relativeTime(post.created_at, nowMs) })] }), repliedTo ? _jsxs("div", { className: "muted replying", children: ["Replying to ", _jsxs("span", { className: "entity", children: ["@", String(repliedTo.username)] })] }) : null, post.text ? _jsx(PostText, { text: post.text }) : null, _jsx(MediaBlock, { media: postMedia(post, media) }), quoted ? _jsx(QuoteCard, { quoted: quoted, users: users, nowMs: nowMs }) : null, _jsx(ActionRow, {})] })] }));
146
+ }
147
+ const EMPTY_PAGE = { rows: [], users: new Map(), tweets: new Map(), media: new Map() };
148
+ function mergePage(prev, body) {
149
+ const rows = Array.isArray(body.data) ? body.data : [];
150
+ const users = new Map(prev.users);
151
+ for (const [k, v] of usersById(body.includes))
152
+ users.set(k, v);
153
+ const tweets = new Map(prev.tweets);
154
+ for (const [k, v] of tweetsById(body.includes))
155
+ tweets.set(k, v);
156
+ const media = new Map(prev.media);
157
+ for (const [k, v] of mediaByKey(body.includes))
158
+ media.set(k, v);
159
+ const next = typeof body.meta?.next_token === 'string' ? body.meta.next_token : undefined;
160
+ return { rows: [...prev.rows, ...rows], users, tweets, media, ...(next ? { next } : {}) };
161
+ }
162
+ export function Timeline({ page, nowMs, empty, filter }) {
163
+ const rows = filter ? page.rows.filter(filter) : page.rows;
164
+ if (rows.length === 0)
165
+ return empty === null ? null : _jsx("div", { className: "empty", children: empty });
166
+ return _jsx("div", { className: "timeline", children: rows.map((p) => _jsx(PostRow, { post: p, users: page.users, tweets: page.tweets, media: page.media, nowMs: nowMs }, String(p.id))) });
167
+ }
168
+ /** A paged timeline read: first page on mount, "Show more" follows meta.next_token. */
169
+ function usePagedRead(session, path, reloadKey) {
170
+ const [page, setPage] = useState(EMPTY_PAGE);
171
+ const [error, setError] = useState(null);
172
+ const [loading, setLoading] = useState(false);
173
+ const load = useCallback(async (token, prev) => {
174
+ if (path === null)
175
+ return;
176
+ setLoading(true);
177
+ const w = await wire(session.token, `${path}${path.includes('?') ? '&' : '?'}max_results=50&${POST_READ_QUERY}${token ? `&pagination_token=${encodeURIComponent(token)}` : ''}`);
178
+ setLoading(false);
179
+ if (w.status !== 200) {
180
+ setError(problemText(w));
181
+ return;
182
+ }
183
+ setError(null);
184
+ setPage(mergePage(prev, w.body));
185
+ }, [path, session.token]);
186
+ useEffect(() => { setPage(EMPTY_PAGE); void load(undefined, EMPTY_PAGE); }, [load, reloadKey]);
187
+ return { page, error, loading, more: () => { if (page.next)
188
+ void load(page.next, page); } };
189
+ }
190
+ function MoreButton({ read }) {
191
+ if (!read.page.next)
192
+ return null;
193
+ return _jsx("button", { className: "show-more", disabled: read.loading, onClick: read.more, children: "Show more posts" });
194
+ }
195
+ // ── the composer: POST /2/tweets, the vendor's own write ────────────────────────────────────────
196
+ const LIMIT = 280;
197
+ function Composer({ session, me, onPosted, replyTo }) {
198
+ const [draft, setDraft] = useState('');
199
+ const [error, setError] = useState(null);
200
+ const [busy, setBusy] = useState(false);
201
+ const length = Array.from(draft).length;
202
+ const left = LIMIT - length;
203
+ const post = async () => {
204
+ if (draft.trim() === '' || busy)
205
+ return;
206
+ setBusy(true);
207
+ // a reply is the SAME route with reply.in_reply_to_tweet_id — X's own discriminator
208
+ const body = replyTo ? { text: draft, reply: { in_reply_to_tweet_id: replyTo.id } } : { text: draft };
209
+ const w = await wire(session.token, '/2/tweets', { method: 'POST', body });
210
+ setBusy(false);
211
+ if (w.status !== 201 && w.status !== 200) {
212
+ setError(problemText(w));
213
+ return;
214
+ }
215
+ setError(null);
216
+ setDraft('');
217
+ onPosted(String(w.body.data?.id ?? ''));
218
+ };
219
+ const pct = Math.min(1, length / LIMIT);
220
+ return (_jsxs("div", { className: replyTo ? 'composer composer-reply' : 'composer', children: [_jsx(Avatar, { user: me ?? { username: session.handle } }), _jsxs("div", { className: "composer-body", children: [replyTo ? _jsxs("div", { className: "muted replying", children: ["Replying to ", _jsxs("span", { className: "entity", children: ["@", replyTo.handle] })] }) : null, _jsx("textarea", { className: "composer-input", placeholder: replyTo ? 'Post your reply' : 'What is happening?!', value: draft, rows: replyTo ? 1 : 2, onChange: (e) => setDraft(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
221
+ void post(); } }), error ? _jsx("div", { className: "composer-error", role: "alert", children: error }) : null, _jsxs("div", { className: "composer-bar", children: [_jsx("span", { className: "composer-reach", children: replyTo ? '' : 'Everyone can reply' }), _jsxs("span", { className: "composer-right", children: [length > 0 ? (_jsxs("span", { className: left < 0 ? 'counter over' : left <= 20 ? 'counter warn' : 'counter', children: [_jsxs("svg", { viewBox: "0 0 20 20", width: "20", height: "20", "aria-hidden": "true", children: [_jsx("circle", { cx: "10", cy: "10", r: "8", fill: "none", stroke: "#2f3336", strokeWidth: "2" }), _jsx("circle", { cx: "10", cy: "10", r: "8", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeDasharray: `${pct * 50.27} 50.27`, transform: "rotate(-90 10 10)" })] }), left <= 20 ? _jsx("span", { className: "counter-num", children: left }) : null] })) : null, _jsx("button", { className: "btn-post", disabled: draft.trim() === '' || busy, onClick: () => void post(), children: replyTo ? 'Reply' : 'Post' })] })] })] })] }));
222
+ }
223
+ // ── screens ──────────────────────────────────────────────────────────────────────────────────────
224
+ function Header({ title, sub, back }) {
225
+ return (_jsxs("div", { className: "col-header", children: [back ? _jsx("button", { className: "back", "aria-label": "Back", onClick: () => history.back(), children: _jsx(Icon, { d: I_BACK, size: 20 }) }) : null, _jsxs("div", { children: [_jsx("div", { className: "col-title", children: title }), sub ? _jsx("div", { className: "muted col-sub", children: sub }) : null] })] }));
226
+ }
227
+ export function ProfileHeader({ user }) {
228
+ return (_jsxs("div", { className: "profile", children: [_jsx("div", { className: "banner" }), _jsx("div", { className: "profile-top", children: _jsx("div", { className: "profile-avatar", children: _jsx(Avatar, { user: user, size: 134 }) }) }), _jsxs("div", { className: "profile-info", children: [_jsxs("div", { className: "profile-name", children: [_jsx("span", { children: String(user.name ?? '') }), user.verified === true ? _jsx(Icon, { d: VERIFIED, fill: true, size: 22, className: "badge" }) : null, user.protected === true ? _jsx(Icon, { d: I_LOCK, size: 18, className: "lock" }) : null] }), _jsxs("div", { className: "muted profile-handle", children: ["@", String(user.username ?? '')] }), typeof user.description === 'string' && user.description !== '' ? _jsx("div", { className: "profile-bio", children: _jsx(PostText, { text: user.description }) }) : null, _jsxs("div", { className: "muted profile-meta", children: [typeof user.location === 'string' && user.location !== '' ? _jsxs("span", { children: [_jsx(Icon, { d: I_PIN, size: 16 }), user.location] }) : null, typeof user.url === 'string' && user.url !== '' ? _jsxs("span", { children: [_jsx(Icon, { d: I_LINK, size: 16 }), _jsx("a", { className: "entity", href: linkHref(user.url), target: "_blank", rel: "noreferrer", children: displayUrl(user.url) })] }) : null, user.created_at ? _jsxs("span", { children: [_jsx(Icon, { d: I_CAL, size: 16 }), joinedLabel(user.created_at)] }) : null] })] })] }));
229
+ }
230
+ function ProfileScreen({ session, handle, tab, nowMs, reloadKey }) {
231
+ const [user, setUser] = useState(null);
232
+ const [error, setError] = useState(null);
233
+ useEffect(() => {
234
+ setUser(null);
235
+ setError(null);
236
+ void wire(session.token, `/2/users/by/username/${encodeURIComponent(handle)}?${PROFILE_READ_QUERY}`).then((w) => {
237
+ if (w.status === 200 && w.body.data)
238
+ setUser(w.body.data);
239
+ else
240
+ setError(w.status === 200 ? 'This account doesn’t exist' : problemText(w));
241
+ });
242
+ }, [handle, session.token, reloadKey]);
243
+ const read = usePagedRead(session, user ? `/2/users/${user.id}/tweets` : null, reloadKey);
244
+ if (error)
245
+ return _jsxs(_Fragment, { children: [_jsx(Header, { title: handle, back: true }), _jsxs("div", { className: "empty", children: [_jsx("div", { className: "empty-title", children: error }), _jsx("div", { className: "muted", children: "Try searching for another." })] })] });
246
+ if (!user)
247
+ return _jsxs(_Fragment, { children: [_jsx(Header, { title: handle, back: true }), _jsx("div", { className: "loading" })] });
248
+ const isReply = (p) => referencedId(p, 'replied_to') !== undefined;
249
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: String(user.name ?? handle), sub: `${read.page.rows.length}${read.page.next ? '+' : ''} ${read.page.rows.length === 1 && !read.page.next ? 'post' : 'posts'}`, back: true }), _jsx(ProfileHeader, { user: user }), _jsxs("nav", { className: "tabs", children: [_jsx("a", { className: tab === 'posts' ? 'tab active' : 'tab', href: `#/${user.username}`, children: "Posts" }), _jsx("a", { className: tab === 'replies' ? 'tab active' : 'tab', href: `#/${user.username}/with_replies`, children: "Replies" })] }), read.error ? _jsx("div", { className: "empty", children: read.error }) : null, _jsx(Timeline, { page: read.page, nowMs: nowMs, filter: tab === 'posts' ? (p) => !isReply(p) : undefined, empty: _jsxs(_Fragment, { children: [_jsx("div", { className: "empty-title", children: `@${user.username} hasn’t posted` }), _jsx("div", { className: "muted", children: "When they do, their posts will show up here." })] }) }), _jsx(MoreButton, { read: read })] }));
250
+ }
251
+ /** The replies the signed-in account can read: its own posts and its mentions, the two timelines
252
+ * the twin serves it. A reply by an account that neither is nor mentions the viewer is not
253
+ * readable here, exactly as it is not readable through the API. */
254
+ function useReplies(session, me, id, reloadKey) {
255
+ const [page, setPage] = useState(EMPTY_PAGE);
256
+ useEffect(() => {
257
+ if (!me)
258
+ return;
259
+ let live = true;
260
+ void Promise.all([
261
+ wire(session.token, `/2/users/${me.id}/tweets?max_results=100&${POST_READ_QUERY}`),
262
+ wire(session.token, `/2/users/${me.id}/mentions?max_results=100&${POST_READ_QUERY}`),
263
+ ]).then(([own, mentions]) => {
264
+ if (!live)
265
+ return;
266
+ const merged = [own, mentions].reduce((acc, w) => (w.status === 200 ? mergePage(acc, w.body) : acc), EMPTY_PAGE);
267
+ setPage({ rows: repliesTo(id, merged.rows), users: merged.users, tweets: merged.tweets, media: merged.media });
268
+ });
269
+ return () => { live = false; };
270
+ }, [session.token, me, id, reloadKey]);
271
+ return page;
272
+ }
273
+ function StatusScreen({ session, me, id, nowMs, reloadKey, onPosted }) {
274
+ const [w, setW] = useState(null);
275
+ useEffect(() => { setW(null); void wire(session.token, `/2/tweets/${encodeURIComponent(id)}?${POST_READ_QUERY}`).then(setW); }, [id, session.token, reloadKey]);
276
+ const replies = useReplies(session, me, id, reloadKey);
277
+ if (w === null)
278
+ return _jsxs(_Fragment, { children: [_jsx(Header, { title: "Post", back: true }), _jsx("div", { className: "loading" })] });
279
+ const post = w.body.data;
280
+ if (w.status !== 200 || !post) {
281
+ return _jsxs(_Fragment, { children: [_jsx(Header, { title: "Post", back: true }), _jsxs("div", { className: "empty", children: [_jsx("div", { className: "empty-title", children: "Hmm...this page doesn\u2019t exist." }), _jsx("div", { className: "muted", children: problemText(w) })] })] });
282
+ }
283
+ const users = usersById(w.body.includes);
284
+ const tweets = tweetsById(w.body.includes);
285
+ const media = mediaByKey(w.body.includes);
286
+ const author = users.get(String(post.author_id));
287
+ const parentId = referencedId(post, 'replied_to');
288
+ const parent = parentId ? tweets.get(parentId) : undefined;
289
+ const quotedId = referencedId(post, 'quoted');
290
+ const quoted = quotedId ? tweets.get(quotedId) : undefined;
291
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: "Post", back: true }), parent ? _jsx(PostRow, { post: parent, users: users, tweets: tweets, media: media, nowMs: nowMs }) : null, _jsxs("article", { className: "status", "data-post-id": String(post.id), children: [_jsxs("div", { className: "status-head", children: [_jsx(Avatar, { user: author }), _jsxs("div", { className: "status-names", children: [_jsxs("div", { className: "name-line", children: [_jsx("span", { className: "display-name", children: String(author?.name ?? 'Unknown') }), author?.verified === true ? _jsx(Icon, { d: VERIFIED, fill: true, size: 17, className: "badge" }) : null] }), _jsxs("div", { className: "muted", children: ["@", String(author?.username ?? 'unknown')] })] })] }), post.in_reply_to_user_id && users.get(String(post.in_reply_to_user_id))
292
+ ? _jsxs("div", { className: "muted replying", children: ["Replying to ", _jsxs("span", { className: "entity", children: ["@", String(users.get(String(post.in_reply_to_user_id)).username)] })] }) : null, post.text ? _jsx(PostText, { text: post.text, big: true }) : null, _jsx(MediaBlock, { media: postMedia(post, media), big: true }), quoted ? _jsx(QuoteCard, { quoted: quoted, users: users, nowMs: nowMs }) : null, _jsx("div", { className: "muted status-time", children: _jsx("time", { dateTime: String(post.created_at ?? ''), children: fullTimestamp(post.created_at) }) }), _jsx(ActionRow, { big: true })] }), _jsx(Composer, { session: session, me: me, onPosted: onPosted, replyTo: { id: String(post.id), handle: String(author?.username ?? '') } }), _jsx(Timeline, { page: replies, nowMs: nowMs, empty: null })] }));
293
+ }
294
+ function HomeScreen({ session, me, nowMs, reloadKey, onPosted }) {
295
+ const read = usePagedRead(session, me ? `/2/users/${me.id}/timelines/reverse_chronological` : null, reloadKey);
296
+ return (_jsxs(_Fragment, { children: [_jsx("nav", { className: "tabs tabs-top", children: _jsx("span", { className: "tab active", children: "Following" }) }), _jsx(Composer, { session: session, me: me, onPosted: onPosted }), read.error ? _jsx("div", { className: "empty", children: read.error }) : null, _jsx(Timeline, { page: read.page, nowMs: nowMs, empty: _jsxs(_Fragment, { children: [_jsx("div", { className: "empty-title", children: "Welcome to X!" }), _jsx("div", { className: "muted", children: "Posts from you and the accounts you follow show up here." })] }) }), _jsx(MoreButton, { read: read })] }));
297
+ }
298
+ function NotificationsScreen({ session, me, nowMs, reloadKey }) {
299
+ const read = usePagedRead(session, me ? `/2/users/${me.id}/mentions` : null, reloadKey);
300
+ return (_jsxs(_Fragment, { children: [_jsx(Header, { title: "Notifications" }), _jsx("nav", { className: "tabs", children: _jsx("span", { className: "tab active", children: "Mentions" }) }), read.error ? _jsx("div", { className: "empty", children: read.error }) : null, _jsx(Timeline, { page: read.page, nowMs: nowMs, empty: _jsxs(_Fragment, { children: [_jsx("div", { className: "empty-title", children: "Nothing to see here \u2014 yet" }), _jsx("div", { className: "muted", children: "When someone mentions you, you\u2019ll find it here." })] }) }), _jsx(MoreButton, { read: read })] }));
301
+ }
302
+ // ── sign-in: x.com's two-step login, the password leg being the twin-issued bearer ──────────────
303
+ function LoginScreen({ onSignedIn }) {
304
+ const [step, setStep] = useState('handle');
305
+ const [handle, setHandle] = useState('');
306
+ const [token, setToken] = useState('');
307
+ const [error, setError] = useState(null);
308
+ const [busy, setBusy] = useState(false);
309
+ const cleanHandle = handle.trim().replace(/^@/, '');
310
+ const next = () => {
311
+ if (!/^[A-Za-z0-9_]{1,15}$/.test(cleanHandle)) {
312
+ setError('Sorry, we could not find your account.');
313
+ return;
314
+ }
315
+ setError(null);
316
+ setStep('token');
317
+ };
318
+ const login = async () => {
319
+ setBusy(true);
320
+ const w = await wire(token.trim(), `/2/users/by/username/${encodeURIComponent(cleanHandle)}`);
321
+ if (w.status === 401) {
322
+ setBusy(false);
323
+ setError('Wrong token!');
324
+ return;
325
+ }
326
+ if (w.status !== 200 || !w.body.data) {
327
+ setBusy(false);
328
+ setError(w.status === 200 ? 'Sorry, we could not find your account.' : problemText(w));
329
+ return;
330
+ }
331
+ const me = w.body.data;
332
+ // Is this token THIS account's? X serves the home timeline only to its own account, so the
333
+ // vendor's own refusal answers it; a token for another account is refused here, not later.
334
+ const own = await wire(token.trim(), `/2/users/${me.id}/timelines/reverse_chronological?max_results=5`);
335
+ setBusy(false);
336
+ if (own.status === 403) {
337
+ setError(`That token does not sign in as @${me.username}.`);
338
+ return;
339
+ }
340
+ if (own.status !== 200) {
341
+ setError(problemText(own));
342
+ return;
343
+ }
344
+ onSignedIn({ handle: String(me.username), token: token.trim() }, me);
345
+ };
346
+ return (_jsx("div", { className: "login-backdrop", children: _jsxs("div", { className: "login", children: [_jsx("div", { className: "login-logo", children: _jsx(Icon, { d: X_LOGO, fill: true, size: 32 }) }), step === 'handle' ? (_jsxs("form", { className: "login-form", onSubmit: (e) => { e.preventDefault(); next(); }, children: [_jsx("h1", { children: "Sign in to X" }), _jsxs("label", { className: "field", children: [_jsx("span", { children: "Phone, email, or username" }), _jsx("input", { autoFocus: true, name: "handle", value: handle, onChange: (e) => setHandle(e.target.value), autoComplete: "username" })] }), error ? _jsx("div", { className: "login-error", role: "alert", children: error }) : null, _jsx("button", { className: "btn-white", type: "submit", disabled: cleanHandle === '', children: "Next" }), _jsxs("div", { className: "muted login-note", children: ["This twin signs you in with the access token it issued for the account (", _jsx("code", { children: "/_twin/tokens" }), "), in place of a password."] })] })) : (_jsxs("form", { className: "login-form", onSubmit: (e) => { e.preventDefault(); void login(); }, children: [_jsx("h1", { children: "Enter your password" }), _jsxs("label", { className: "field field-disabled", children: [_jsx("span", { children: "Username" }), _jsx("input", { value: `@${cleanHandle}`, disabled: true })] }), _jsxs("label", { className: "field", children: [_jsx("span", { children: "Access token" }), _jsx("input", { autoFocus: true, name: "token", type: "password", value: token, onChange: (e) => setToken(e.target.value), autoComplete: "current-password" })] }), error ? _jsx("div", { className: "login-error", role: "alert", children: error }) : null, _jsx("button", { className: "btn-white", type: "submit", disabled: token.trim() === '' || busy, children: "Log in" }), _jsx("button", { className: "btn-link", type: "button", onClick: () => { setStep('handle'); setError(null); }, children: "Use another account" })] }))] }) }));
347
+ }
348
+ function App() {
349
+ const [session, setSession] = useState(readSession);
350
+ const [me, setMe] = useState(null);
351
+ const [route, setRoute] = useState(() => parseRoute(typeof location === 'undefined' ? '' : location.hash));
352
+ const [reloadKey, setReloadKey] = useState(0);
353
+ const [toast, setToast] = useState(null);
354
+ const [nowMs, setNowMs] = useState(() => Date.now());
355
+ useEffect(() => {
356
+ const onHash = () => { setRoute(parseRoute(location.hash)); setNowMs(Date.now()); window.scrollTo(0, 0); };
357
+ // a `#/…` link is one of the mirror's own routes; under a World's <base> (the twin's place, not the
358
+ // shell's) the browser would resolve it against the base and leave the shell, so it moves the hash here
359
+ const onClick = (e) => {
360
+ const a = e.target?.closest?.('a[href^="#/"]');
361
+ if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
362
+ return;
363
+ e.preventDefault();
364
+ location.hash = a.getAttribute('href');
365
+ };
366
+ window.addEventListener('hashchange', onHash);
367
+ document.addEventListener('click', onClick, true);
368
+ return () => { window.removeEventListener('hashchange', onHash); document.removeEventListener('click', onClick, true); };
369
+ }, []);
370
+ useEffect(() => { const t = setInterval(() => setNowMs(Date.now()), 30_000); return () => clearInterval(t); }, []);
371
+ // who am I: the signed-in handle, read back through the vendor's own user lookup
372
+ useEffect(() => {
373
+ if (!session) {
374
+ setMe(null);
375
+ return;
376
+ }
377
+ void wire(session.token, `/2/users/by/username/${encodeURIComponent(session.handle)}?user.fields=verified,protected`).then((w) => {
378
+ if (w.status === 200 && w.body.data)
379
+ setMe(w.body.data);
380
+ else if (w.status === 401)
381
+ signOut();
382
+ });
383
+ }, [session]);
384
+ const signIn = (s, user) => {
385
+ sessionStorage.setItem(SESSION_HANDLE, s.handle);
386
+ sessionStorage.setItem(SESSION_TOKEN, s.token);
387
+ setMe(user);
388
+ setSession(s);
389
+ go(s.handle);
390
+ };
391
+ const signOut = () => {
392
+ sessionStorage.removeItem(SESSION_HANDLE);
393
+ sessionStorage.removeItem(SESSION_TOKEN);
394
+ setSession(null);
395
+ setMe(null);
396
+ go('i/flow/login');
397
+ };
398
+ const onPosted = (id) => { setReloadKey((k) => k + 1); setNowMs(Date.now()); setToast({ id }); setTimeout(() => setToast(null), 6000); };
399
+ if (!session || route?.page === 'login')
400
+ return _jsx(LoginScreen, { onSignedIn: signIn });
401
+ const r = route ?? { page: 'profile', handle: session.handle, tab: 'posts' };
402
+ const handle = me?.username ? String(me.username) : session.handle;
403
+ let screen;
404
+ if (r.page === 'home')
405
+ screen = _jsx(HomeScreen, { session: session, me: me, nowMs: nowMs, reloadKey: reloadKey, onPosted: onPosted });
406
+ else if (r.page === 'notifications')
407
+ screen = _jsx(NotificationsScreen, { session: session, me: me, nowMs: nowMs, reloadKey: reloadKey });
408
+ else if (r.page === 'status')
409
+ screen = _jsx(StatusScreen, { session: session, me: me, id: r.id, nowMs: nowMs, reloadKey: reloadKey, onPosted: onPosted });
410
+ else
411
+ screen = _jsx(ProfileScreen, { session: session, handle: r.page === 'profile' ? r.handle : handle, tab: r.page === 'profile' ? r.tab : 'posts', nowMs: nowMs, reloadKey: reloadKey });
412
+ const onProfile = r.page === 'profile' && r.handle.toLowerCase() === handle.toLowerCase();
413
+ return (_jsxs("div", { className: "layout", children: [_jsxs("header", { className: "sidenav", children: [_jsx("a", { className: "nav-logo", href: "#/home", "aria-label": "X", children: _jsx(Icon, { d: X_LOGO, fill: true, size: 30 }) }), _jsxs("a", { className: r.page === 'home' ? 'nav-item active' : 'nav-item', href: "#/home", children: [_jsx(Icon, { d: I_HOME, size: 26 }), _jsx("span", { children: "Home" })] }), _jsxs("a", { className: r.page === 'notifications' ? 'nav-item active' : 'nav-item', href: "#/notifications", children: [_jsx(Icon, { d: I_BELL, size: 26 }), _jsx("span", { children: "Notifications" })] }), _jsxs("a", { className: onProfile ? 'nav-item active' : 'nav-item', href: `#/${handle}`, children: [_jsx(Icon, { d: I_USER, size: 26 }), _jsx("span", { children: "Profile" })] }), _jsx("button", { className: "btn-nav-post", onClick: () => go('home'), children: "Post" }), _jsxs("div", { className: "nav-account", title: "Log out", onClick: signOut, children: [_jsx(Avatar, { user: me ?? { username: session.handle } }), _jsxs("div", { className: "nav-account-names", children: [_jsx("div", { className: "display-name", children: String(me?.name ?? session.handle) }), _jsxs("div", { className: "muted", children: ["@", handle] })] }), _jsx("span", { className: "muted nav-logout", children: "Log out" })] })] }), _jsx("main", { className: "column", children: screen }), _jsx("aside", { className: "rail" }), toast ? (_jsxs("div", { className: "toast", role: "status", children: [_jsx("span", { children: "Your post was sent." }), toast.id ? _jsx("a", { href: `#/${handle}/status/${toast.id}`, children: "View" }) : null] })) : null] }));
414
+ }
415
+ const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
416
+ if (el)
417
+ createRoot(el).render(_jsx(App, {}));
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ import { hasFlag, optionValue } from '@volter/world-core/args';
4
+ import { createXTwinServer } from "./x-server.js";
5
+ import { createXMirrorServer } from "./x-mirror-ui.js";
6
+ const [cmd, ...rest] = process.argv.slice(2);
7
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
8
+ const root = optionValue(rest, '--root') || undefined;
9
+ const readOnly = hasFlag(rest, '--read-only');
10
+ if (cmd === 'serve') {
11
+ const server = await createXTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
12
+ process.stdout.write(`x twin${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${server.port}\n`);
13
+ await keepProcessAlive();
14
+ }
15
+ else if (cmd === 'mirror') {
16
+ const s = await createXMirrorServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
17
+ process.stdout.write(`x mirror UI (x.com: sign in, profile, timeline, post, compose) at ${s.url}\n`);
18
+ await keepProcessAlive();
19
+ }
20
+ else if (cmd === 'conformance') {
21
+ const { checkXConformance } = await import("./x-conformance.js");
22
+ const report = await checkXConformance({ ...(root ? { root } : {}) });
23
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
24
+ if (!report.ok)
25
+ process.exitCode = 1;
26
+ }
27
+ else {
28
+ process.stdout.write('Usage: world-x serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
29
+ }
@@ -0,0 +1,14 @@
1
+ export { handleXTwinRequest } from './x-twin.js';
2
+ export type { XRequest } from './x-twin.js';
3
+ export { createXTwinFetch, createXTwinServer, type XTwinFetchOptions } from './x-server.js';
4
+ export { budgetedXExecute, liveXExecute, MAX_PROCESSING_POLLS, MAX_PROCESSING_WAIT_MS, XVideoStillProcessingError, mapTimelinePost, pullXMentions, pullXOwnTimeline, pushPendingXActions, pushXAction, performXAction, PULL_TWEET_FIELDS, syncXFromReal, xRequestForAction, } from './x-connector.js';
5
+ export type { LiveXOptions, XExecute } from './x-connector.js';
6
+ export { forbiddenProblem, invalidRequestProblem, notFoundProblem, rateLimitExceeded, readOnlyRefusal, resourceNotFoundProblem, unauthorizedProblem, } from './x-problems.js';
7
+ export type { XResponse } from './x-problems.js';
8
+ export { avatarHue, avatarInitial, buildXMirrorClient, createXMirrorServer, displayUrl, durationLabel, fullTimestamp, joinedLabel, mediaByKey, postMedia, PROFILE_READ_QUERY, POST_READ_QUERY, referencedId, relativeTime, textSegments, tweetsById, usersById, videoSource, xMirrorHtml, xMirrorStyles, } from './x-mirror-ui.js';
9
+ export type { TextSegment, XRow } from './x-mirror-ui.js';
10
+ export { missingScopes, parseScopeList, REQUIRED_SCOPES, X_SCOPES } from './x-scopes.js';
11
+ export { X_BUDGET_BURST_CEILING, X_BUDGET_CEILING, X_BUDGET_MAX_RETRY_AFTER_S, X_BUDGET_WINDOW_MS, X_CALL_WEIGHTS, X_RATE_BUDGET, XBudget, XBudgetError, xBudgetPath, xCallWeight, } from './x-budget.js';
12
+ export type { XBudgetErrorKind, XBudgetOptions, XBudgetReservation, XBudgetSnapshot } from './x-budget.js';
13
+ import type { TwinPack } from '@volter/world-core';
14
+ export declare const pack: TwinPack;
@@ -0,0 +1,68 @@
1
+ export { handleXTwinRequest } from "./x-twin.js";
2
+ export { createXTwinFetch, createXTwinServer } from "./x-server.js";
3
+ export { budgetedXExecute, liveXExecute, MAX_PROCESSING_POLLS, MAX_PROCESSING_WAIT_MS, XVideoStillProcessingError, mapTimelinePost, pullXMentions, pullXOwnTimeline, pushPendingXActions, pushXAction, performXAction, PULL_TWEET_FIELDS, syncXFromReal, xRequestForAction, } from "./x-connector.js";
4
+ export { forbiddenProblem, invalidRequestProblem, notFoundProblem, rateLimitExceeded, readOnlyRefusal, resourceNotFoundProblem, unauthorizedProblem, } from "./x-problems.js";
5
+ export { avatarHue, avatarInitial, buildXMirrorClient, createXMirrorServer, displayUrl, durationLabel, fullTimestamp, joinedLabel, mediaByKey, postMedia, PROFILE_READ_QUERY, POST_READ_QUERY, referencedId, relativeTime, textSegments, tweetsById, usersById, videoSource, xMirrorHtml, xMirrorStyles, } from "./x-mirror-ui.js";
6
+ export { missingScopes, parseScopeList, REQUIRED_SCOPES, X_SCOPES } from "./x-scopes.js";
7
+ // The client-side rate budget — the fail-closed backstop `liveXExecute` routes every live
8
+ // request through. The mechanism is the kernel's (`@volter/world-core` -> rateBudget.ts); these are
9
+ // this vendor's numbers and the typed bindings around them.
10
+ export { X_BUDGET_BURST_CEILING, X_BUDGET_CEILING, X_BUDGET_MAX_RETRY_AFTER_S, X_BUDGET_WINDOW_MS, X_CALL_WEIGHTS, X_RATE_BUDGET, XBudget, XBudgetError, xBudgetPath, xCallWeight, } from "./x-budget.js";
11
+ import { X_RATE_BUDGET as RATE_BUDGET } from "./x-budget.js";
12
+ /**
13
+ * The api.x.com / api.twitter.com paths THIS pack serves, as a RegExp SOURCE for the descriptor's
14
+ * `hosts` path rule (the injector compiles it with `new RegExp` and tests it against the pathname).
15
+ * The POSTING slice — create/quote/reply (one route), retract, post lookup (single and bulk),
16
+ * recent search, the three per-user timelines (mentions, the account's own posts, the home
17
+ * timeline), user lookup by id and by username, and image upload (one-shot and chunked) — plus the
18
+ * twin-only control prefix. The user-id
19
+ * rule EXCLUDES `/2/users/me`: that route is xidentity's, and the two claims stay disjoint. ANCHORED: an unanchored claim would swallow
20
+ * `/2/tweetsomething`. Everything else on api.x.com stays unclaimed, so an unmodelled X call
21
+ * refuses loudly rather than landing in a twin that cannot serve it.
22
+ */
23
+ const X_API_PATHS = '^(?:/2/media/upload/?|/2/media/upload/initialize/?|/2/media/upload/[0-9]+/(?:append|finalize)/?|/2/tweets/?|/2/tweets/[^/]+/?|/2/tweets/search/recent/?|/2/users/(?!me/?$)[^/]+/?|/2/users/by/username/[^/]+/?|/2/users/[^/]+/mentions/?|/2/users/[^/]+/tweets/?|/2/users/[^/]+/timelines/reverse_chronological/?)$|^/_twin/';
24
+ export const pack = {
25
+ vendor: 'x',
26
+ // The SAME object x-budget.ts declares at module load — one source of truth, so registering
27
+ // the pack and importing the connector arm identical numbers.
28
+ rateBudget: RATE_BUDGET,
29
+ transport: 'rest',
30
+ archetype: 'crud',
31
+ bin: 'world-x',
32
+ resources: ['account', 'token', 'post', 'follow', 'rate_limit'],
33
+ specSource: 'X API v2 posting surface — POST /2/tweets (original post, QUOTE and reply, told apart by quote_tweet_id / reply.in_reply_to_tweet_id), DELETE /2/tweets/:id, GET /2/tweets/:id and GET /2/tweets?ids=, GET /2/tweets/search/recent, GET /2/users/:id/{mentions,tweets,timelines/reverse_chronological}, GET /2/users/:id and GET /2/users/by/username/:username, with tweet.fields/user.fields/expansions projection and next_token paging. Routes and body shapes grounded in the pinned twitter-api-v2@1.29.1 client source; error envelopes and the scope catalog transcribed from packages/twin/xidentity (X API v2 OpenAPI 2.167). See test-fixtures/x-openapi-operations.SOURCE.md.',
34
+ description: "X twin — the org's public voice: post, quote, reply to a mention, retract, look a post or an account back up, and read the mentions / own / home timelines and recent search, offline, with X's own tweet.fields projection, bearer/scope gate and problem envelopes. OAuth itself is xidentity's and stays there.",
35
+ // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
36
+ // 2026-08-31).
37
+ //
38
+ // `twitter-api-v2` is claimed by THIS pack, not by `xidentity`: it is a third-party library
39
+ // spanning the whole X API (posts, streams, DMs), and this pack models the POSTING surface it is
40
+ // actually installed for. No env stem is claimed — X_CLIENT_ID / X_CLIENT_SECRET and
41
+ // TWITTER_CLIENT_ID name the OAuth APP credentials, which is xidentity's surface; a repo that
42
+ // posts holds a user access token that flow minted, so the app credential and the posting client
43
+ // are the same integration seen from two sides and both twins report as covered.
44
+ adoption: {
45
+ // COMMUNITY: X ships no Python SDK (its official clients are `@xdevplatform/xdk` and the legacy
46
+ // `twitter-api-sdk`, both TypeScript). `tweepy` is the de-facto Python client of the X API and is
47
+ // claimed HERE rather than on `xidentity` because its dominant use is exactly this pack's surface
48
+ // - posting and reading tweets/mentions - not the OAuth token exchange. One name, one pack.
49
+ pypi: ['tweepy'],
50
+ sdks: ['twitter-api-v2'],
51
+ },
52
+ // The POSTING surface — the org's public voice. Path-scoped for the same reason xidentity is:
53
+ // api.x.com is the whole X API v2 host and this pack models one slice. The two packs' path sets
54
+ // are DISJOINT BY CONSTRUCTION: `xidentity` claims `/2/oauth2/{token,revoke}` and `/2/users/me`;
55
+ // this claims `/2/tweets*`, the per-user timelines, `/2/users/{id}` and
56
+ // `/2/users/by/username/{username}`. The user-id rule carries a `(?!me/?$)` lookahead and the
57
+ // timeline rules need a trailing segment, so `/2/users/me` matches none of them and no request
58
+ // is claimable by both. ANCHORED, the
59
+ // googleoauth lesson: an unanchored claim swallows `/2/tweetsomething`. Everything else on
60
+ // api.x.com — DMs, spaces, lists, media metadata — stays unclaimed, so an unmodelled X call
61
+ // refuses loudly rather than landing in a twin that cannot serve it. api.twitter.com is claimed
62
+ // alongside api.x.com because that is the host X's own legacy clients still target.
63
+ hosts: [
64
+ { host: 'api.x.com', pathPattern: X_API_PATHS },
65
+ { host: 'api.twitter.com', pathPattern: X_API_PATHS },
66
+ ],
67
+ browserRouting: { apiPathPrefix: '/2/', loaderHost: 'https://api.x.com' },
68
+ };