@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.
- package/README.md +112 -0
- package/client/xidentity-consent.css +204 -0
- package/client/xidentity-consent.tsx +162 -0
- package/dist/client/xidentity-consent.bundle.js +235 -0
- package/dist/client/xidentity-consent.css +204 -0
- package/dist/client/xidentity-consent.d.ts +53 -0
- package/dist/client/xidentity-consent.js +57 -0
- package/dist/client/xidentity-consent.tsx +162 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +105 -0
- package/dist/src/xidentity-budget.d.ts +50 -0
- package/dist/src/xidentity-budget.js +108 -0
- package/dist/src/xidentity-capabilities.d.ts +3 -0
- package/dist/src/xidentity-capabilities.js +905 -0
- package/dist/src/xidentity-conformance.d.ts +10 -0
- package/dist/src/xidentity-conformance.js +332 -0
- package/dist/src/xidentity-connector.d.ts +84 -0
- package/dist/src/xidentity-connector.js +239 -0
- package/dist/src/xidentity-consent-client.gen.d.ts +2 -0
- package/dist/src/xidentity-consent-client.gen.js +10 -0
- package/dist/src/xidentity-consent-ui.d.ts +21 -0
- package/dist/src/xidentity-consent-ui.js +94 -0
- package/dist/src/xidentity-pkce.d.ts +7 -0
- package/dist/src/xidentity-pkce.js +27 -0
- package/dist/src/xidentity-problems.d.ts +38 -0
- package/dist/src/xidentity-problems.js +108 -0
- package/dist/src/xidentity-scopes.d.ts +23 -0
- package/dist/src/xidentity-scopes.js +81 -0
- package/dist/src/xidentity-server.d.ts +33 -0
- package/dist/src/xidentity-server.js +85 -0
- package/dist/src/xidentity-store.d.ts +97 -0
- package/dist/src/xidentity-store.js +358 -0
- package/dist/src/xidentity-twin.d.ts +54 -0
- package/dist/src/xidentity-twin.js +851 -0
- package/package.json +74 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +177 -0
- package/src/xidentity-budget.ts +135 -0
- package/src/xidentity-capabilities.ts +1012 -0
- package/src/xidentity-conformance.ts +370 -0
- package/src/xidentity-connector.ts +269 -0
- package/src/xidentity-consent-client.gen.ts +10 -0
- package/src/xidentity-consent-ui.ts +113 -0
- package/src/xidentity-journey.uitest.ts +277 -0
- package/src/xidentity-pkce.ts +29 -0
- package/src/xidentity-problems.ts +128 -0
- package/src/xidentity-scopes.ts +96 -0
- package/src/xidentity-server.ts +97 -0
- package/src/xidentity-store.ts +419 -0
- 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'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'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'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();
|