@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,8 @@
1
+ export type XConformanceReport = {
2
+ ok: boolean;
3
+ checksRun: number;
4
+ failures: string[];
5
+ };
6
+ export declare function checkXConformance(options?: {
7
+ root?: string;
8
+ }): Promise<XConformanceReport>;
@@ -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
+ }