@volter/twin-xidentity 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 (52) hide show
  1. package/README.md +112 -0
  2. package/client/xidentity-consent.css +204 -0
  3. package/client/xidentity-consent.tsx +162 -0
  4. package/dist/client/xidentity-consent.bundle.js +235 -0
  5. package/dist/client/xidentity-consent.css +204 -0
  6. package/dist/client/xidentity-consent.d.ts +53 -0
  7. package/dist/client/xidentity-consent.js +57 -0
  8. package/dist/client/xidentity-consent.tsx +162 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +44 -0
  11. package/dist/src/index.d.ts +15 -0
  12. package/dist/src/index.js +105 -0
  13. package/dist/src/xidentity-budget.d.ts +50 -0
  14. package/dist/src/xidentity-budget.js +108 -0
  15. package/dist/src/xidentity-capabilities.d.ts +3 -0
  16. package/dist/src/xidentity-capabilities.js +905 -0
  17. package/dist/src/xidentity-conformance.d.ts +10 -0
  18. package/dist/src/xidentity-conformance.js +332 -0
  19. package/dist/src/xidentity-connector.d.ts +84 -0
  20. package/dist/src/xidentity-connector.js +239 -0
  21. package/dist/src/xidentity-consent-client.gen.d.ts +2 -0
  22. package/dist/src/xidentity-consent-client.gen.js +10 -0
  23. package/dist/src/xidentity-consent-ui.d.ts +21 -0
  24. package/dist/src/xidentity-consent-ui.js +94 -0
  25. package/dist/src/xidentity-pkce.d.ts +7 -0
  26. package/dist/src/xidentity-pkce.js +27 -0
  27. package/dist/src/xidentity-problems.d.ts +38 -0
  28. package/dist/src/xidentity-problems.js +108 -0
  29. package/dist/src/xidentity-scopes.d.ts +23 -0
  30. package/dist/src/xidentity-scopes.js +81 -0
  31. package/dist/src/xidentity-server.d.ts +33 -0
  32. package/dist/src/xidentity-server.js +85 -0
  33. package/dist/src/xidentity-store.d.ts +97 -0
  34. package/dist/src/xidentity-store.js +358 -0
  35. package/dist/src/xidentity-twin.d.ts +54 -0
  36. package/dist/src/xidentity-twin.js +851 -0
  37. package/package.json +74 -0
  38. package/src/cli.ts +43 -0
  39. package/src/index.ts +177 -0
  40. package/src/xidentity-budget.ts +135 -0
  41. package/src/xidentity-capabilities.ts +1012 -0
  42. package/src/xidentity-conformance.ts +370 -0
  43. package/src/xidentity-connector.ts +269 -0
  44. package/src/xidentity-consent-client.gen.ts +10 -0
  45. package/src/xidentity-consent-ui.ts +113 -0
  46. package/src/xidentity-journey.uitest.ts +277 -0
  47. package/src/xidentity-pkce.ts +29 -0
  48. package/src/xidentity-problems.ts +128 -0
  49. package/src/xidentity-scopes.ts +96 -0
  50. package/src/xidentity-server.ts +97 -0
  51. package/src/xidentity-store.ts +419 -0
  52. package/src/xidentity-twin.ts +944 -0
