@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,113 @@
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.ts';
9
+
10
+ export type XConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
11
+
12
+ export async function checkXConformance(options: { root?: string } = {}): Promise<XConformanceReport> {
13
+ const failures: string[] = [];
14
+ const check = (name: string, ok: boolean) => { if (!ok) failures.push(name); };
15
+ const root = options.root;
16
+ const auth = { authorization: 'Bearer conformance-token' };
17
+ const h = (method: string, path: string, body?: unknown, headers: Record<string, string> = auth) =>
18
+ handleXTwinRequest({ method, path, headers, root, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
19
+
20
+ await h('POST', '/_twin/accounts', { id: '1000', username: 'orgvoice', name: 'Org Voice' }, {});
21
+ await h('POST', '/_twin/accounts', { id: '2000', username: 'member' }, {});
22
+ await h('POST', '/_twin/tokens', { token: 'conformance-token', account_id: '1000', scopes: ['tweet.read', 'tweet.write', 'users.read'] }, {});
23
+
24
+ const created = await h('POST', '/2/tweets', { text: 'hello world' });
25
+ const createdBody = created.body as any;
26
+ check('create answers 201 with the thin {id, text, edit_history_tweet_ids} data envelope', created.status === 201
27
+ && typeof createdBody?.data?.id === 'string'
28
+ && createdBody.data.text === 'hello world'
29
+ && Array.isArray(createdBody.data.edit_history_tweet_ids)
30
+ && createdBody.data.edit_history_tweet_ids[0] === createdBody.data.id
31
+ && Object.keys(createdBody.data).length === 3);
32
+
33
+ const emptyMentions = await h('GET', '/2/users/1000/mentions');
34
+ check('an EMPTY mentions timeline omits `data` and answers with meta.result_count 0', emptyMentions.status === 200
35
+ && (emptyMentions.body as any)?.meta?.result_count === 0
36
+ && (emptyMentions.body as any)?.data === undefined);
37
+
38
+ await h('POST', '/_twin/posts', { author_id: '2000', text: 'hey @orgvoice can you help' }, {});
39
+ const mentions = await h('GET', '/2/users/1000/mentions');
40
+ const mentionsBody = mentions.body as any;
41
+ check('a mentions timeline carries data[] plus meta.newest_id/oldest_id', mentions.status === 200
42
+ && Array.isArray(mentionsBody?.data) && mentionsBody.data.length === 1
43
+ && mentionsBody.meta?.result_count === 1
44
+ && mentionsBody.meta?.newest_id === mentionsBody.data[0].id
45
+ && mentionsBody.meta?.oldest_id === mentionsBody.data[0].id);
46
+
47
+ const replied = await h('POST', '/2/tweets', { text: 'happy to help', reply: { in_reply_to_tweet_id: mentionsBody.data[0].id } });
48
+ check('a reply answers 201 with the same thin envelope a post does', replied.status === 201 && typeof (replied.body as any)?.data?.id === 'string');
49
+
50
+ const deleted = await h('DELETE', `/2/tweets/${(replied.body as any).data.id}`);
51
+ check('delete answers {data:{deleted:true}}', deleted.status === 200 && (deleted.body as any)?.data?.deleted === true);
52
+
53
+ const noAuth = await h('GET', '/2/users/1000/mentions', undefined, {});
54
+ check('an absent bearer is the about:blank 401 problem', noAuth.status === 401
55
+ && (noAuth.body as any)?.type === 'about:blank'
56
+ && (noAuth.body as any)?.title === 'Unauthorized');
57
+
58
+ const unmodelled = await h('GET', '/2/tweets/counts/recent?query=x');
59
+ check('an UNMODELLED /2 route fails like the vendor (404 problem), never a fake success', unmodelled.status === 404
60
+ && (unmodelled.body as any)?.title === 'Not Found Error');
61
+
62
+ const orphanReply = await h('POST', '/2/tweets', { text: 'to nobody', reply: { in_reply_to_tweet_id: '404404404404404404' } });
63
+ check('a reply to a post that does not exist is the resource-not-found problem', orphanReply.status === 404
64
+ && (orphanReply.body as any)?.errors?.[0]?.resource_type === 'tweet'
65
+ && (orphanReply.body as any)?.errors?.[0]?.parameter === 'reply.in_reply_to_tweet_id');
66
+
67
+ const noText = await h('POST', '/2/tweets', {});
68
+ check('a create with no text is the invalid-request problem', noText.status === 400
69
+ && (noText.body as any)?.type === 'https://api.x.com/2/problems/invalid-request');
70
+
71
+ const looked = await h('GET', `/2/tweets/${createdBody.data.id}`);
72
+ check('post lookup answers the DEFAULT projection — id, text, edit_history_tweet_ids and nothing else', looked.status === 200
73
+ && Object.keys((looked.body as any)?.data ?? {}).sort().join(',') === 'edit_history_tweet_ids,id,text'
74
+ && (looked.body as any).data.id === createdBody.data.id);
75
+
76
+ const projected = await h('GET', `/2/tweets/${createdBody.data.id}?tweet.fields=author_id,conversation_id`);
77
+ check('tweet.fields adds exactly the fields asked for', projected.status === 200
78
+ && (projected.body as any)?.data?.author_id === '1000'
79
+ && (projected.body as any).data.conversation_id === createdBody.data.id);
80
+
81
+ const ghost = await h('GET', '/2/tweets/404404404404404404');
82
+ check('a lookup of an id nobody created answers `errors`, never a fabricated `data`', ghost.status === 200
83
+ && (ghost.body as any)?.data === undefined
84
+ && (ghost.body as any)?.errors?.[0]?.resource_type === 'tweet'
85
+ && (ghost.body as any).errors[0].value === '404404404404404404');
86
+
87
+ const bulk = await h('GET', `/2/tweets?ids=${createdBody.data.id},404404404404404404`);
88
+ check('a bulk lookup is PARTIAL: the id that exists in data, the one that does not in errors', bulk.status === 200
89
+ && (bulk.body as any)?.data?.length === 1
90
+ && (bulk.body as any).data[0].id === createdBody.data.id
91
+ && (bulk.body as any)?.errors?.length === 1);
92
+
93
+ const own = await h('GET', '/2/users/1000/tweets');
94
+ check("the author's own timeline carries the post it made", own.status === 200
95
+ && ((own.body as any)?.data ?? []).some((row: any) => row.id === createdBody.data.id));
96
+
97
+ const searched = await h('GET', '/2/tweets/search/recent?query=hello');
98
+ const searchUnsupported = await h('GET', '/2/tweets/search/recent?query=hello OR world');
99
+ check('recent search matches on the stored text, and refuses grammar the twin does not model', searched.status === 200
100
+ && ((searched.body as any)?.data ?? []).some((row: any) => row.id === createdBody.data.id)
101
+ && searchUnsupported.status === 400
102
+ && (searchUnsupported.body as any)?.errors?.[0]?.parameters?.query?.[0] === 'OR');
103
+
104
+ const foreignHome = await h('GET', '/2/users/2000/timelines/reverse_chronological');
105
+ check("the home timeline is refused for an account that is not the bearer's", foreignHome.status === 403
106
+ && (foreignHome.body as any)?.title === 'Forbidden');
107
+
108
+ const tooLong = await h('POST', '/2/tweets', { text: 'x'.repeat(281) });
109
+ check('a post over the account character limit is the invalid-request problem', tooLong.status === 400
110
+ && (tooLong.body as any)?.errors?.[0]?.parameters?.text?.[0] === '281');
111
+
112
+ return { ok: failures.length === 0, checksRun: 18, failures };
113
+ }