@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,130 @@
1
+ // X API v2 error envelopes for the POSTING surface.
2
+ //
3
+ // TRANSCRIBED, NOT SHARED. `packages/twin/xidentity/src/xidentity-problems.ts` already models
4
+ // X's error families for the identity surface, and two packs disagreeing about one vendor's
5
+ // error shape would be worse than one pack. The architecture guardrail forbids a cross-vendor
6
+ // pack import (`scripts/architecture.test.ts`, A3: "does not import another vendor pack"), so
7
+ // the agreement is kept by TRANSCRIPTION and stated here, at the seam, rather than by a shared
8
+ // module: every envelope below is byte-identical in shape to the xidentity function named beside
9
+ // it. If X's error shape is ever re-grounded, both files move together.
10
+ //
11
+ // The families this surface can produce (numbering follows xidentity-problems.ts):
12
+ //
13
+ // 2. v2 problem envelopes — the OpenAPI 2.167 `Problem` family, discriminated by a `type`
14
+ // URL. The generic auth failure is the `about:blank` form.
15
+ // (xidentity: `unauthorizedProblem` / `forbiddenProblem` / `notFoundProblem`.)
16
+ // 3. the request-level invalid-parameter envelope `{errors:[{parameters,message}], title,
17
+ // detail, type: …/invalid-request}` — the OpenAPI's InvalidRequestProblem.
18
+ // (xidentity: `invalidRequestProblem`.)
19
+ // 4. the 15-minute-window rate refusal: HTTP 429 with legacy error code 88.
20
+ // (xidentity: `rateLimitExceeded`.)
21
+ //
22
+ // Family 1 (the RFC 6749 token-endpoint body) cannot arise here: OAuth is xidentity's and stays
23
+ // there. This pack consumes the bearer token that flow produces; it never mints one.
24
+ //
25
+ // EVIDENCE BOUNDARY. Shapes are the OpenAPI 2.167 `Problem` family as recorded in
26
+ // spec-sources.json's xidentity entry (live-fetched 2026-08-21). Exact wire WORDING for the
27
+ // posting surface's refusals was not captured from an official artefact by this build — no X
28
+ // call is made anywhere in this pack — so each string that is not shape-forced is filed as a
29
+ // pinning todo in the manifest (`x.errors.*_wording`) rather than claimed pinned.
30
+ const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
31
+ const PROBLEM = { ...NOSTORE, 'content-type': 'application/problem+json' };
32
+ /** The generic auth problem X answers a bad/absent/expired bearer with on /2 resources. */
33
+ export function unauthorizedProblem() {
34
+ return {
35
+ status: 401,
36
+ body: { title: 'Unauthorized', type: 'about:blank', status: 401, detail: 'Unauthorized' },
37
+ headers: { ...PROBLEM },
38
+ };
39
+ }
40
+ /** A user token whose scope set does not cover the endpoint. Wording: `x.errors.scope_wording`. */
41
+ export function forbiddenProblem(detail) {
42
+ return {
43
+ status: 403,
44
+ body: { title: 'Forbidden', type: 'about:blank', status: 403, detail },
45
+ headers: { ...PROBLEM },
46
+ };
47
+ }
48
+ /** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
49
+ export function notFoundProblem() {
50
+ return {
51
+ status: 404,
52
+ body: { title: 'Not Found Error', type: 'about:blank', status: 404, detail: 'Not Found' },
53
+ headers: { ...PROBLEM },
54
+ };
55
+ }
56
+ /**
57
+ * A named resource the request addressed does not exist — X's `resource-not-found` problem,
58
+ * which carries the offending value/resource_type rather than the bare about:blank form.
59
+ * Wording pinned by `x.errors.resource_not_found_wording`.
60
+ */
61
+ export function resourceNotFoundError(value, resourceType, parameter) {
62
+ return {
63
+ parameter,
64
+ resource_id: value,
65
+ value,
66
+ detail: `Could not find ${resourceType} with ${parameter}: [${value}].`,
67
+ title: 'Not Found Error',
68
+ resource_type: resourceType,
69
+ type: 'https://api.twitter.com/2/problems/resource-not-found',
70
+ };
71
+ }
72
+ export function resourceNotFoundProblem(value, resourceType, parameter) {
73
+ return {
74
+ status: 404,
75
+ body: { errors: [resourceNotFoundError(value, resourceType, parameter)] },
76
+ headers: { ...NOSTORE, 'content-type': 'application/json' },
77
+ };
78
+ }
79
+ /**
80
+ * THE LOOKUP ENVELOPE, and why its status differs from the refusal above. X's *lookup* endpoints
81
+ * (`GET /2/tweets`, `GET /2/tweets/:id`) answer PARTIALLY: a request for five ids where two are
82
+ * missing has to return the three that exist AND say what happened to the other two, so the
83
+ * missing ones ride in a sibling `errors` array under HTTP 200 rather than turning the whole
84
+ * request into a 404. The bulk route FORCES that model — there is no other way to express a
85
+ * partial result — and X applies the same envelope to the single-id route. What matters for this
86
+ * twin, and what its verifies assert, is the half that is not a status: a `data` entry is NEVER
87
+ * fabricated for an id nobody created; the id comes back inside `errors`, by name.
88
+ *
89
+ * The exact `detail`/`title` WORDING is unpinned by this build, same boundary as every other
90
+ * envelope here (`x.errors.resource_not_found_wording`).
91
+ */
92
+ export function lookupResult(data, errors, includes) {
93
+ const body = {};
94
+ if (data !== undefined)
95
+ body.data = data;
96
+ if (includes !== undefined && Object.keys(includes).length > 0)
97
+ body.includes = includes;
98
+ if (errors.length > 0)
99
+ body.errors = errors;
100
+ return { status: 200, body, headers: { ...NOSTORE, 'content-type': 'application/json' } };
101
+ }
102
+ /** The invalid-parameter envelope (OpenAPI InvalidRequestProblem + the wire's `errors` array). */
103
+ export function invalidRequestProblem(parameters, message) {
104
+ return {
105
+ status: 400,
106
+ body: {
107
+ errors: [{ parameters, message }],
108
+ title: 'Invalid Request',
109
+ detail: 'One or more parameters to your request was invalid.',
110
+ type: 'https://api.x.com/2/problems/invalid-request',
111
+ },
112
+ headers: { ...NOSTORE, 'content-type': 'application/json' },
113
+ };
114
+ }
115
+ /** HTTP 429, legacy code 88 — the documented pair (docs.x.com fundamentals/rate-limits). */
116
+ export function rateLimitExceeded(headers) {
117
+ return {
118
+ status: 429,
119
+ body: { errors: [{ code: 88, message: 'Rate limit exceeded' }] },
120
+ headers: { ...NOSTORE, ...headers, 'content-type': 'application/json' },
121
+ };
122
+ }
123
+ /** A write attempted against a read-only twin. Not a vendor shape — the kernel's own refusal. */
124
+ export function readOnlyRefusal() {
125
+ return {
126
+ status: 405,
127
+ body: { title: 'Method Not Allowed', type: 'about:blank', status: 405, detail: 'This twin is running read-only; writes are refused.' },
128
+ headers: { ...PROBLEM },
129
+ };
130
+ }
@@ -0,0 +1,7 @@
1
+ /** The subset of X's scope catalog the posting surface can require, with X's own consent wording. */
2
+ export declare const X_SCOPES: Record<string, string>;
3
+ /** Space-separated scope param → ordered unique scope list (X formats scope space-separated). */
4
+ export declare function parseScopeList(raw: string | null | undefined): string[];
5
+ /** The scopes a token must hold for one modelled operation, from the OpenAPI's `security` entry. */
6
+ export declare const REQUIRED_SCOPES: Record<'tweets.create' | 'tweets.reply' | 'tweets.delete' | 'mentions.list' | 'tweets.lookup' | 'timelines.read' | 'search.recent' | 'users.lookup' | 'media.upload', readonly string[]>;
7
+ export declare function missingScopes(held: readonly string[], required: readonly string[]): string[];
@@ -0,0 +1,62 @@
1
+ // The X OAuth 2.0 scopes this POSTING surface reads, and the endpoint→scope map that gates it.
2
+ //
3
+ // TRANSCRIBED, NOT SHARED — same seam note as x-problems.ts. The catalog itself is
4
+ // `packages/twin/xidentity/src/xidentity-scopes.ts`'s `SCOPE_CATALOG`, taken verbatim from the
5
+ // X API v2 OpenAPI 2.167 `OAuth2UserToken` security scheme (recorded in spec-sources.json's
6
+ // xidentity entry, live-fetched 2026-08-21). Only the scopes THIS surface can require are
7
+ // listed; the other nineteen are xidentity's business, and re-listing them here would create a
8
+ // second catalog to drift.
9
+ //
10
+ // The endpoint→scope pairs are the OpenAPI's own per-operation `security` entries for the three
11
+ // operations this pack models — the same source the catalog came from.
12
+ /** The subset of X's scope catalog the posting surface can require, with X's own consent wording. */
13
+ export const X_SCOPES = {
14
+ 'tweet.read': 'View all posts you can see, including those from protected accounts.',
15
+ 'tweet.write': 'Create and repost on your behalf.',
16
+ 'users.read': 'View any account you can see, including protected accounts.',
17
+ 'offline.access': 'Stay connected to your account until you revoke access.',
18
+ 'media.write': 'Upload media, such as photos and videos, on your behalf.',
19
+ };
20
+ /** Space-separated scope param → ordered unique scope list (X formats scope space-separated). */
21
+ export function parseScopeList(raw) {
22
+ if (!raw)
23
+ return [];
24
+ const seen = new Set();
25
+ const out = [];
26
+ for (const s of raw.split(/[\s+]+/)) {
27
+ if (!s || seen.has(s))
28
+ continue;
29
+ seen.add(s);
30
+ out.push(s);
31
+ }
32
+ return out;
33
+ }
34
+ /** The scopes a token must hold for one modelled operation, from the OpenAPI's `security` entry. */
35
+ export const REQUIRED_SCOPES = {
36
+ 'tweets.create': ['tweet.write', 'users.read'],
37
+ // A reply is the SAME OpenAPI operation as an original post (`POST /2/tweets`), so its scope
38
+ // requirement is identical at the vendor. The split lives one layer up — in the CENSUS, where
39
+ // a grant can name one and not the other. Keeping the two rows here, equal and separate,
40
+ // records that the vendor does not distinguish them and this estate does.
41
+ 'tweets.reply': ['tweet.write', 'users.read'],
42
+ 'tweets.delete': ['tweet.write', 'users.read'],
43
+ 'mentions.list': ['tweet.read', 'users.read'],
44
+ // The READ operations, from the same source: the pinned client's own per-method docblocks name
45
+ // `users.read` + `tweet.read` on the lookup endpoints (dist/cjs/v2/client.v2.read.js, the
46
+ // `singleTweet`/`tweets` comments) and `tweet.read` + `users.read` on the timelines
47
+ // (`homeTimeline`, `userTimeline`). One scope pair for the whole read surface, as X publishes it.
48
+ 'tweets.lookup': ['tweet.read', 'users.read'],
49
+ 'timelines.read': ['tweet.read', 'users.read'],
50
+ 'search.recent': ['tweet.read', 'users.read'],
51
+ // User lookup (by id and by username). The pinned client's `user`/`userByUsername` docblocks
52
+ // name no scope; the adjacent `me()` docblock (client.v2.read.js:234) names this pair, and it is
53
+ // transcribed from there — the one user-lookup scope statement in the grounding source.
54
+ 'users.lookup': ['tweet.read', 'users.read'],
55
+ // Media upload (one-shot, initialize/append/finalize, STATUS): docs.x.com's per-operation
56
+ // security entry names `media.write` alone ("Upload media, such as photos and videos, on your
57
+ // behalf"). Attaching the uploaded id to a post is POST /2/tweets and needs that route's pair.
58
+ 'media.upload': ['media.write'],
59
+ };
60
+ export function missingScopes(held, required) {
61
+ return required.filter((scope) => !held.includes(scope));
62
+ }
@@ -0,0 +1,14 @@
1
+ /** Options every X-twin HTTP surface needs, independent of who owns the socket. */
2
+ export interface XTwinFetchOptions {
3
+ root?: string;
4
+ readOnly?: boolean;
5
+ }
6
+ export declare function createXTwinFetch(options?: XTwinFetchOptions): (request: Request) => Promise<Response>;
7
+ export declare function createXTwinServer(options?: {
8
+ root?: string;
9
+ port?: number;
10
+ readOnly?: boolean;
11
+ }): Promise<{
12
+ port: number;
13
+ stop: () => void;
14
+ }>;
@@ -0,0 +1,127 @@
1
+ // X (Twitter) twin HTTP server.
2
+ //
3
+ // FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, built from the
4
+ // kernel's ONE adaptation (`createTwinFetchFromHandler` — manifest door, body read, header map,
5
+ // worldNow() stamp, JSON reply); this file contributes only VALUES — plus the two places X's wire
6
+ // exceeds that adapter's text-in/JSON-out shape, both about media BYTES:
7
+ //
8
+ // - a MULTIPART upload (`POST /2/media/upload`, `…/{id}/append`): the adapter reads bodies as
9
+ // text, which corrupts binary, so the form is parsed here and handed to the same handler as
10
+ // `form` (a JSON upload, base64 in the body, goes through the adapter unchanged);
11
+ // - the media bytes themselves, read from the blob seam by digest, at X's own path shapes:
12
+ // pbs.twimg.com's `/media/<name>.<ext>` for an image and `/ext_tw_video_thumb/<id>/pu/img/…`
13
+ // for a video's preview, video.twimg.com's `/ext_tw_video/<id>/pu/vid/avc1/<w>x<h>/<name>.mp4`
14
+ // for the video file — with HTTP Range (206), so a <video> can seek. Those hosts are public
15
+ // at X (an <img> or <video> carries no bearer), so these routes are too.
16
+ //
17
+ // The server is one line of Bun.serve around that same closure.
18
+ import { readMediaRecord, readXBlob, readXBlobType, videoPosterSvg } from "./x-media.js";
19
+ import { handleXTwinRequest } from "./x-twin.js";
20
+ import { createTwinFetchFromHandler, runWithCorrelationId, serveHttp, statefulTwinManifest, twinPublicBase, worldNow } from '@volter/world-core';
21
+ const MULTIPART_UPLOAD_PATH = /^\/2\/media\/upload(?:\/\d{1,19}\/append)?\/?$/;
22
+ const MEDIA_BYTES_PATH = /^\/media\/([0-9a-f]{64})\.(jpg|png|gif|webp)$/;
23
+ const VIDEO_BYTES_PATH = /^\/ext_tw_video\/\d{1,19}\/pu\/vid\/avc1\/\d+x\d+\/([0-9a-f]{64})\.mp4$/;
24
+ const VIDEO_THUMB_PATH = /^\/ext_tw_video_thumb\/(\d{1,19})\/pu\/img\/[0-9a-f]{64}\.svg$/;
25
+ /**
26
+ * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range
27
+ * that does not parse — including a first-byte-pos past its last-byte-pos (`bytes=5-2`) — is
28
+ * IGNORED and the whole body is a 200; a well-formed range that starts past the end is a 416. The
29
+ * content type is the one the twin decided at upload (stored beside the bytes), and `nosniff`
30
+ * stops a browser second-guessing it.
31
+ */
32
+ function bytesResponse(request, bytes, contentType) {
33
+ const total = bytes.length;
34
+ const base = { 'content-type': contentType, 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes', 'cache-control': 'public, max-age=604800, immutable' };
35
+ const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
36
+ let start = 0;
37
+ let end = total - 1;
38
+ const valid = range !== null && (range[1] !== '' || range[2] !== '') && !(range[1] !== '' && range[2] !== '' && Number(range[1]) > Number(range[2]));
39
+ if (valid) {
40
+ if (range[1] === '') {
41
+ start = Math.max(0, total - Number(range[2]));
42
+ }
43
+ else {
44
+ start = Number(range[1]);
45
+ end = range[2] === '' ? total - 1 : Math.min(Number(range[2]), total - 1);
46
+ }
47
+ if (start >= total || (range[1] === '' && Number(range[2]) === 0))
48
+ return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
49
+ }
50
+ const partial = start !== 0 || end !== total - 1;
51
+ const body = bytes.slice(start, end + 1);
52
+ return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
53
+ status: partial ? 206 : 200,
54
+ headers: { ...base, 'content-length': String(body.length), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
55
+ });
56
+ }
57
+ export function createXTwinFetch(options = {}) {
58
+ const adapted = createTwinFetchFromHandler(handleXTwinRequest, {
59
+ ...options,
60
+ extras: (request) => ({ publicBase: twinPublicBase(request) }),
61
+ manifest: statefulTwinManifest({ vendor: 'x', twinOf: 'the X (Twitter) API v2 posting surface', stores: 'tweets, replies, deletions, the mentions timeline and uploaded images' }),
62
+ });
63
+ return async (request) => {
64
+ const url = new URL(request.url);
65
+ const reading = request.method === 'GET' || request.method === 'HEAD';
66
+ const image = reading ? MEDIA_BYTES_PATH.exec(url.pathname) : null;
67
+ const video = reading ? VIDEO_BYTES_PATH.exec(url.pathname) : null;
68
+ if (image || video) {
69
+ const digest = (image ?? video)[1];
70
+ const bytes = await readXBlob(digest, options.root);
71
+ const type = await readXBlobType(digest, options.root);
72
+ // bytes with no recorded type were never an upload this twin read: not served
73
+ if (!bytes || !type)
74
+ return new Response('Not Found', { status: 404 });
75
+ return bytesResponse(request, bytes, type);
76
+ }
77
+ const thumb = reading ? VIDEO_THUMB_PATH.exec(url.pathname) : null;
78
+ if (thumb) {
79
+ const record = await readMediaRecord(thumb[1], options.root);
80
+ if (!record?.sha256)
81
+ return new Response('Not Found', { status: 404 });
82
+ return new Response(request.method === 'HEAD' ? null : videoPosterSvg(record.width ?? 0, record.height ?? 0), { headers: { 'content-type': 'image/svg+xml', 'x-content-type-options': 'nosniff', 'cache-control': 'public, max-age=604800' } });
83
+ }
84
+ const type = request.headers.get('content-type') ?? '';
85
+ if (request.method === 'POST' && MULTIPART_UPLOAD_PATH.test(url.pathname) && /^multipart\/form-data/i.test(type)) {
86
+ let form;
87
+ try {
88
+ form = await request.formData();
89
+ }
90
+ catch {
91
+ return Response.json({ errors: [{ parameters: { body: ['(unparseable)'] }, message: 'The multipart body could not be parsed.' }], title: 'Invalid Request', detail: 'One or more parameters to your request was invalid.', type: 'https://api.x.com/2/problems/invalid-request' }, { status: 400 });
92
+ }
93
+ const fields = {};
94
+ let media;
95
+ for (const [name, value] of form.entries()) {
96
+ if (typeof value === 'string')
97
+ fields[name] = value;
98
+ else if (name === 'media' && value instanceof Blob)
99
+ media = new Uint8Array(await value.arrayBuffer());
100
+ }
101
+ const headers = {};
102
+ request.headers.forEach((value, key) => { headers[key] = value; });
103
+ const invoke = () => handleXTwinRequest({
104
+ method: request.method,
105
+ path: url.pathname + (url.search || ''),
106
+ headers,
107
+ form: { fields, ...(media ? { media } : {}) },
108
+ readOnly: options.readOnly ?? false,
109
+ occurredAt: worldNow(),
110
+ publicBase: twinPublicBase(request),
111
+ ...(options.root !== undefined ? { root: options.root } : {}),
112
+ });
113
+ const requestId = request.headers.get('x-twins-request-id') ?? undefined;
114
+ const result = await (requestId ? runWithCorrelationId(requestId, invoke) : invoke());
115
+ return new Response(JSON.stringify(result.body), { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
116
+ }
117
+ return adapted(request);
118
+ };
119
+ }
120
+ export async function createXTwinServer(options = {}) {
121
+ const server = await serveHttp({
122
+ port: options.port ?? 0,
123
+ idleTimeout: 60,
124
+ fetch: createXTwinFetch(options),
125
+ });
126
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
127
+ }
@@ -0,0 +1,21 @@
1
+ import { type XResponse } from './x-problems.js';
2
+ export type XRequest = {
3
+ method: string;
4
+ path: string;
5
+ body?: string;
6
+ headers?: Record<string, string>;
7
+ readOnly?: boolean;
8
+ occurredAt?: string;
9
+ root?: string;
10
+ /** A multipart body, parsed by the server (x-server.ts): its text fields and the `media` part's
11
+ * bytes. The JSON adapter reads bodies as text, which would corrupt binary; so a multipart
12
+ * upload arrives here instead of in `body`. */
13
+ form?: {
14
+ fields: Record<string, string>;
15
+ media?: Uint8Array;
16
+ };
17
+ /** Where this twin is reached (`twinPublicBase`: origin plus any World mount path). A media `url`
18
+ * is minted from it; a direct handler call with none mints X's own pbs.twimg.com form. */
19
+ publicBase?: string;
20
+ };
21
+ export declare function handleXTwinRequest(request: XRequest): Promise<XResponse>;