@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Spec conformance for the X posting surface — dev-only, imported LAZILY by cli.ts so it never
|
|
2
|
+
// enters the pack's runtime entrypoint graph.
|
|
3
|
+
//
|
|
4
|
+
// What it checks is what a client's own code depends on and a status-only check would miss: the
|
|
5
|
+
// vendor's response SHAPES (create is thin, the mentions timeline OMITS `data` when empty), and
|
|
6
|
+
// the negative paths (a bad bearer is the about:blank problem, an unmodelled /2 route is a 404
|
|
7
|
+
// problem, a reply to a post that does not exist is `resource-not-found`).
|
|
8
|
+
import { handleXTwinRequest } from "./x-twin.js";
|
|
9
|
+
export async function checkXConformance(options = {}) {
|
|
10
|
+
const failures = [];
|
|
11
|
+
const check = (name, ok) => { if (!ok)
|
|
12
|
+
failures.push(name); };
|
|
13
|
+
const root = options.root;
|
|
14
|
+
const auth = { authorization: 'Bearer conformance-token' };
|
|
15
|
+
const h = (method, path, body, headers = auth) => handleXTwinRequest({ method, path, headers, root, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
16
|
+
await h('POST', '/_twin/accounts', { id: '1000', username: 'orgvoice', name: 'Org Voice' }, {});
|
|
17
|
+
await h('POST', '/_twin/accounts', { id: '2000', username: 'member' }, {});
|
|
18
|
+
await h('POST', '/_twin/tokens', { token: 'conformance-token', account_id: '1000', scopes: ['tweet.read', 'tweet.write', 'users.read'] }, {});
|
|
19
|
+
const created = await h('POST', '/2/tweets', { text: 'hello world' });
|
|
20
|
+
const createdBody = created.body;
|
|
21
|
+
check('create answers 201 with the thin {id, text, edit_history_tweet_ids} data envelope', created.status === 201
|
|
22
|
+
&& typeof createdBody?.data?.id === 'string'
|
|
23
|
+
&& createdBody.data.text === 'hello world'
|
|
24
|
+
&& Array.isArray(createdBody.data.edit_history_tweet_ids)
|
|
25
|
+
&& createdBody.data.edit_history_tweet_ids[0] === createdBody.data.id
|
|
26
|
+
&& Object.keys(createdBody.data).length === 3);
|
|
27
|
+
const emptyMentions = await h('GET', '/2/users/1000/mentions');
|
|
28
|
+
check('an EMPTY mentions timeline omits `data` and answers with meta.result_count 0', emptyMentions.status === 200
|
|
29
|
+
&& emptyMentions.body?.meta?.result_count === 0
|
|
30
|
+
&& emptyMentions.body?.data === undefined);
|
|
31
|
+
await h('POST', '/_twin/posts', { author_id: '2000', text: 'hey @orgvoice can you help' }, {});
|
|
32
|
+
const mentions = await h('GET', '/2/users/1000/mentions');
|
|
33
|
+
const mentionsBody = mentions.body;
|
|
34
|
+
check('a mentions timeline carries data[] plus meta.newest_id/oldest_id', mentions.status === 200
|
|
35
|
+
&& Array.isArray(mentionsBody?.data) && mentionsBody.data.length === 1
|
|
36
|
+
&& mentionsBody.meta?.result_count === 1
|
|
37
|
+
&& mentionsBody.meta?.newest_id === mentionsBody.data[0].id
|
|
38
|
+
&& mentionsBody.meta?.oldest_id === mentionsBody.data[0].id);
|
|
39
|
+
const replied = await h('POST', '/2/tweets', { text: 'happy to help', reply: { in_reply_to_tweet_id: mentionsBody.data[0].id } });
|
|
40
|
+
check('a reply answers 201 with the same thin envelope a post does', replied.status === 201 && typeof replied.body?.data?.id === 'string');
|
|
41
|
+
const deleted = await h('DELETE', `/2/tweets/${replied.body.data.id}`);
|
|
42
|
+
check('delete answers {data:{deleted:true}}', deleted.status === 200 && deleted.body?.data?.deleted === true);
|
|
43
|
+
const noAuth = await h('GET', '/2/users/1000/mentions', undefined, {});
|
|
44
|
+
check('an absent bearer is the about:blank 401 problem', noAuth.status === 401
|
|
45
|
+
&& noAuth.body?.type === 'about:blank'
|
|
46
|
+
&& noAuth.body?.title === 'Unauthorized');
|
|
47
|
+
const unmodelled = await h('GET', '/2/tweets/counts/recent?query=x');
|
|
48
|
+
check('an UNMODELLED /2 route fails like the vendor (404 problem), never a fake success', unmodelled.status === 404
|
|
49
|
+
&& unmodelled.body?.title === 'Not Found Error');
|
|
50
|
+
const orphanReply = await h('POST', '/2/tweets', { text: 'to nobody', reply: { in_reply_to_tweet_id: '404404404404404404' } });
|
|
51
|
+
check('a reply to a post that does not exist is the resource-not-found problem', orphanReply.status === 404
|
|
52
|
+
&& orphanReply.body?.errors?.[0]?.resource_type === 'tweet'
|
|
53
|
+
&& orphanReply.body?.errors?.[0]?.parameter === 'reply.in_reply_to_tweet_id');
|
|
54
|
+
const noText = await h('POST', '/2/tweets', {});
|
|
55
|
+
check('a create with no text is the invalid-request problem', noText.status === 400
|
|
56
|
+
&& noText.body?.type === 'https://api.x.com/2/problems/invalid-request');
|
|
57
|
+
const looked = await h('GET', `/2/tweets/${createdBody.data.id}`);
|
|
58
|
+
check('post lookup answers the DEFAULT projection — id, text, edit_history_tweet_ids and nothing else', looked.status === 200
|
|
59
|
+
&& Object.keys(looked.body?.data ?? {}).sort().join(',') === 'edit_history_tweet_ids,id,text'
|
|
60
|
+
&& looked.body.data.id === createdBody.data.id);
|
|
61
|
+
const projected = await h('GET', `/2/tweets/${createdBody.data.id}?tweet.fields=author_id,conversation_id`);
|
|
62
|
+
check('tweet.fields adds exactly the fields asked for', projected.status === 200
|
|
63
|
+
&& projected.body?.data?.author_id === '1000'
|
|
64
|
+
&& projected.body.data.conversation_id === createdBody.data.id);
|
|
65
|
+
const ghost = await h('GET', '/2/tweets/404404404404404404');
|
|
66
|
+
check('a lookup of an id nobody created answers `errors`, never a fabricated `data`', ghost.status === 200
|
|
67
|
+
&& ghost.body?.data === undefined
|
|
68
|
+
&& ghost.body?.errors?.[0]?.resource_type === 'tweet'
|
|
69
|
+
&& ghost.body.errors[0].value === '404404404404404404');
|
|
70
|
+
const bulk = await h('GET', `/2/tweets?ids=${createdBody.data.id},404404404404404404`);
|
|
71
|
+
check('a bulk lookup is PARTIAL: the id that exists in data, the one that does not in errors', bulk.status === 200
|
|
72
|
+
&& bulk.body?.data?.length === 1
|
|
73
|
+
&& bulk.body.data[0].id === createdBody.data.id
|
|
74
|
+
&& bulk.body?.errors?.length === 1);
|
|
75
|
+
const own = await h('GET', '/2/users/1000/tweets');
|
|
76
|
+
check("the author's own timeline carries the post it made", own.status === 200
|
|
77
|
+
&& (own.body?.data ?? []).some((row) => row.id === createdBody.data.id));
|
|
78
|
+
const searched = await h('GET', '/2/tweets/search/recent?query=hello');
|
|
79
|
+
const searchUnsupported = await h('GET', '/2/tweets/search/recent?query=hello OR world');
|
|
80
|
+
check('recent search matches on the stored text, and refuses grammar the twin does not model', searched.status === 200
|
|
81
|
+
&& (searched.body?.data ?? []).some((row) => row.id === createdBody.data.id)
|
|
82
|
+
&& searchUnsupported.status === 400
|
|
83
|
+
&& searchUnsupported.body?.errors?.[0]?.parameters?.query?.[0] === 'OR');
|
|
84
|
+
const foreignHome = await h('GET', '/2/users/2000/timelines/reverse_chronological');
|
|
85
|
+
check("the home timeline is refused for an account that is not the bearer's", foreignHome.status === 403
|
|
86
|
+
&& foreignHome.body?.title === 'Forbidden');
|
|
87
|
+
const tooLong = await h('POST', '/2/tweets', { text: 'x'.repeat(281) });
|
|
88
|
+
check('a post over the account character limit is the invalid-request problem', tooLong.status === 400
|
|
89
|
+
&& tooLong.body?.errors?.[0]?.parameters?.text?.[0] === '281');
|
|
90
|
+
return { ok: failures.length === 0, checksRun: 18, failures };
|
|
91
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
2
|
+
import { XBudget, type XBudgetOptions } from './x-budget.js';
|
|
3
|
+
/** The modelled tweet.fields a pull requests — every field the twin's post rows can hold. */
|
|
4
|
+
export declare const PULL_TWEET_FIELDS: readonly ["author_id", "created_at", "conversation_id", "in_reply_to_user_id", "referenced_tweets"];
|
|
5
|
+
export type XExecute = (method: 'GET' | 'POST' | 'DELETE', path: string, init?: {
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
body?: string;
|
|
8
|
+
}) => Promise<Record<string, any>>;
|
|
9
|
+
export type LiveXOptions = {
|
|
10
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
13
|
+
budget?: XBudget;
|
|
14
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
15
|
+
budgetOptions?: XBudgetOptions;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A live executor against the real X API, holding the operator's OWN user access token.
|
|
19
|
+
*
|
|
20
|
+
* THIS IS THE ONE PLACE this pack issues a live X request, and therefore the one place the rate
|
|
21
|
+
* budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request goes
|
|
22
|
+
* out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says stop)
|
|
23
|
+
* and the response is fed back (`recordCall`) so a 429 / `Retry-After` becomes a PERSISTED
|
|
24
|
+
* cooldown that makes every later call fail fast WITHOUT touching X. There is deliberately no
|
|
25
|
+
* option to disable the guard and no value of `budget` that yields an unguarded client
|
|
26
|
+
* (`assertBudgetGuardIntact`). This matters more here than on a read-only vendor: an unguarded
|
|
27
|
+
* loop at this vendor does not waste quota, it POSTS IN PUBLIC.
|
|
28
|
+
*/
|
|
29
|
+
export declare function liveXExecute(accessToken: string, opts?: LiveXOptions): XExecute;
|
|
30
|
+
/** Map one real timeline post → the twin's `post` SyncResource. Nothing invented: a field the
|
|
31
|
+
* response omits records NOTHING rather than a placeholder, so a partial reply can never fold a
|
|
32
|
+
* null over a previously observed value. */
|
|
33
|
+
export declare function mapTimelinePost(row: Record<string, any>): SyncResource;
|
|
34
|
+
/** PULL the org's mentions timeline into the twin's observed log (idempotent). */
|
|
35
|
+
export declare function pullXMentions(execute: XExecute, userId: string, root?: string, occurredAt?: string): Promise<number>;
|
|
36
|
+
/** PULL the org's OWN posted timeline into the twin's observed log (idempotent). */
|
|
37
|
+
export declare function pullXOwnTimeline(execute: XExecute, userId: string, root?: string, occurredAt?: string): Promise<number>;
|
|
38
|
+
/**
|
|
39
|
+
* D7 consumer-facing pull entry point: pull everything readable from the real X posting surface
|
|
40
|
+
* and fold it into the twin in ONE shadow-diffed `syncPull`, returning the standard
|
|
41
|
+
* `{ observed, deltasAppended }`. Idempotent — a re-pull of identical state appends nothing.
|
|
42
|
+
*/
|
|
43
|
+
export declare function syncXFromReal(execute: XExecute, opts: {
|
|
44
|
+
userId: string;
|
|
45
|
+
root?: string;
|
|
46
|
+
occurredAt?: string;
|
|
47
|
+
includeOwnTimeline?: boolean;
|
|
48
|
+
}): Promise<{
|
|
49
|
+
observed: number;
|
|
50
|
+
deltasAppended: number;
|
|
51
|
+
}>;
|
|
52
|
+
/**
|
|
53
|
+
* The request ONE pending twin action becomes at the real vendor. A pure shape builder — it
|
|
54
|
+
* issues nothing, so it can be asserted directly. The reply's discriminator travels here exactly
|
|
55
|
+
* as the twin read it: `reply.in_reply_to_tweet_id`, the same field, in the same place.
|
|
56
|
+
*/
|
|
57
|
+
export declare function xRequestForAction(action: {
|
|
58
|
+
operation?: string;
|
|
59
|
+
subject?: {
|
|
60
|
+
type: string;
|
|
61
|
+
id: string;
|
|
62
|
+
};
|
|
63
|
+
fields?: Record<string, any>;
|
|
64
|
+
}): {
|
|
65
|
+
method: 'POST' | 'DELETE';
|
|
66
|
+
path: string;
|
|
67
|
+
body?: string;
|
|
68
|
+
} | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* PUSH one pending action to the real vendor and confirm it with what the vendor answered.
|
|
71
|
+
*
|
|
72
|
+
* A create/reply is confirmed under the REAL id X minted, not the twin's locally minted one:
|
|
73
|
+
* after a push the public post has a public id, and a projection still claiming the local id
|
|
74
|
+
* would make every later delete address a post that does not exist. The local subject is
|
|
75
|
+
* confirmed as superseded in the same call.
|
|
76
|
+
*/
|
|
77
|
+
export declare function pushXAction(execute: XExecute, action: {
|
|
78
|
+
id: string;
|
|
79
|
+
operation?: string;
|
|
80
|
+
subject?: {
|
|
81
|
+
type: string;
|
|
82
|
+
id: string;
|
|
83
|
+
};
|
|
84
|
+
fields?: Record<string, any>;
|
|
85
|
+
}, opts?: {
|
|
86
|
+
root?: string;
|
|
87
|
+
occurredAt?: string;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
pushed: boolean;
|
|
90
|
+
realId?: string;
|
|
91
|
+
reason?: string;
|
|
92
|
+
}>;
|
|
93
|
+
/** Push every pending action this connector knows how to push. */
|
|
94
|
+
export declare function pushPendingXActions(execute: XExecute, opts?: {
|
|
95
|
+
root?: string;
|
|
96
|
+
occurredAt?: string;
|
|
97
|
+
}): Promise<{
|
|
98
|
+
pushed: number;
|
|
99
|
+
unpushable: number;
|
|
100
|
+
}>;
|
|
101
|
+
/**
|
|
102
|
+
* The perform adapter (runtime contract R18): what a World's deploy calls for each landed X entry
|
|
103
|
+
* on a twin whose root is the platform. A post, reply, quote or delete crosses through the host's
|
|
104
|
+
* executor, which adds the sealed credential; the kernel records the landing from the returned id.
|
|
105
|
+
* Anything else (a seeded account or token) is the twin's own record and crosses nothing. A
|
|
106
|
+
* reply's or quote's parent authored against a local id crosses against the id X minted for it.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* The kernel executor, charged to this pack's XBudget: the check RESERVES before the call and
|
|
110
|
+
* throws (XBudgetError) instead of calling when the ceiling, the burst bound or a cooldown says
|
|
111
|
+
* stop; the response settles the reservation and arms a cooldown on 429 / Retry-After.
|
|
112
|
+
*/
|
|
113
|
+
export declare function budgetedXExecute(execute: RemoteExecute, budget?: XBudget): RemoteExecute;
|
|
114
|
+
export declare function performXAction(kernelExecute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome>;
|
|
115
|
+
/** How long, in total, a perform waits on X's video processing before it fails the entry
|
|
116
|
+
* retryably — the bound on a request at an `auto` head (see the header). */
|
|
117
|
+
export declare const MAX_PROCESSING_WAIT_MS = 120000;
|
|
118
|
+
/** At most this many STATUS reads per video, whatever `check_after_secs` says. */
|
|
119
|
+
export declare const MAX_PROCESSING_POLLS = 30;
|
|
120
|
+
/** The vendor has the video but has not finished processing it inside the bound: retry later. */
|
|
121
|
+
export declare class XVideoStillProcessingError extends Error {
|
|
122
|
+
readonly mediaId: string;
|
|
123
|
+
readonly retryable = true;
|
|
124
|
+
constructor(mediaId: string, waitedMs: number, polls: number);
|
|
125
|
+
}
|