@@ -0,0 +1,204 @@
1
+ /* X authorize screen — the twin's rendering of x.com's OAuth consent page. Dark, single-column,
2
+ pill buttons: the visual grammar of the real page, sized for the journey filmstrip review. */
3
+
4
+ :root {
5
+ color-scheme: dark;
6
+ --x-bg: #000000;
7
+ --x-card: #000000;
8
+ --x-text: #e7e9ea;
9
+ --x-muted: #71767b;
10
+ --x-border: #2f3336;
11
+ --x-primary: #eff3f4;
12
+ --x-primary-text: #0f1419;
13
+ --x-danger: #f4212e;
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ background: var(--x-bg);
23
+ color: var(--x-text);
24
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
25
+ display: flex;
26
+ justify-content: center;
27
+ padding: 32px 16px;
28
+ }
29
+
30
+ .card {
31
+ width: 100%;
32
+ max-width: 600px;
33
+ background: var(--x-card);
34
+ border: 1px solid var(--x-border);
35
+ border-radius: 16px;
36
+ padding: 32px 32px 24px;
37
+ }
38
+
39
+ .x-header {
40
+ display: flex;
41
+ justify-content: center;
42
+ margin-bottom: 16px;
43
+ }
44
+
45
+ .x-mark {
46
+ font-size: 32px;
47
+ line-height: 1;
48
+ color: var(--x-text);
49
+ }
50
+
51
+ .signed-in {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 12px;
55
+ border: 1px solid var(--x-border);
56
+ border-radius: 9999px;
57
+ padding: 8px 16px;
58
+ margin: 0 auto 20px;
59
+ width: fit-content;
60
+ }
61
+
62
+ .avatar {
63
+ display: inline-flex;
64
+ align-items: center;
65
+ justify-content: center;
66
+ width: 40px;
67
+ height: 40px;
68
+ border-radius: 50%;
69
+ background: #1d9bf0;
70
+ color: #fff;
71
+ font-weight: 700;
72
+ font-size: 18px;
73
+ }
74
+
75
+ .account-text {
76
+ display: flex;
77
+ flex-direction: column;
78
+ }
79
+
80
+ .account-name {
81
+ font-weight: 700;
82
+ font-size: 15px;
83
+ }
84
+
85
+ .account-username {
86
+ color: var(--x-muted);
87
+ font-size: 14px;
88
+ }
89
+
90
+ .title {
91
+ font-size: 23px;
92
+ font-weight: 800;
93
+ text-align: center;
94
+ margin: 0 0 24px;
95
+ }
96
+
97
+ .app-name {
98
+ font-weight: 800;
99
+ }
100
+
101
+ .scope-section {
102
+ margin-bottom: 20px;
103
+ }
104
+
105
+ .scope-heading {
106
+ font-size: 17px;
107
+ font-weight: 700;
108
+ margin: 0 0 8px;
109
+ }
110
+
111
+ .scope-list {
112
+ list-style: disc;
113
+ margin: 0;
114
+ padding-left: 24px;
115
+ }
116
+
117
+ .scope-row {
118
+ font-size: 15px;
119
+ color: var(--x-text);
120
+ margin-bottom: 6px;
121
+ }
122
+
123
+ .scope-tag {
124
+ margin-left: 8px;
125
+ font-size: 12px;
126
+ border: 1px solid var(--x-border);
127
+ border-radius: 4px;
128
+ padding: 1px 6px;
129
+ color: var(--x-muted);
130
+ }
131
+
132
+ .scope-tag-unknown {
133
+ color: var(--x-danger);
134
+ border-color: var(--x-danger);
135
+ }
136
+
137
+ .consent-form {
138
+ margin-top: 24px;
139
+ }
140
+
141
+ .actions {
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 12px;
145
+ }
146
+
147
+ .btn {
148
+ border-radius: 9999px;
149
+ font-size: 17px;
150
+ font-weight: 700;
151
+ padding: 12px 24px;
152
+ cursor: pointer;
153
+ border: 1px solid var(--x-border);
154
+ background: transparent;
155
+ color: var(--x-text);
156
+ }
157
+
158
+ .btn-primary {
159
+ background: var(--x-primary);
160
+ color: var(--x-primary-text);
161
+ border-color: var(--x-primary);
162
+ }
163
+
164
+ .btn-primary:hover {
165
+ background: #d7dbdc;
166
+ }
167
+
168
+ .btn-secondary:hover {
169
+ background: #16181c;
170
+ }
171
+
172
+ .legal {
173
+ margin-top: 20px;
174
+ color: var(--x-muted);
175
+ font-size: 13px;
176
+ line-height: 1.5;
177
+ }
178
+
179
+ .redirect-host {
180
+ color: var(--x-text);
181
+ }
182
+
183
+ .card-error .title {
184
+ margin-bottom: 12px;
185
+ }
186
+
187
+ .error-detail {
188
+ font-size: 15px;
189
+ text-align: center;
190
+ color: var(--x-text);
191
+ }
192
+
193
+ .error-code {
194
+ text-align: center;
195
+ color: var(--x-muted);
196
+ font-size: 14px;
197
+ }
198
+
199
+ .error-request {
200
+ text-align: center;
201
+ color: var(--x-muted);
202
+ font-size: 13px;
203
+ word-break: break-all;
204
+ }
@@ -0,0 +1,53 @@
1
+ export type ConsentAccount = {
2
+ id: string;
3
+ username: string;
4
+ name: string;
5
+ profileImageUrl: string;
6
+ };
7
+ export type ConsentScopeRow = {
8
+ scope: string;
9
+ label: string;
10
+ group: 'view' | 'do' | 'session';
11
+ known: boolean;
12
+ };
13
+ export type ConsentApp = {
14
+ clientId: string;
15
+ name: string;
16
+ };
17
+ export type ConsentView = {
18
+ requestId: string;
19
+ /** Where the twin is reachable — the form action posts back here. */
20
+ origin: string;
21
+ app: ConsentApp;
22
+ /** The x.com session this browser is signed in as — the account that will consent. */
23
+ account: ConsentAccount;
24
+ scopes: ConsentScopeRow[];
25
+ /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. */
26
+ redirectHost: string;
27
+ };
28
+ /** The authorize screen — X's consent page for the app named by the pending auth request. */
29
+ export declare function ConsentPage({ view }: {
30
+ view: ConsentView;
31
+ }): import("react").JSX.Element;
32
+ export type ErrorPageProps = {
33
+ status: number;
34
+ /** The OAuth error code the failure classifies as (e.g. `invalid_request`). */
35
+ code: string;
36
+ detail: string;
37
+ /** The offending parameter, echoed for the developer. */
38
+ requestParam?: string;
39
+ };
40
+ /**
41
+ * The authorize-endpoint error PAGE — what a browser sees when the twin cannot trust the
42
+ * redirect_uri (unknown client, unregistered callback) and therefore must not bounce the error
43
+ * back to it. X's live page for this case renders a terse "Something went wrong" card; its exact
44
+ * DOM was not captured, so this page states the failure plainly and machine-readably rather than
45
+ * imitating unverified vendor prose (`xidentity.authorize.error_page_fidelity`, todo).
46
+ */
47
+ export declare function ErrorPage({ status, code, detail, requestParam }: ErrorPageProps): import("react").JSX.Element;
48
+ /**
49
+ * The bundled browser entry. The screen is fully functional server-side; the bundle only marks the
50
+ * document as enhanced (a hook for future affordances) without re-rendering server markup, so the
51
+ * no-JavaScript path and the enhanced path agree by construction.
52
+ */
53
+ export declare function hydrateConsentControls(): void;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // X's AUTHORIZE SCREEN, as React — the vendor's own product UI, not a dashboard mirror.
3
+ //
4
+ // These components are the ONE renderer for the screen: the twin server-renders them with
5
+ // `renderToStaticMarkup` to answer `GET /i/oauth2/authorize`, the capability verifies render the
6
+ // SAME exported components over the SAME projection, and the Playwright journey drives the HTML
7
+ // they emit. There is no second implementation to drift from.
8
+ //
9
+ // PROGRESSIVE ENHANCEMENT, DELIBERATELY: the decision is a plain `<form>` POST with two submit
10
+ // buttons — X's real page is exactly two choices, "Authorize app" and "Cancel", with no per-scope
11
+ // checkboxes — so no JavaScript is required to complete an OAuth round trip against this twin
12
+ // (headless browsers, `curl`, and redirect-following libraries all work).
13
+ //
14
+ // LAYOUT PROVENANCE: the section headings ("Things App can view…" / "…do…"), the two buttons and
15
+ // the redirect notice reproduce X's authorize page as widely screenshotted; the exact live DOM was
16
+ // not captured from a vendor artefact, so visual fidelity is certified by the journey filmstrip
17
+ // review, not asserted as a pin.
18
+ import { StrictMode } from 'react';
19
+ import { createRoot } from 'react-dom/client';
20
+ /** The X mark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
21
+ function XMark() {
22
+ return (_jsx("span", { className: "x-mark", "aria-hidden": "true", children: "\uD835\uDD4F" }));
23
+ }
24
+ function ScopeSection({ heading, rows }) {
25
+ if (rows.length === 0)
26
+ return null;
27
+ return (_jsxs("section", { className: "scope-section", children: [_jsx("h2", { className: "scope-heading", children: heading }), _jsx("ul", { className: "scope-list", children: rows.map((row) => (_jsxs("li", { className: "scope-row", children: [_jsx("span", { className: "scope-text", children: row.label }), row.known ? null : _jsx("span", { className: "scope-tag scope-tag-unknown", children: "not in the twin's scope catalog" })] }, row.scope))) })] }));
28
+ }
29
+ /** The authorize screen — X's consent page for the app named by the pending auth request. */
30
+ export function ConsentPage({ view }) {
31
+ const appName = view.app.name;
32
+ return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsx("div", { className: "signed-in", children: _jsxs("span", { className: "account-text", children: [_jsx("span", { className: "account-name", children: view.account.name || view.account.username || view.account.id }), view.account.username ? _jsxs("span", { className: "account-username", children: ["@", view.account.username] }) : null] }) }), _jsxs("h1", { className: "title", children: [_jsx("strong", { className: "app-name", children: appName }), " wants to access your X account"] }), _jsx(ScopeSection, { heading: `Things ${appName} can view`, rows: view.scopes.filter((s) => s.group === 'view') }), _jsx(ScopeSection, { heading: `Things ${appName} can do`, rows: view.scopes.filter((s) => s.group === 'do') }), _jsx(ScopeSection, { heading: "Until you revoke access", rows: view.scopes.filter((s) => s.group === 'session') }), _jsxs("form", { method: "POST", action: `${view.origin}/_twin/consent`, className: "consent-form", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), _jsxs("div", { className: "actions", children: [_jsx("button", { className: "btn btn-primary", type: "submit", name: "decision", value: "allow", children: "Authorize app" }), _jsx("button", { className: "btn btn-secondary", type: "submit", name: "decision", value: "deny", children: "Cancel" })] })] }), _jsxs("p", { className: "legal", children: ["You'll be redirected to ", _jsx("strong", { className: "redirect-host", children: view.redirectHost }), ". You can revoke access to any app at any time from the Apps and sessions section of your X settings."] })] }));
33
+ }
34
+ /**
35
+ * The authorize-endpoint error PAGE — what a browser sees when the twin cannot trust the
36
+ * redirect_uri (unknown client, unregistered callback) and therefore must not bounce the error
37
+ * back to it. X's live page for this case renders a terse "Something went wrong" card; its exact
38
+ * DOM was not captured, so this page states the failure plainly and machine-readably rather than
39
+ * imitating unverified vendor prose (`xidentity.authorize.error_page_fidelity`, todo).
40
+ */
41
+ export function ErrorPage({ status, code, detail, requestParam }) {
42
+ return (_jsxs("div", { className: "card card-error", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsx("h1", { className: "title", children: "Something went wrong" }), _jsxs("p", { className: "error-detail", children: ["You weren't able to give access to the App. ", detail] }), _jsxs("p", { className: "error-code", children: ["Error ", status, ": ", code] }), requestParam ? _jsxs("p", { className: "error-request", children: ["Request details: ", requestParam] }) : null] }));
43
+ }
44
+ /**
45
+ * The bundled browser entry. The screen is fully functional server-side; the bundle only marks the
46
+ * document as enhanced (a hook for future affordances) without re-rendering server markup, so the
47
+ * no-JavaScript path and the enhanced path agree by construction.
48
+ */
49
+ export function hydrateConsentControls() {
50
+ const slot = document.getElementById('consent-enhanced');
51
+ if (!slot)
52
+ return;
53
+ createRoot(slot).render(_jsx(StrictMode, { children: null }));
54
+ document.documentElement.dataset['twinEnhanced'] = 'true';
55
+ }
56
+ if (typeof document !== 'undefined')
57
+ hydrateConsentControls();
@@ -0,0 +1,162 @@
1
+ // X's AUTHORIZE SCREEN, as React — the vendor's own product UI, not a dashboard mirror.
2
+ //
3
+ // These components are the ONE renderer for the screen: the twin server-renders them with
4
+ // `renderToStaticMarkup` to answer `GET /i/oauth2/authorize`, the capability verifies render the
5
+ // SAME exported components over the SAME projection, and the Playwright journey drives the HTML
6
+ // they emit. There is no second implementation to drift from.
7
+ //
8
+ // PROGRESSIVE ENHANCEMENT, DELIBERATELY: the decision is a plain `<form>` POST with two submit
9
+ // buttons — X's real page is exactly two choices, "Authorize app" and "Cancel", with no per-scope
10
+ // checkboxes — so no JavaScript is required to complete an OAuth round trip against this twin
11
+ // (headless browsers, `curl`, and redirect-following libraries all work).
12
+ //
13
+ // LAYOUT PROVENANCE: the section headings ("Things App can view…" / "…do…"), the two buttons and
14
+ // the redirect notice reproduce X's authorize page as widely screenshotted; the exact live DOM was
15
+ // not captured from a vendor artefact, so visual fidelity is certified by the journey filmstrip
16
+ // review, not asserted as a pin.
17
+ import { StrictMode } from 'react';
18
+ import { createRoot } from 'react-dom/client';
19
+
20
+ export type ConsentAccount = {
21
+ id: string;
22
+ username: string;
23
+ name: string;
24
+ profileImageUrl: string;
25
+ };
26
+
27
+ export type ConsentScopeRow = {
28
+ scope: string;
29
+ label: string;
30
+ group: 'view' | 'do' | 'session';
31
+ known: boolean;
32
+ };
33
+
34
+ export type ConsentApp = {
35
+ clientId: string;
36
+ name: string;
37
+ };
38
+
39
+ export type ConsentView = {
40
+ requestId: string;
41
+ /** Where the twin is reachable — the form action posts back here. */
42
+ origin: string;
43
+ app: ConsentApp;
44
+ /** The x.com session this browser is signed in as — the account that will consent. */
45
+ account: ConsentAccount;
46
+ scopes: ConsentScopeRow[];
47
+ /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. */
48
+ redirectHost: string;
49
+ };
50
+
51
+ /** The X mark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
52
+ function XMark() {
53
+ return (
54
+ <span className="x-mark" aria-hidden="true">
55
+ 𝕏
56
+ </span>
57
+ );
58
+ }
59
+
60
+ function ScopeSection({ heading, rows }: { heading: string; rows: ConsentScopeRow[] }) {
61
+ if (rows.length === 0) return null;
62
+ return (
63
+ <section className="scope-section">
64
+ <h2 className="scope-heading">{heading}</h2>
65
+ <ul className="scope-list">
66
+ {rows.map((row) => (
67
+ <li key={row.scope} className="scope-row">
68
+ <span className="scope-text">{row.label}</span>
69
+ {row.known ? null : <span className="scope-tag scope-tag-unknown">not in the twin&apos;s scope catalog</span>}
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ </section>
74
+ );
75
+ }
76
+
77
+ /** The authorize screen — X's consent page for the app named by the pending auth request. */
78
+ export function ConsentPage({ view }: { view: ConsentView }) {
79
+ const appName = view.app.name;
80
+ return (
81
+ <div className="card">
82
+ <header className="x-header">
83
+ <XMark />
84
+ </header>
85
+ <div className="signed-in">
86
+ <span className="account-text">
87
+ {/* A partially-pulled persona may lack a display name or handle; the fallback shows the
88
+ REAL id rather than an empty pill or a bare "@" (never an invented placeholder). */}
89
+ <span className="account-name">{view.account.name || view.account.username || view.account.id}</span>
90
+ {view.account.username ? <span className="account-username">@{view.account.username}</span> : null}
91
+ </span>
92
+ </div>
93
+ <h1 className="title">
94
+ <strong className="app-name">{appName}</strong> wants to access your X account
95
+ </h1>
96
+ <ScopeSection heading={`Things ${appName} can view`} rows={view.scopes.filter((s) => s.group === 'view')} />
97
+ <ScopeSection heading={`Things ${appName} can do`} rows={view.scopes.filter((s) => s.group === 'do')} />
98
+ <ScopeSection heading="Until you revoke access" rows={view.scopes.filter((s) => s.group === 'session')} />
99
+ <form method="POST" action={`${view.origin}/_twin/consent`} className="consent-form">
100
+ <input type="hidden" name="auth_request" value={view.requestId} />
101
+ <div className="actions">
102
+ <button className="btn btn-primary" type="submit" name="decision" value="allow">
103
+ Authorize app
104
+ </button>
105
+ <button className="btn btn-secondary" type="submit" name="decision" value="deny">
106
+ Cancel
107
+ </button>
108
+ </div>
109
+ </form>
110
+ <p className="legal">
111
+ You&apos;ll be redirected to <strong className="redirect-host">{view.redirectHost}</strong>. You can revoke
112
+ access to any app at any time from the Apps and sessions section of your X settings.
113
+ </p>
114
+ </div>
115
+ );
116
+ }
117
+
118
+ export type ErrorPageProps = {
119
+ status: number;
120
+ /** The OAuth error code the failure classifies as (e.g. `invalid_request`). */
121
+ code: string;
122
+ detail: string;
123
+ /** The offending parameter, echoed for the developer. */
124
+ requestParam?: string;
125
+ };
126
+
127
+ /**
128
+ * The authorize-endpoint error PAGE — what a browser sees when the twin cannot trust the
129
+ * redirect_uri (unknown client, unregistered callback) and therefore must not bounce the error
130
+ * back to it. X's live page for this case renders a terse "Something went wrong" card; its exact
131
+ * DOM was not captured, so this page states the failure plainly and machine-readably rather than
132
+ * imitating unverified vendor prose (`xidentity.authorize.error_page_fidelity`, todo).
133
+ */
134
+ export function ErrorPage({ status, code, detail, requestParam }: ErrorPageProps) {
135
+ return (
136
+ <div className="card card-error">
137
+ <header className="x-header">
138
+ <XMark />
139
+ </header>
140
+ <h1 className="title">Something went wrong</h1>
141
+ <p className="error-detail">You weren&apos;t able to give access to the App. {detail}</p>
142
+ <p className="error-code">
143
+ Error {status}: {code}
144
+ </p>
145
+ {requestParam ? <p className="error-request">Request details: {requestParam}</p> : null}
146
+ </div>
147
+ );
148
+ }
149
+
150
+ /**
151
+ * The bundled browser entry. The screen is fully functional server-side; the bundle only marks the
152
+ * document as enhanced (a hook for future affordances) without re-rendering server markup, so the
153
+ * no-JavaScript path and the enhanced path agree by construction.
154
+ */
155
+ export function hydrateConsentControls(): void {
156
+ const slot = document.getElementById('consent-enhanced');
157
+ if (!slot) return;
158
+ createRoot(slot).render(<StrictMode>{null}</StrictMode>);
159
+ document.documentElement.dataset['twinEnhanced'] = 'true';
160
+ }
161
+
162
+ if (typeof document !== 'undefined') hydrateConsentControls();
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-xidentity CLI: serve the X identity twin, or run conformance.
4
+ //
5
+ // `serve` and `mirror` start the SAME server, because the authorize screen is served by the twin
6
+ // at the vendor's own path — there is no second renderer to start. `mirror` additionally prints a
7
+ // ready-to-open authorization URL, which is the thing an operator actually wants when they ask to
8
+ // "see the UI".
9
+ import { hasFlag, optionValue } from '@volter/world-core/args';
10
+ import { createXIdentityTwinServer } from "./xidentity-server.js";
11
+ import { pkceS256 } from "./xidentity-pkce.js";
12
+ import { DEFAULT_CLIENT_ID, defaultRedirectUris } from "./xidentity-store.js";
13
+ const [cmd, ...rest] = process.argv.slice(2);
14
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
15
+ const root = optionValue(rest, '--root') || undefined;
16
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
17
+ if (cmd === 'serve' || cmd === 'mirror') {
18
+ const s = await createXIdentityTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
19
+ const origin = `http://127.0.0.1:${s.port}`;
20
+ process.stdout.write(`xidentity twin (X OAuth 2.0 + /2/users/me)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
21
+ if (cmd === 'mirror') {
22
+ const url = new URL(`${origin}/i/oauth2/authorize`);
23
+ url.searchParams.set('response_type', 'code');
24
+ url.searchParams.set('client_id', DEFAULT_CLIENT_ID);
25
+ url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]);
26
+ url.searchParams.set('scope', 'tweet.read users.read offline.access');
27
+ url.searchParams.set('state', 'twin-demo-state');
28
+ url.searchParams.set('code_challenge', pkceS256('twin-demo-verifier'));
29
+ url.searchParams.set('code_challenge_method', 'S256');
30
+ process.stdout.write(`authorize screen: ${url.toString()}\n`);
31
+ }
32
+ await keepProcessAlive();
33
+ }
34
+ else if (cmd === 'conformance') {
35
+ // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
36
+ const { checkXIdentityConformance } = await import("./xidentity-conformance.js");
37
+ const report = await checkXIdentityConformance({ ...(root ? { root } : {}) });
38
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
39
+ if (!report.ok)
40
+ process.exitCode = 1;
41
+ }
42
+ else {
43
+ process.stdout.write('Usage: world-xidentity serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
44
+ }
@@ -0,0 +1,15 @@
1
+ export { ACCESS_TOKEN_TTL_SECONDS, API_ORIGIN, AUTH_CODE_TTL_SECONDS, AUTHORIZE_ORIGIN, handleXIdentityTwinRequest, LEGACY_API_ORIGIN, LEGACY_AUTHORIZE_ORIGIN, RATE_WINDOW_SECONDS, RESOURCE_TYPES, USER_EXPANSIONS, USER_FIELDS, USERS_ME_RATE_LIMIT, USERS_ME_REQUIRED_SCOPES, xIdentityTwinSnapshot, } from './xidentity-twin.js';
2
+ export type { XIdentityRequest, XIdentityResponse, XResponse } from './xidentity-twin.js';
3
+ export { createXIdentityConsentServer, createXIdentityTwinFetch, createXIdentityTwinServer, type XIdentityTwinFetchOptions } from './xidentity-server.js';
4
+ export { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, DEFAULT_PUBLIC_CLIENT_ID, defaultRedirectUris, redirectUriAllowed, sessionAccount, } from './xidentity-store.js';
5
+ export { normalizeChallengeMethod, pkceS256, pkceVerifies } from './xidentity-pkce.js';
6
+ export { describeScope, describeScopes, formatScopeParam, KNOWN_SCOPES, parseScopeParam, SCOPE_CATALOG, sortScopesForConsent, } from './xidentity-scopes.js';
7
+ export type { ScopeInfo } from './xidentity-scopes.js';
8
+ export { liveXIdentityExecute, mapUsersMeAccount, PULL_USER_FIELDS, pullXIdentity, pushPendingXIdentityActions, syncXIdentityFromReal, } from './xidentity-connector.js';
9
+ export type { LiveXIdentityOptions, XIdentityExecute } from './xidentity-connector.js';
10
+ export { XIDENTITY_BUDGET_CEILING, XIDENTITY_BUDGET_MAX_RETRY_AFTER_S, XIDENTITY_BUDGET_WINDOW_MS, XIDENTITY_CALL_WEIGHTS, XIDENTITY_RATE_BUDGET, XIdentityBudget, XIdentityBudgetError, xIdentityBudgetPath, xIdentityCallWeight, } from './xidentity-budget.js';
11
+ export type { XIdentityBudgetErrorKind, XIdentityBudgetOptions, XIdentityBudgetReservation, XIdentityBudgetSnapshot, } from './xidentity-budget.js';
12
+ export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, xIdentityConsentState, } from './xidentity-consent-ui.js';
13
+ export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './xidentity-consent-ui.js';
14
+ import type { TwinPack } from '@volter/world-core';
15
+ export declare const pack: TwinPack;
@@ -0,0 +1,105 @@
1
+ // @volter/twin-xidentity — the X (Twitter) identity twin (one vendor, one package), built on the
2
+ // shared @volter/world-core kernel.
3
+ //
4
+ // A BROWSER-FACING protocol pack (the googleoauth class): it serves X's own authorize screen as
5
+ // HTML at x.com's real path, completes the full OAuth 2.0 authorization-code + PKCE round trip
6
+ // (consent → 302 with code+state → redemption at the token endpoint → refresh → revoke), and
7
+ // answers GET /2/users/me with X's real field/expansion/problem envelopes. Deliberately NON-OIDC,
8
+ // exactly as the vendor is: opaque tokens, no id_token, no JWKS — identity comes from the API.
9
+ // Conformance tooling lives in @volter/world-tooling (a dev dependency) — not shipped here.
10
+ export { ACCESS_TOKEN_TTL_SECONDS, API_ORIGIN, AUTH_CODE_TTL_SECONDS, AUTHORIZE_ORIGIN, handleXIdentityTwinRequest, LEGACY_API_ORIGIN, LEGACY_AUTHORIZE_ORIGIN, RATE_WINDOW_SECONDS, RESOURCE_TYPES, USER_EXPANSIONS, USER_FIELDS, USERS_ME_RATE_LIMIT, USERS_ME_REQUIRED_SCOPES, xIdentityTwinSnapshot, } from "./xidentity-twin.js";
11
+ export { createXIdentityConsentServer, createXIdentityTwinFetch, createXIdentityTwinServer } from "./xidentity-server.js";
12
+ export { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, DEFAULT_PUBLIC_CLIENT_ID, defaultRedirectUris, redirectUriAllowed, sessionAccount, } from "./xidentity-store.js";
13
+ export { normalizeChallengeMethod, pkceS256, pkceVerifies } from "./xidentity-pkce.js";
14
+ export { describeScope, describeScopes, formatScopeParam, KNOWN_SCOPES, parseScopeParam, SCOPE_CATALOG, sortScopesForConsent, } from "./xidentity-scopes.js";
15
+ export { liveXIdentityExecute, mapUsersMeAccount, PULL_USER_FIELDS, pullXIdentity, pushPendingXIdentityActions, syncXIdentityFromReal, } from "./xidentity-connector.js";
16
+ // The client-side rate budget — the fail-closed backstop `liveXIdentityExecute` routes every live
17
+ // request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
18
+ // here is this vendor's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
19
+ // bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
20
+ // `XIdentityBudgetError` by type; there is deliberately no export that disables the guard.
21
+ export { XIDENTITY_BUDGET_CEILING, XIDENTITY_BUDGET_MAX_RETRY_AFTER_S, XIDENTITY_BUDGET_WINDOW_MS, XIDENTITY_CALL_WEIGHTS, XIDENTITY_RATE_BUDGET, XIdentityBudget, XIdentityBudgetError, xIdentityBudgetPath, xIdentityCallWeight, } from "./xidentity-budget.js";
22
+ export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, xIdentityConsentState, } from "./xidentity-consent-ui.js";
23
+ import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from "./xidentity-budget.js";
24
+ /**
25
+ * The x.com / twitter.com paths THIS pack serves — the consent leg — as a RegExp SOURCE for the
26
+ * descriptor's `hosts` path rule. x.com is NOT an API host: it is the whole X web property (the
27
+ * timeline, settings, everything). Claiming the host outright would be the Cal.com mis-route
28
+ * incident with the largest possible blast radius, so this claims EXACTLY the authorize path plus
29
+ * the twin-only prefix. ANCHORED, the googleoauth lesson: an unanchored claim swallows
30
+ * `/i/oauth2/authorize2` and friends. Transcribed unchanged from inject.cjs's former
31
+ * `isXIdentityConsentPath` predicate.
32
+ */
33
+ const XIDENTITY_CONSENT_PATHS = '^/i/oauth2/authorize/?$|^/_twin/';
34
+ /**
35
+ * The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints
36
+ * and the authenticated-user read. api.x.com is the ENTIRE X API v2 host — posts, DMs, spaces — and
37
+ * this pack models the identity slice only, so nothing else is claimed: an unmodelled X call must
38
+ * refuse loudly rather than land in a twin that cannot serve it. Transcribed unchanged from
39
+ * inject.cjs's former `isXIdentityApiPath` predicate.
40
+ */
41
+ const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)/?$';
42
+ export const pack = {
43
+ // STILL PROTOCOL 1, and the reason is the gate rather than the pack (ROADMAP.md,
44
+ // shape-parity-refusals: a refusal the harness cannot express). Everything else is ready: the connector
45
+ // is on the v2 kernel and `performXIdentityAction` / `syncXIdentityFromRemote` are written and
46
+ // exported below. What blocks the declaration is that `GET /2/users/me` needs an access token
47
+ // that only a completed OAuth flow mints, so the refresh REFUSES at the twin's own wire — which
48
+ // is correct (never fold an empty account over observed state) and which the parity harness
49
+ // turns into a crash rather than a reported diff.
50
+ vendor: 'xidentity',
51
+ // The SAME object xidentity-budget.ts declares at module load — one source of truth, so
52
+ // registering the pack and importing the connector can never arm two different ceilings.
53
+ rateBudget: RATE_BUDGET,
54
+ transport: 'rest',
55
+ archetype: 'crud',
56
+ bin: 'world-xidentity',
57
+ resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window'],
58
+ specSource: 'api.x.com/2/openapi.json (X API v2 OpenAPI 2.167: /2/users/me, the Problem family, the '
59
+ + '24-scope OAuth2UserToken catalog) + docs.x.com OAuth 2.0 authorization-code/user-access-token '
60
+ + 'guides + the official SDK sources (@xdevplatform/xdk, twitter-api-typescript-sdk), all '
61
+ + 'fetched 2026-08-21',
62
+ description: 'X (Twitter) identity twin — the real x.com authorize screen, the full OAuth 2.0 '
63
+ + 'authorization-code + PKCE round trip (token, refresh, revoke), and GET /2/users/me with '
64
+ + "X's real field/expansion/problem envelopes. Non-OIDC, exactly as the vendor is.",
65
+ // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
66
+ // 2026-08-31).
67
+ //
68
+ // BOTH official clients — the current XDK and the legacy (archived but still installed) SDK —
69
+ // plus X's own npm scope, where the current official XDK lives and any future first-party
70
+ // sibling will. `twitter-api-v2` is deliberately NOT claimed here: it is a third-party library
71
+ // spanning the whole X API and belongs to the POSTING pack (`x`), which models the surface it is
72
+ // installed for.
73
+ //
74
+ // The X_* / TWITTER_* stems stay on THIS pack now that a second X pack exists, deliberately:
75
+ // X_CLIENT_ID / X_CLIENT_SECRET and TWITTER_CLIENT_ID / TWITTER_BEARER_TOKEN name the OAuth APP
76
+ // credentials, which is this pack's surface. NB the single-letter `X` stem fires on ANY
77
+ // X_<credential-suffix> shape the BROAD suffix list admits (X_KEY, X_TOKEN, X_API_URL, …), not
78
+ // only the strict _CLIENT_ID/_CLIENT_SECRET pair — accepted deliberately: a var actually named
79
+ // bare `X_*` with a credential suffix is overwhelmingly the vendor, and a false hit surfaces as
80
+ // a visible coverage row rather than a silent drop.
81
+ adoption: {
82
+ // No Python distribution of its own: X's official SDKs are TypeScript, and the one community
83
+ // Python client of this API - `tweepy` - is claimed by the `x` pack (one name, one pack), whose
84
+ // posting surface is what tweepy is overwhelmingly used for.
85
+ pypi: [],
86
+ sdks: ['@xdevplatform/xdk', 'twitter-api-sdk'],
87
+ scopes: ['@xdevplatform/'],
88
+ envStems: ['X', 'TWITTER'],
89
+ },
90
+ // TWO host families — x.com/api.x.com plus the twitter.com pair X's LEGACY official SDK still
91
+ // targets — every one path-scoped, because x.com is the entire X web property and api.x.com the
92
+ // entire X API v2 host, of which this pack models the identity slice only. With no pathname each
93
+ // host is still claimed so the ambient proxy MITMs it; the post-decrypt resolve then routes the
94
+ // identity paths here and refuses the rest loudly (unclaimedTwinnedHostPathMessage) — the
95
+ // Cal.com mis-route lesson.
96
+ hosts: [
97
+ { host: 'x.com', pathPattern: XIDENTITY_CONSENT_PATHS },
98
+ { host: 'twitter.com', pathPattern: XIDENTITY_CONSENT_PATHS },
99
+ { host: 'api.x.com', pathPattern: XIDENTITY_API_PATHS },
100
+ { host: 'api.twitter.com', pathPattern: XIDENTITY_API_PATHS },
101
+ ],
102
+ // A browser is REDIRECTED to x.com/i/oauth2/authorize — the loader host is the consent host and
103
+ // the prefix is the OAuth path root.
104
+ browserRouting: { apiPathPrefix: '/i/oauth2/', loaderHost: 'https://x.com' },
105
+ };