@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
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @volter/twin-xidentity
2
+
3
+ A local, stateful, vendor-faithful twin of **X (Twitter) identity** — the x.com authorize screen,
4
+ the full OAuth 2.0 authorization-code + PKCE round trip (token, refresh, revoke), and
5
+ `GET /2/users/me` with X's real field/expansion/problem envelopes. Point an unmodified X client at
6
+ it and complete a whole sign-in-with-X flow offline.
7
+
8
+ ```bash
9
+ bun run packages/twin/xidentity/src/cli.ts mirror
10
+ # xidentity twin (X OAuth 2.0 + /2/users/me) at http://127.0.0.1:54321
11
+ # authorize screen: http://127.0.0.1:54321/i/oauth2/authorize?response_type=code&client_id=…
12
+ ```
13
+
14
+ ## Deliberately NON-OIDC — because the vendor is
15
+
16
+ X's OAuth 2.0 issues **opaque bearer tokens**: no `id_token`, no JWKS, no discovery document.
17
+ Identity is fetched from `GET /2/users/me` with the access token. That non-OIDC shape is the whole
18
+ reason this vendor needs its own pack next to `googleoauth` — an integration built against an OIDC
19
+ provider (id_token claims, `openid email profile` scopes) breaks against X in exactly the ways this
20
+ twin reproduces: the scope model is X's own catalog (the OpenAPI's 24 scopes plus the announcement-added
21
+ `users.email`, which gates `confirmed_email` — a recorded intra-vendor discrepancy), the refresh
22
+ token only arrives with `offline.access`, PKCE is **required**, and the only identity read is the
23
+ API call.
24
+
25
+ It is a **browser-facing protocol pack** (the `googleoauth` class): the authorize screen is served
26
+ as HTML at the vendor's real path (`/i/oauth2/authorize`), server-rendered from the twin's own
27
+ kernel projection by the **same React components** the browser bundle ships — one renderer, so
28
+ API↔UI parity cannot drift. The decision is two plain `<form>` submits ("Authorize app" /
29
+ "Cancel"), so **no JavaScript is required to complete an OAuth round trip** against this twin. The
30
+ bundle and stylesheet the twin serves are **committed text** (`src/xidentity-consent-client.gen.ts`,
31
+ written from `client/` by `bun scripts/consent-clients.ts` and drift-gated by
32
+ `scripts/consent-clients.test.ts`): the serve path reads constants and never runs a bundler.
33
+
34
+ ## Coverage
35
+
36
+ Partial and honest. The manifest (`src/xidentity-capabilities.ts`) is the **enumerated identity
37
+ service-area** denominator, not a claim to enumerate the whole X vendor surface. It is authored
38
+ top-down from X's own artefacts (all fetched 2026-08-21): the **X API
39
+ v2 OpenAPI document** (2.167) for `/2/users/me`, the Problem error family and the 24-scope catalog
40
+ with X's consent descriptions; the **docs.x.com OAuth 2.0 guides** for the authorize/token/refresh/
41
+ revoke requests; the **docs.x.com rate-limit pages** for the `x-rate-limit-*` headers and the
42
+ 75/15-minute per-user figure; and the **official SDK sources** (`@xdevplatform/xdk`,
43
+ `twitter-api-typescript-sdk`) for the token/revoke response shapes the docs never show as JSON.
44
+ It currently reads **82 done / 131 covered** (49 `todo`, and the login leg is not an API surface: it is the seeded session, so it is not in that
45
+ denominator). The `todo`s are real X identity
46
+ surface this twin does not model — most notably **OAuth 1.0a three-legged sign-in**, the **v1.1
47
+ app-only bearer flow**, the unmodelled `user.fields` (entities, withheld, subscription, the
48
+ relational fields), expansion hydration — plus a family of **wire-pinning todos**: behaviours the
49
+ twin models from RFC 6749/7009 or widely-reported captures because no fetched official artefact
50
+ states them (exact token error wording, the authorize error channel, refresh-token rotation, the
51
+ code TTL). Each such `done` names its evidence boundary in the manifest and its pinning todo.
52
+
53
+ ### What is real here
54
+
55
+ - **The complete round trip.** Authorization request → authorize screen → 302 to `redirect_uri`
56
+ with exactly `state`+`code` (the documented callback shape) → the code is redeemable **exactly
57
+ once**, PKCE-verified with real SHA-256 → `refresh_token` grant (rotating) → revoke kills the
58
+ whole grant. One pack against one state root, so the code minted at the screen is honoured at
59
+ the token endpoint by construction.
60
+ - **PKCE required**, `S256` and `plain`, exactly as X requires it; the legacy official SDK's
61
+ lowercase `s256` spelling is accepted because the vendor's own client emits it.
62
+ - **Exact callback matching** — X documents exact-match validation, so a trailing slash is a
63
+ different URI and `redirect_uri_mismatch`-class bugs reproduce.
64
+ - **Confidential vs public clients** — HTTP Basic for confidential (the docs' rule), body
65
+ `client_id` for public; a confidential client without its Basic header is refused.
66
+ - **Vendor-shaped opaque credentials** — the twin's codes/tokens are base64url blobs whose decoded
67
+ structure (`<opaque>:<unix-ms>:1:1:ac|at|rt:1`) matches what the docs' own examples decode to.
68
+ - **The identity read** — `GET /2/users/me` with the OpenAPI's scope demands (`tweet.read` +
69
+ `users.read`), the 24-value `user.fields` enum with the wire's invalid-parameter envelope, the
70
+ documented `x-rate-limit-*` headers, 75/15min per-user accounting, and the documented
71
+ 429 + legacy code 88 refusal.
72
+ - **SDK fidelity** — the unmodified current official SDK (`@xdevplatform/xdk`) drives
73
+ `users.getMe()` against the twin through its own public `baseUrl` config
74
+ (`xidentity-sdk.integration.test.ts`); the OAuth legs its hardcoded hosts cannot re-aim are
75
+ proven over real transport in the docs' own curl shapes.
76
+
77
+ ### What is not
78
+
79
+ The twin **authenticates nobody**: no password, no 2FA, no risk engine. The
80
+ x.com "signed in" session is a seeded persona row — `POST /_twin/session` switches it, which is the
81
+ honest local equivalent of "the browser is already signed in". This is the **identity service-area**
82
+ of the X vendor twin. Posts, timelines, DMs and the rest of the X content API are additional
83
+ service-areas to add within this same pack; they are not a reason to create a second X vendor pack.
84
+
85
+ ### Twin-only scaffolding (not vendor surface, not counted)
86
+
87
+ `GET /_twin/consent` (re-render a pending authorize screen by request handle) and
88
+ `POST /_twin/consent` (the Authorize/Cancel form post — X's real form posts to an undocumented
89
+ internal endpoint), `POST /_twin/clients`, `POST /_twin/accounts`, `POST /_twin/session`,
90
+ `POST /_twin/rate_limit` (arm a deterministic 429), and the `/_twin/assets/*` page assets. This
91
+ list is the audit trail the conformance census deliberately excludes — keep it in lockstep with
92
+ `twinControl`'s branches in `xidentity-twin.ts`.
93
+
94
+ ## The three operations
95
+
96
+ - **pull** — `syncXIdentityFromReal(execute)` observes the real account behind the operator's own
97
+ user token (`GET /2/users/me`, every modelled field) over an **injected executor**; live runs use
98
+ `liveXIdentityExecute(accessToken)`, the ONE place a real X request may be issued, guarded by the
99
+ fail-closed rate budget (`xidentity-budget.ts`: X's own 75/15min scheme, live-fetched
100
+ 2026-08-21). The client registry and the grant's scope set are **not observable** (no API reads
101
+ X Apps; X has no token introspection) — reasoned gaps, not fakes.
102
+ - **write** — the default: local consent flows mint local credentials, no real calls.
103
+ - **push** — structurally impossible (X exposes no write API for this surface) and reported as
104
+ such, never faked.
105
+
106
+ ## Serving
107
+
108
+ `world-xidentity serve` starts one server for the WHOLE surface; point `x.com`, `twitter.com`,
109
+ `api.x.com` and `api.twitter.com` at it (the injector's `xidentity` `VENDOR_HOSTS` entry does
110
+ exactly that). `mirror` is the same server plus a ready-to-open authorize URL; `conformance` runs
111
+ the endpoint census (one real probe per claimed endpoint + the hand-enumerated router surface +
112
+ a reachability witness per resource type).
@@ -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,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();