@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
|
@@ -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'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();
|
package/dist/src/cli.js
ADDED
|
@@ -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
|
+
};
|