@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,944 @@
|
|
|
1
|
+
// X (Twitter) identity twin REQUEST HANDLER — the vendor's sign-in-with-X surface, served locally.
|
|
2
|
+
//
|
|
3
|
+
// Contract: handleXIdentityTwinRequest({method, path, ...}) -> {status, body, headers}. It is the
|
|
4
|
+
// faithful surface an unmodified X client talks to:
|
|
5
|
+
//
|
|
6
|
+
// x.com GET /i/oauth2/authorize → the AUTHORIZE (consent) SCREEN
|
|
7
|
+
// api.x.com POST /2/oauth2/token → authorization_code + refresh_token grants
|
|
8
|
+
// POST /2/oauth2/revoke → token revocation
|
|
9
|
+
// GET /2/users/me → the authenticated user's identity
|
|
10
|
+
//
|
|
11
|
+
// (twitter.com / api.twitter.com are the same surface — X's LEGACY official SDK still points
|
|
12
|
+
// there — and the injector's VENDOR_HOSTS entry routes both host families here.)
|
|
13
|
+
//
|
|
14
|
+
// ── WHY THIS PACK IS BROWSER-FACING ─────────────────────────────────────────────────────────────
|
|
15
|
+
// The whole point of the authorization-code flow is that a browser is redirected to the vendor, a
|
|
16
|
+
// person reads what the app is asking for, and clicks Authorize. A twin that skipped the screen
|
|
17
|
+
// and 302'd straight back would be a bypass, not a twin — every integration bug that lives in the
|
|
18
|
+
// consent leg (wrong redirect_uri, missing state, a user who cancels) would be invisible. So the
|
|
19
|
+
// twin SERVES HTML at the vendor's real path, rendered server-side from its own projection by the
|
|
20
|
+
// SAME React components the client bundle ships (xidentity-consent-ui.ts). The googleoauth pack is
|
|
21
|
+
// the class precedent; the mirror DISCIPLINE applies in full.
|
|
22
|
+
//
|
|
23
|
+
// ── WHAT IS REAL HERE ───────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// • The FULL authorization-code round trip: auth request → consent → 302 with code+state → the
|
|
25
|
+
// code is redeemable exactly ONCE at this same pack's token endpoint → refresh → revoke.
|
|
26
|
+
// • PKCE S256/plain, verified with real SHA-256 (xidentity-pkce.ts). PKCE is REQUIRED, as X
|
|
27
|
+
// requires it — an authorize request without a code_challenge is refused.
|
|
28
|
+
// • Redirect-URI matching is EXACT, the way X's is ("This value must correspond to one of the
|
|
29
|
+
// Callback URLs defined in your App's settings", with exact-match validation).
|
|
30
|
+
// • Tokens are OPAQUE, deliberately: X's OAuth 2.0 issues no id_token, no JWKS, no OIDC
|
|
31
|
+
// discovery — identity is fetched from GET /2/users/me with the access token. This NON-OIDC
|
|
32
|
+
// shape is the entire reason this vendor needs its own pack next to googleoauth.
|
|
33
|
+
//
|
|
34
|
+
// ── WHAT IS NOT ─────────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// The twin authenticates NOBODY: there is no password, no 2FA, no risk engine. The x.com "signed
|
|
36
|
+
// in" session is a seeded persona row (`POST /_twin/session` switches it) — a deliberate
|
|
37
|
+
// design, not a hidden gap. This first X vendor service-area models IDENTITY only; posts,
|
|
38
|
+
// timelines and DMs are additional service-areas that belong in this same vendor pack when built.
|
|
39
|
+
//
|
|
40
|
+
// EVIDENCE BOUNDARIES are marked inline: behaviours grounded in docs.x.com / the v2 OpenAPI /
|
|
41
|
+
// X's official SDK sources (all fetched 2026-08-21) say so; behaviours derived from RFC 6749/7009
|
|
42
|
+
// or widely-reported wire captures are marked EXTRAPOLATION with a manifest todo pinning them.
|
|
43
|
+
//
|
|
44
|
+
// State lives in the kernel action log (D1). No real X endpoint is ever called from this path (D4).
|
|
45
|
+
import {
|
|
46
|
+
badAuthorizationCode,
|
|
47
|
+
badRefreshToken,
|
|
48
|
+
forbiddenProblem,
|
|
49
|
+
invalidClient,
|
|
50
|
+
invalidRequestProblem,
|
|
51
|
+
internalServerProblem,
|
|
52
|
+
notFoundProblem,
|
|
53
|
+
oauthError,
|
|
54
|
+
rateLimitExceeded,
|
|
55
|
+
unauthorizedProblem,
|
|
56
|
+
type XResponse,
|
|
57
|
+
} from './xidentity-problems.ts';
|
|
58
|
+
import { applyTwinWriteAtomic, type TwinResource } from '@volter/world-core';
|
|
59
|
+
import { consentPageHtml, errorPageHtml, xIdentityConsentState } from './xidentity-consent-ui.ts';
|
|
60
|
+
import { formatScopeParam, parseScopeParam } from './xidentity-scopes.ts';
|
|
61
|
+
import { normalizeChallengeMethod, pkceVerifies } from './xidentity-pkce.ts';
|
|
62
|
+
import {
|
|
63
|
+
authRequestIdFor,
|
|
64
|
+
ensureSeed,
|
|
65
|
+
mintAccessToken,
|
|
66
|
+
mintAuthorizationCode,
|
|
67
|
+
mintClientId,
|
|
68
|
+
mintRefreshToken,
|
|
69
|
+
nextRev,
|
|
70
|
+
nextRevIn,
|
|
71
|
+
readAll,
|
|
72
|
+
readOne,
|
|
73
|
+
readType,
|
|
74
|
+
RESOURCE_TYPES as STORE_RESOURCE_TYPES,
|
|
75
|
+
sessionAccount,
|
|
76
|
+
redirectUriAllowed,
|
|
77
|
+
write,
|
|
78
|
+
writeAtomic,
|
|
79
|
+
type Row,
|
|
80
|
+
} from './xidentity-store.ts';
|
|
81
|
+
|
|
82
|
+
export { RESOURCE_TYPES } from './xidentity-store.ts';
|
|
83
|
+
export type { XResponse } from './xidentity-problems.ts';
|
|
84
|
+
|
|
85
|
+
/** X's real hosts. The authorize screen lives on the site host; the API on api.x.com. The legacy
|
|
86
|
+
* twitter.com pair still serves the same surface and X's legacy official SDK still targets it. */
|
|
87
|
+
export const AUTHORIZE_ORIGIN = 'https://x.com';
|
|
88
|
+
export const API_ORIGIN = 'https://api.x.com';
|
|
89
|
+
export const LEGACY_AUTHORIZE_ORIGIN = 'https://twitter.com';
|
|
90
|
+
export const LEGACY_API_ORIGIN = 'https://api.twitter.com';
|
|
91
|
+
|
|
92
|
+
/** X's documented default access-token lifetime: "Access tokens default to 2-hour validity"
|
|
93
|
+
* (docs.x.com authorization-code guide, fetched 2026-08-21). */
|
|
94
|
+
export const ACCESS_TOKEN_TTL_SECONDS = 7200;
|
|
95
|
+
/** X's authorization codes are short-lived; 30 seconds is the widely-reported figure.
|
|
96
|
+
* EXTRAPOLATION — not read from a fetched official artefact; pinned as
|
|
97
|
+
* `xidentity.token.code_ttl` (todo). The single-use rule, by contrast, is hard protocol. */
|
|
98
|
+
export const AUTH_CODE_TTL_SECONDS = 30;
|
|
99
|
+
/** GET /2/users/me: 75 requests / 15 min per user (docs.x.com/x-api/fundamentals/rate-limits,
|
|
100
|
+
* fetched 2026-08-21). */
|
|
101
|
+
export const USERS_ME_RATE_LIMIT = 75;
|
|
102
|
+
export const RATE_WINDOW_SECONDS = 15 * 60;
|
|
103
|
+
|
|
104
|
+
export type XIdentityRequest = {
|
|
105
|
+
method: string;
|
|
106
|
+
/** Path plus query string, e.g. `/i/oauth2/authorize?client_id=…`. */
|
|
107
|
+
path: string;
|
|
108
|
+
body?: string;
|
|
109
|
+
/** Lower-cased request headers (authorization / content-type). */
|
|
110
|
+
headers?: Record<string, string>;
|
|
111
|
+
occurredAt?: string;
|
|
112
|
+
root?: string;
|
|
113
|
+
readOnly?: boolean;
|
|
114
|
+
/** Where this twin is reached (`twinPublicBase`: origin plus any served-World mount path) — the
|
|
115
|
+
* consent form's action must point back at the twin. Absent (an in-process call), vendor-real
|
|
116
|
+
* URLs are used. */
|
|
117
|
+
origin?: string;
|
|
118
|
+
/** The bare origin the request arrived at, when it differs from `origin` (a served World mounts
|
|
119
|
+
* the twin under a path). The seeded demo app's callbacks derive from it; defaults to `origin`. */
|
|
120
|
+
callbackOrigin?: string;
|
|
121
|
+
};
|
|
122
|
+
export type XIdentityResponse = XResponse;
|
|
123
|
+
|
|
124
|
+
const HTML = { 'content-type': 'text/html; charset=utf-8' };
|
|
125
|
+
const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
|
|
126
|
+
|
|
127
|
+
// ── helpers ─────────────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function splitPath(raw: string): { path: string; query: URLSearchParams } {
|
|
130
|
+
const [p, q = ''] = raw.split('?');
|
|
131
|
+
const path = (p ?? '/').replace(/\/+$/, '') || '/';
|
|
132
|
+
return { path, query: new URLSearchParams(q) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The world instant this request happened at. The twin has NO clock of its own (runtime contract
|
|
137
|
+
* R9: no clock in served content) — `createXIdentityTwinFetch` stamps every request with
|
|
138
|
+
* `worldNow()`, the world's frozen instant, and a caller that names no instant is a DEFECT rather
|
|
139
|
+
* than a cue to read the wall clock. The old `?? Date.now()` fallback put the host's wall clock
|
|
140
|
+
* into `expires_in` and into the unix-ms segment of every minted code/token on any path that
|
|
141
|
+
* forgot to stamp; it throws now.
|
|
142
|
+
*/
|
|
143
|
+
function instantOf(occurredAt?: string): string {
|
|
144
|
+
if (!occurredAt) {
|
|
145
|
+
throw new Error('xidentity twin: the request carries no occurredAt — this twin has no clock of its own; the server stamps worldNow()');
|
|
146
|
+
}
|
|
147
|
+
return occurredAt;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function nowMillis(occurredAt?: string): number {
|
|
151
|
+
const ms = Date.parse(instantOf(occurredAt));
|
|
152
|
+
if (Number.isNaN(ms)) throw new Error(`xidentity twin: occurredAt is not a timestamp: ${occurredAt}`);
|
|
153
|
+
return ms;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function nowSeconds(occurredAt?: string): number {
|
|
157
|
+
return Math.floor(nowMillis(occurredAt) / 1000);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The authorize endpoint's un-redirectable failure path: until the redirect_uri is proven to
|
|
162
|
+
* belong to the client there is no address the twin may bounce an error to, so unknown-client and
|
|
163
|
+
* bad-callback failures render the vendor's error page (200 HTML would hide the failure from
|
|
164
|
+
* scripts; the page is served WITH the failure's status). EXTRAPOLATION: X's live page and its
|
|
165
|
+
* HTTP status were not captured — the security boundary itself is RFC 6749 §4.1.2.1's hard rule.
|
|
166
|
+
* Pinned as `xidentity.authorize.error_page_fidelity` (todo).
|
|
167
|
+
*/
|
|
168
|
+
function errorPage(req: XIdentityRequest, e: { status: number; code: string; detail: string; requestParam?: string }): XResponse {
|
|
169
|
+
return {
|
|
170
|
+
status: e.status,
|
|
171
|
+
body: errorPageHtml(e, req.origin ?? ''),
|
|
172
|
+
headers: { ...HTML, ...NOSTORE },
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A 302 back to a VALIDATED redirect_uri carrying an OAuth error (RFC 6749 §4.1.2.1). */
|
|
177
|
+
function redirectError(redirectUri: string, error: string, state: string | null, description?: string): XResponse {
|
|
178
|
+
const url = new URL(redirectUri);
|
|
179
|
+
url.searchParams.set('error', error);
|
|
180
|
+
if (description) url.searchParams.set('error_description', description);
|
|
181
|
+
if (state !== null) url.searchParams.set('state', state);
|
|
182
|
+
return { status: 302, body: '', headers: { location: url.toString(), ...NOSTORE } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The success bounce. X's documented callback carries EXACTLY `state` and `code` (the
|
|
186
|
+
* user-access-token guide's example: `https://www.example.com/?state=state&code=VGNibzFWSWRE…`) —
|
|
187
|
+
* no scope echo, no extra parameters, and the twin adds none. */
|
|
188
|
+
function redirectSuccess(redirectUri: string, code: string, state: string): XResponse {
|
|
189
|
+
const url = new URL(redirectUri);
|
|
190
|
+
url.searchParams.set('state', state);
|
|
191
|
+
url.searchParams.set('code', code);
|
|
192
|
+
return { status: 302, body: '', headers: { location: url.toString(), ...NOSTORE } };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Client authentication at the token/revoke endpoints. The docs' rule (user-access-token guide):
|
|
197
|
+
* a CONFIDENTIAL client authenticates with HTTP Basic — base64(client_id:client_secret) — and
|
|
198
|
+
* "you don't need client id [in the body] for confidential clients with a valid Authorization
|
|
199
|
+
* Header"; a PUBLIC client has no secret and "you still are required to include Client Id in the
|
|
200
|
+
* body". A confidential client presenting no Basic header is refused: the docs document no
|
|
201
|
+
* client_secret_post alternative for X, and inventing that tolerance would be the inverse
|
|
202
|
+
* false-green (`xidentity.token.client_secret_post`, todo pins the live behaviour).
|
|
203
|
+
*/
|
|
204
|
+
function authenticateClient(
|
|
205
|
+
form: URLSearchParams,
|
|
206
|
+
headers: Record<string, string> | undefined,
|
|
207
|
+
root: string | undefined,
|
|
208
|
+
resources?: readonly TwinResource[],
|
|
209
|
+
): { client: Row } | { fail: XResponse } {
|
|
210
|
+
const lookup = (id: string) => (resources
|
|
211
|
+
? resources.find((row) => row.type === 'oauth_client' && row.id === id) as Row | undefined
|
|
212
|
+
: readOne(root, 'oauth_client', id));
|
|
213
|
+
const basic = headers?.authorization;
|
|
214
|
+
if (basic && /^basic /i.test(basic)) {
|
|
215
|
+
let decoded = '';
|
|
216
|
+
try {
|
|
217
|
+
decoded = Buffer.from(basic.slice(6).trim(), 'base64').toString('utf8');
|
|
218
|
+
} catch {
|
|
219
|
+
return { fail: invalidClient() };
|
|
220
|
+
}
|
|
221
|
+
// The percent-decode below applies RFC 6749 §2.3.1's rule (client_id and client_secret are
|
|
222
|
+
// form-urlencoded before Basic encoding). X's docs describe the header only as
|
|
223
|
+
// base64(client_id:client_secret) — EXTRAPOLATION, and the containment of a hostile decode is
|
|
224
|
+
// what `xidentity.errors.malformed_client_auth_contained` certifies; the live tolerance is
|
|
225
|
+
// pinned by `xidentity.token.error_wording` (todo).
|
|
226
|
+
const sep = decoded.indexOf(':');
|
|
227
|
+
if (sep < 0) return { fail: invalidClient() };
|
|
228
|
+
const clientId = decodeURIComponent(decoded.slice(0, sep));
|
|
229
|
+
const secret = decodeURIComponent(decoded.slice(sep + 1));
|
|
230
|
+
const client = lookup(clientId);
|
|
231
|
+
if (!client || typeof client.secret !== 'string' || client.secret !== secret) return { fail: invalidClient() };
|
|
232
|
+
return { client };
|
|
233
|
+
}
|
|
234
|
+
const clientId = form.get('client_id');
|
|
235
|
+
if (!clientId) return { fail: oauthError('invalid_request', 'Missing required parameter [client_id].', 400) };
|
|
236
|
+
const client = lookup(clientId);
|
|
237
|
+
if (!client) return { fail: invalidClient() };
|
|
238
|
+
if (client.clientType === 'confidential') return { fail: invalidClient() };
|
|
239
|
+
return { client };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── the authorization endpoint (the authorize screen) ───────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
const AUTHORIZE_PATH = '/i/oauth2/authorize';
|
|
245
|
+
|
|
246
|
+
async function authorize(req: XIdentityRequest, query: URLSearchParams): Promise<XResponse> {
|
|
247
|
+
const root = req.root;
|
|
248
|
+
|
|
249
|
+
// 1. client_id — without a trusted client there is no trusted redirect_uri, so failures here
|
|
250
|
+
// render the error page.
|
|
251
|
+
const clientId = query.get('client_id');
|
|
252
|
+
if (!clientId) return errorPage(req, { status: 400, code: 'invalid_request', detail: 'Missing required parameter: client_id.', requestParam: 'client_id' });
|
|
253
|
+
const client = readOne(root, 'oauth_client', clientId);
|
|
254
|
+
if (!client) return errorPage(req, { status: 401, code: 'invalid_client', detail: 'The client_id does not name a known App.', requestParam: `client_id=${clientId}` });
|
|
255
|
+
|
|
256
|
+
// 2. redirect_uri — THE SECURITY BOUNDARY. X validates the callback against the App's registered
|
|
257
|
+
// Callback URLs with exact matching; an unregistered one gets the page, never a bounce.
|
|
258
|
+
const redirectUri = query.get('redirect_uri');
|
|
259
|
+
if (!redirectUri) return errorPage(req, { status: 400, code: 'invalid_request', detail: 'Missing required parameter: redirect_uri.', requestParam: 'redirect_uri' });
|
|
260
|
+
if (!redirectUriAllowed(client, redirectUri)) {
|
|
261
|
+
return errorPage(req, {
|
|
262
|
+
status: 400,
|
|
263
|
+
code: 'invalid_request',
|
|
264
|
+
detail: 'The redirect_uri does not match any registered Callback URL for this App. X compares callback URLs exactly.',
|
|
265
|
+
requestParam: `redirect_uri=${redirectUri}`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 3. Everything after the boundary has a trustworthy address, so protocol errors bounce there
|
|
270
|
+
// (RFC 6749 §4.1.2.1 — EXTRAPOLATION for X: the docs show no authorize error examples; the
|
|
271
|
+
// page-vs-redirect split for each case is pinned by `xidentity.authorize.error_channel`, todo).
|
|
272
|
+
const state = query.get('state');
|
|
273
|
+
const respond = (error: string, description: string) =>
|
|
274
|
+
redirectError(redirectUri, error, state, description);
|
|
275
|
+
|
|
276
|
+
const responseType = query.get('response_type');
|
|
277
|
+
if (!responseType) return respond('invalid_request', 'Missing required parameter: response_type.');
|
|
278
|
+
// `code` is the ONLY response_type X's OAuth 2.0 documents — there is no implicit flow to
|
|
279
|
+
// model, so an unknown value is the vendor's own unsupported_response_type, not "unmodelled".
|
|
280
|
+
if (responseType !== 'code') return respond('unsupported_response_type', `Unsupported response_type: ${responseType}.`);
|
|
281
|
+
|
|
282
|
+
const scopes = parseScopeParam(query.get('scope'));
|
|
283
|
+
if (scopes.length === 0) return respond('invalid_request', 'Missing required parameter: scope.');
|
|
284
|
+
|
|
285
|
+
// `state` is REQUIRED by X: both official SDKs' authorize-URL builders make it non-optional
|
|
286
|
+
// (twitter-api-typescript-sdk GenerateAuthUrlOptions; xdk oauth2_auth) and the docs describe it
|
|
287
|
+
// as the CSRF check with a 500-character ceiling.
|
|
288
|
+
if (state === null) return respond('invalid_request', 'Missing required parameter: state.');
|
|
289
|
+
if (state.length > 500) return respond('invalid_request', 'The state parameter must be at most 500 characters.');
|
|
290
|
+
|
|
291
|
+
// PKCE is REQUIRED on X's OAuth 2.0 — the docs' parameter tables list code_challenge, and the
|
|
292
|
+
// LEGACY official SDK always sends one. (The current xdk sends it only after
|
|
293
|
+
// setPkceParameters — the docs table, not the SDKs, is the load-bearing evidence; §9 round two.)
|
|
294
|
+
const codeChallenge = query.get('code_challenge');
|
|
295
|
+
if (!codeChallenge) return respond('invalid_request', 'Missing required parameter: code_challenge.');
|
|
296
|
+
const rawMethod = query.get('code_challenge_method');
|
|
297
|
+
// Method omitted ⇒ `plain` (RFC 6376 §4.3 default — EXTRAPOLATION for X, whose docs always show
|
|
298
|
+
// the parameter; pinned by `xidentity.authorize.challenge_method_default`, todo).
|
|
299
|
+
const method = rawMethod === null ? 'plain' : normalizeChallengeMethod(rawMethod);
|
|
300
|
+
if (method === null) {
|
|
301
|
+
return respond('invalid_request', `Invalid code_challenge_method: ${rawMethod}. Expected S256 or plain.`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (req.readOnly) {
|
|
305
|
+
return oauthError('temporarily_unavailable', 'this twin was started read-only; omit readOnly to accept writes', 405);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const account = sessionAccount(root);
|
|
309
|
+
if (!account) {
|
|
310
|
+
// No signed-in session to consent as. The REAL x.com would show a login screen here; the twin
|
|
311
|
+
// authenticates nobody, so it states that plainly rather than pretending.
|
|
312
|
+
return errorPage(req, { status: 400, code: 'invalid_request', detail: 'No x.com session is signed in on this twin. Seed an account and set /_twin/session.' });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 4. Record the request, then render the screen. The auth_request row is what the
|
|
316
|
+
// Authorize/Cancel post resolves — the browser never carries the parameters back, so they
|
|
317
|
+
// cannot be tampered with between the two legs.
|
|
318
|
+
const fields = {
|
|
319
|
+
clientId,
|
|
320
|
+
redirectUri,
|
|
321
|
+
scope: formatScopeParam(scopes),
|
|
322
|
+
state,
|
|
323
|
+
codeChallenge,
|
|
324
|
+
codeChallengeMethod: method,
|
|
325
|
+
accountId: account.id,
|
|
326
|
+
settled: false,
|
|
327
|
+
rev: 1,
|
|
328
|
+
};
|
|
329
|
+
// The handle is SERVED (it is the authorize form's own hidden field), so R9 governs it: minted
|
|
330
|
+
// from the world instant + the rows already held, never from entropy, and an identical authorize
|
|
331
|
+
// at the same instant reuses the pending row rather than piling up a second screen.
|
|
332
|
+
const requestId = authRequestIdFor(root, instantOf(req.occurredAt), fields);
|
|
333
|
+
await write('auth_request', requestId, 'auth_request.create', fields, { root, occurredAt: instantOf(req.occurredAt) });
|
|
334
|
+
|
|
335
|
+
return renderConsent(req, requestId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function renderConsent(req: XIdentityRequest, requestId: string): XResponse {
|
|
339
|
+
const view = xIdentityConsentState({
|
|
340
|
+
root: req.root,
|
|
341
|
+
requestId,
|
|
342
|
+
origin: req.origin ?? AUTHORIZE_ORIGIN,
|
|
343
|
+
});
|
|
344
|
+
if (!view) {
|
|
345
|
+
return errorPage(req, { status: 400, code: 'invalid_request', detail: 'Unknown or expired authorization request.' });
|
|
346
|
+
}
|
|
347
|
+
return { status: 200, body: consentPageHtml(view, req.origin ?? ''), headers: { ...HTML, ...NOSTORE } };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The Authorize/Cancel post. TWIN-ONLY SURFACE, deliberately namespaced under `/_twin/` and
|
|
352
|
+
* deliberately ABSENT from the capability manifest: X's authorize form posts to an undocumented
|
|
353
|
+
* internal endpoint, so there is no vendor path to be faithful to here (the googleoauth
|
|
354
|
+
* `/_twin/consent` precedent, sanctioned by ADDING_A_TWIN.md §6's seed-route rule).
|
|
355
|
+
*/
|
|
356
|
+
async function consentDecision(req: XIdentityRequest, form: URLSearchParams): Promise<XResponse> {
|
|
357
|
+
const root = req.root;
|
|
358
|
+
const at = req.occurredAt;
|
|
359
|
+
const requestId = form.get('auth_request') ?? '';
|
|
360
|
+
const issuedMs = nowMillis(at);
|
|
361
|
+
const code = mintAuthorizationCode(root, issuedMs, requestId);
|
|
362
|
+
const decided = await applyTwinWriteAtomic(
|
|
363
|
+
'xidentity',
|
|
364
|
+
(resources) => {
|
|
365
|
+
const authRequest = resources.find((row) => row.type === 'auth_request' && row.id === requestId) as Row | undefined;
|
|
366
|
+
if (!authRequest || authRequest.settled === true) {
|
|
367
|
+
return { kind: 'skip', value: oauthError('invalid_request', 'Unknown, expired, or already settled authorization request.', 400) };
|
|
368
|
+
}
|
|
369
|
+
const redirectUri = String(authRequest.redirectUri);
|
|
370
|
+
const state = String(authRequest.state ?? '');
|
|
371
|
+
const settle = {
|
|
372
|
+
operation: 'auth_request.settle',
|
|
373
|
+
subjectType: 'auth_request',
|
|
374
|
+
subjectId: requestId,
|
|
375
|
+
fields: { settled: true, rev: nextRevIn(resources, 'auth_request', requestId) },
|
|
376
|
+
...(at ? { occurredAt: at } : {}),
|
|
377
|
+
actor: { kind: 'human' as const },
|
|
378
|
+
};
|
|
379
|
+
if (form.get('decision') !== 'allow') {
|
|
380
|
+
return { kind: 'write', value: redirectError(redirectUri, 'access_denied', state), write: settle };
|
|
381
|
+
}
|
|
382
|
+
// Bind consent to the account displayed when this request was created. A concurrent session
|
|
383
|
+
// switch cannot make the screen name one account while minting credentials for another.
|
|
384
|
+
const account = resources.find((row) => row.type === 'account' && row.id === authRequest.accountId) as Row | undefined;
|
|
385
|
+
if (!account) return { kind: 'skip', value: oauthError('invalid_request', 'The account shown for this authorization request no longer exists.', 400) };
|
|
386
|
+
const scopes = parseScopeParam(String(authRequest.scope));
|
|
387
|
+
const grantId = `${authRequest.clientId}:${account.id}`;
|
|
388
|
+
const grantFields = { clientId: authRequest.clientId, sub: account.id, scopes, rev: nextRevIn(resources, 'grant', grantId) };
|
|
389
|
+
const grantExists = resources.some((row) => row.type === 'grant' && row.id === grantId);
|
|
390
|
+
return {
|
|
391
|
+
kind: 'write',
|
|
392
|
+
value: redirectSuccess(redirectUri, code, state),
|
|
393
|
+
write: {
|
|
394
|
+
...settle,
|
|
395
|
+
projection: {
|
|
396
|
+
creates: [
|
|
397
|
+
...(grantExists ? [] : [{ type: 'grant', id: grantId, fields: grantFields }]),
|
|
398
|
+
{
|
|
399
|
+
type: 'authorization_code',
|
|
400
|
+
id: code,
|
|
401
|
+
fields: {
|
|
402
|
+
clientId: authRequest.clientId,
|
|
403
|
+
sub: account.id,
|
|
404
|
+
scope: formatScopeParam(scopes),
|
|
405
|
+
redirectUri,
|
|
406
|
+
codeChallenge: authRequest.codeChallenge,
|
|
407
|
+
codeChallengeMethod: authRequest.codeChallengeMethod,
|
|
408
|
+
consumed: false,
|
|
409
|
+
expiresAt: nowSeconds(at) + AUTH_CODE_TTL_SECONDS,
|
|
410
|
+
rev: 1,
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
...(grantExists ? { updates: [{ type: 'grant', id: grantId, fields: grantFields }] } : {}),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
root,
|
|
420
|
+
);
|
|
421
|
+
return decided.value;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ── the token endpoint ──────────────────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
async function token(req: XIdentityRequest): Promise<XResponse> {
|
|
427
|
+
// X's token endpoint is `application/x-www-form-urlencoded` (docs curl examples). The twin is
|
|
428
|
+
// deliberately strict about that rather than tolerating a JSON body it has no documented
|
|
429
|
+
// evidence the vendor accepts (`xidentity.token.json_body_tolerance`, todo).
|
|
430
|
+
const form = new URLSearchParams(req.body ?? '');
|
|
431
|
+
const grantType = form.get('grant_type');
|
|
432
|
+
if (!grantType) return oauthError('invalid_request', 'Missing required parameter [grant_type].', 400);
|
|
433
|
+
if (grantType === 'authorization_code') return authorizationCodeGrant(req, form);
|
|
434
|
+
if (grantType === 'refresh_token') return refreshTokenGrant(req, form);
|
|
435
|
+
// X's OAuth 2.0 user-token endpoint documents exactly the two grants. (The app-only bearer flow
|
|
436
|
+
// lives at the v1.1 `/oauth2/token` path, filed as `xidentity.app_only.bearer_token`, todo.)
|
|
437
|
+
return oauthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}.`, 400);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function issuedTokenResponse(accessToken: string, scope: string, refreshToken?: string): XResponse {
|
|
441
|
+
return {
|
|
442
|
+
status: 200,
|
|
443
|
+
body: {
|
|
444
|
+
token_type: 'bearer',
|
|
445
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
446
|
+
access_token: accessToken,
|
|
447
|
+
scope,
|
|
448
|
+
...(refreshToken ? { refresh_token: refreshToken } : {}),
|
|
449
|
+
},
|
|
450
|
+
headers: { ...NOSTORE },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function authorizationCodeGrant(req: XIdentityRequest, form: URLSearchParams): Promise<XResponse> {
|
|
455
|
+
const root = req.root;
|
|
456
|
+
const code = form.get('code');
|
|
457
|
+
if (!code) return oauthError('invalid_request', 'Missing required parameter [code].', 400);
|
|
458
|
+
const redirectUri = form.get('redirect_uri');
|
|
459
|
+
if (!redirectUri) return oauthError('invalid_request', 'Missing required parameter [redirect_uri].', 400);
|
|
460
|
+
const verifier = form.get('code_verifier');
|
|
461
|
+
if (!verifier) return oauthError('invalid_request', 'Missing required parameter [code_verifier].', 400);
|
|
462
|
+
|
|
463
|
+
const issuedMs = nowMillis(req.occurredAt);
|
|
464
|
+
const accessToken = mintAccessToken(root, issuedMs, code);
|
|
465
|
+
const replacementRefreshToken = mintRefreshToken(root, issuedMs, code);
|
|
466
|
+
const committed = await applyTwinWriteAtomic(
|
|
467
|
+
'xidentity',
|
|
468
|
+
(resources) => {
|
|
469
|
+
const auth = authenticateClient(form, req.headers, root, resources);
|
|
470
|
+
if ('fail' in auth) return { kind: 'skip', value: auth.fail };
|
|
471
|
+
const row = resources.find((resource) => resource.type === 'authorization_code' && resource.id === code) as Row | undefined;
|
|
472
|
+
if (!row || row.clientId !== auth.client.id || row.consumed === true
|
|
473
|
+
|| (typeof row.expiresAt === 'number' && nowSeconds(req.occurredAt) >= row.expiresAt)
|
|
474
|
+
|| redirectUri !== row.redirectUri
|
|
475
|
+
|| !pkceVerifies(row.codeChallengeMethod === 'S256' ? 'S256' : 'plain', String(row.codeChallenge ?? ''), verifier)) {
|
|
476
|
+
return { kind: 'skip', value: badAuthorizationCode() };
|
|
477
|
+
}
|
|
478
|
+
const scope = String(row.scope);
|
|
479
|
+
const withRefreshToken = parseScopeParam(scope).includes('offline.access');
|
|
480
|
+
return {
|
|
481
|
+
kind: 'write',
|
|
482
|
+
value: issuedTokenResponse(accessToken, scope, withRefreshToken ? replacementRefreshToken : undefined),
|
|
483
|
+
write: {
|
|
484
|
+
operation: 'authorization_code.redeem',
|
|
485
|
+
subjectType: 'authorization_code',
|
|
486
|
+
subjectId: code,
|
|
487
|
+
fields: { consumed: true, rev: nextRevIn(resources, 'authorization_code', code) },
|
|
488
|
+
projection: {
|
|
489
|
+
creates: [
|
|
490
|
+
{
|
|
491
|
+
type: 'access_token', id: accessToken,
|
|
492
|
+
fields: { clientId: auth.client.id, sub: String(row.sub), scope, expiresAt: nowSeconds(req.occurredAt) + ACCESS_TOKEN_TTL_SECONDS, revoked: false, rev: 1 },
|
|
493
|
+
},
|
|
494
|
+
...(withRefreshToken ? [{
|
|
495
|
+
type: 'refresh_token', id: replacementRefreshToken,
|
|
496
|
+
fields: { clientId: auth.client.id, sub: String(row.sub), scope, revoked: false, rev: 1 },
|
|
497
|
+
}] : []),
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
501
|
+
actor: { kind: 'system' },
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
},
|
|
505
|
+
root,
|
|
506
|
+
);
|
|
507
|
+
return committed.value;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function refreshTokenGrant(req: XIdentityRequest, form: URLSearchParams): Promise<XResponse> {
|
|
511
|
+
const root = req.root;
|
|
512
|
+
const refreshToken = form.get('refresh_token');
|
|
513
|
+
if (!refreshToken) return oauthError('invalid_request', 'Missing required parameter [refresh_token].', 400);
|
|
514
|
+
const issuedMs = nowMillis(req.occurredAt);
|
|
515
|
+
const accessToken = mintAccessToken(root, issuedMs, refreshToken);
|
|
516
|
+
const replacementRefreshToken = mintRefreshToken(root, issuedMs, refreshToken);
|
|
517
|
+
const committed = await applyTwinWriteAtomic(
|
|
518
|
+
'xidentity',
|
|
519
|
+
(resources) => {
|
|
520
|
+
const auth = authenticateClient(form, req.headers, root, resources);
|
|
521
|
+
if ('fail' in auth) return { kind: 'skip', value: auth.fail };
|
|
522
|
+
const row = resources.find((resource) => resource.type === 'refresh_token' && resource.id === refreshToken) as Row | undefined;
|
|
523
|
+
if (!row || row.revoked === true || row.clientId !== auth.client.id) return { kind: 'skip', value: badRefreshToken() };
|
|
524
|
+
const scope = String(row.scope);
|
|
525
|
+
return {
|
|
526
|
+
kind: 'write',
|
|
527
|
+
value: issuedTokenResponse(accessToken, scope, replacementRefreshToken),
|
|
528
|
+
write: {
|
|
529
|
+
operation: 'refresh_token.rotate',
|
|
530
|
+
subjectType: 'refresh_token',
|
|
531
|
+
subjectId: refreshToken,
|
|
532
|
+
fields: { revoked: true, rev: nextRevIn(resources, 'refresh_token', refreshToken) },
|
|
533
|
+
projection: {
|
|
534
|
+
creates: [
|
|
535
|
+
{ type: 'access_token', id: accessToken, fields: { clientId: auth.client.id, sub: String(row.sub), scope, expiresAt: nowSeconds(req.occurredAt) + ACCESS_TOKEN_TTL_SECONDS, revoked: false, rev: 1 } },
|
|
536
|
+
{ type: 'refresh_token', id: replacementRefreshToken, fields: { clientId: auth.client.id, sub: String(row.sub), scope, revoked: false, rev: 1 } },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
540
|
+
actor: { kind: 'system' },
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
},
|
|
544
|
+
root,
|
|
545
|
+
);
|
|
546
|
+
return committed.value;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── the revoke endpoint ─────────────────────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
async function revoke(req: XIdentityRequest): Promise<XResponse> {
|
|
552
|
+
const form = new URLSearchParams(req.body ?? '');
|
|
553
|
+
const root = req.root;
|
|
554
|
+
const opts = { root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) };
|
|
555
|
+
const auth = authenticateClient(form, req.headers, root);
|
|
556
|
+
if ('fail' in auth) return auth.fail;
|
|
557
|
+
const { client } = auth;
|
|
558
|
+
|
|
559
|
+
const token_ = form.get('token');
|
|
560
|
+
if (!token_) return oauthError('invalid_request', 'Missing required parameter [token].', 400);
|
|
561
|
+
// `token_type_hint` (access_token | refresh_token) is what X's LEGACY official SDK always sends
|
|
562
|
+
// (RevokeAccessTokenParams). Per RFC 7009 §2.1 the hint only optimises the lookup — the twin
|
|
563
|
+
// searches both types regardless, so a wrong hint cannot change the outcome.
|
|
564
|
+
|
|
565
|
+
const access = readOne(root, 'access_token', token_);
|
|
566
|
+
const refresh = readOne(root, 'refresh_token', token_);
|
|
567
|
+
const match = access ?? refresh;
|
|
568
|
+
|
|
569
|
+
// A token that does not exist answers 200 (RFC 7009 §2.2: "invalid tokens do not cause an
|
|
570
|
+
// error" — answering differently would let a client probe which tokens exist). A token that
|
|
571
|
+
// belongs to ANOTHER client also answers 200 here WITHOUT revoking — but note RFC 7009 §2.1
|
|
572
|
+
// says a failed issued-to-this-client validation is REFUSED with an error, so the 200 reading
|
|
573
|
+
// treats a foreign token as merely invalid; which reading X takes is UNPINNED — EXTRAPOLATION,
|
|
574
|
+
// pinned by `xidentity.revoke.cross_client_wire` (the foreign-client case) and
|
|
575
|
+
// `xidentity.revoke.wire_pin` (unknown-token status, sweep, body). The response body
|
|
576
|
+
// `{"revoked": true}` is grounded in the legacy official SDK's RevokeAccessTokenResponse type.
|
|
577
|
+
if (match && match.clientId === client.id) {
|
|
578
|
+
// Revocation is the docs' "log out" feature, and the GRANT RECORD is what dies: the twin
|
|
579
|
+
// resolves the (client, user) grant the named token belongs to and revokes everything under
|
|
580
|
+
// it — every access token, every refresh token, and the grant itself, whichever half was
|
|
581
|
+
// named. EXTRAPOLATION for the pair-wide sweep (`xidentity.revoke.pair_revocation`, todo);
|
|
582
|
+
// revoking ONLY the named token would leave a "logged out" user's other half alive. A token
|
|
583
|
+
// whose grant row is somehow gone falls back to dying alone.
|
|
584
|
+
const sub = String(match.sub);
|
|
585
|
+
const clientId = String(match.clientId);
|
|
586
|
+
const grantId = `${clientId}:${sub}`;
|
|
587
|
+
const grant = readOne(root, 'grant', grantId);
|
|
588
|
+
if (grant) {
|
|
589
|
+
for (const row of readType(root, 'access_token')) {
|
|
590
|
+
if (row.sub === sub && row.clientId === clientId && row.revoked !== true) {
|
|
591
|
+
await write('access_token', row.id, 'access_token.revoke', { revoked: true, rev: nextRev(root, 'access_token', row.id) }, opts);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
for (const row of readType(root, 'refresh_token')) {
|
|
595
|
+
if (row.sub === sub && row.clientId === clientId && row.revoked !== true) {
|
|
596
|
+
await write('refresh_token', row.id, 'refresh_token.revoke', { revoked: true, rev: nextRev(root, 'refresh_token', row.id) }, opts);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
await write('grant', grantId, 'grant.revoke', { clientId, sub, scopes: [], rev: nextRev(root, 'grant', grantId) }, opts);
|
|
600
|
+
} else {
|
|
601
|
+
const type = access ? 'access_token' : 'refresh_token';
|
|
602
|
+
await write(type, match.id, `${type}.revoke`, { revoked: true, rev: nextRev(root, type, match.id) }, opts);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return { status: 200, body: { revoked: true }, headers: { ...NOSTORE } };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── GET /2/users/me ─────────────────────────────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
/** The `user.fields` enum — the v2 OpenAPI's UserFieldsParameter (2.167), verbatim. */
|
|
611
|
+
export const USER_FIELDS = [
|
|
612
|
+
'confirmed_email',
|
|
613
|
+
'connection_status',
|
|
614
|
+
'created_at',
|
|
615
|
+
'description',
|
|
616
|
+
'entities',
|
|
617
|
+
'id',
|
|
618
|
+
'is_identity_verified',
|
|
619
|
+
'location',
|
|
620
|
+
'name',
|
|
621
|
+
'parody',
|
|
622
|
+
'profile_banner_url',
|
|
623
|
+
'profile_image_url',
|
|
624
|
+
'protected',
|
|
625
|
+
'public_metrics',
|
|
626
|
+
'receives_your_dm',
|
|
627
|
+
'subscribes_to_you',
|
|
628
|
+
'subscription',
|
|
629
|
+
'subscription_type',
|
|
630
|
+
'url',
|
|
631
|
+
'username',
|
|
632
|
+
'verified',
|
|
633
|
+
'verified_followers_count',
|
|
634
|
+
'verified_type',
|
|
635
|
+
'withheld',
|
|
636
|
+
] as const;
|
|
637
|
+
|
|
638
|
+
/** The `expansions` enum — UserExpansionsParameter (2.167), verbatim. */
|
|
639
|
+
export const USER_EXPANSIONS = ['affiliation', 'most_recent_post_id', 'pinned_post_id'] as const;
|
|
640
|
+
|
|
641
|
+
/** The scopes GET /2/users/me demands — the OpenAPI operation's OAuth2UserToken entry lists BOTH. */
|
|
642
|
+
export const USERS_ME_REQUIRED_SCOPES = ['tweet.read', 'users.read'] as const;
|
|
643
|
+
|
|
644
|
+
/** Map an account row to the vendor's User object for a requested field set. Default fields are
|
|
645
|
+
* id, name, username (the OpenAPI/docs default); everything else appears only when asked for and
|
|
646
|
+
* only when the twin models it — a modelled-but-absent optional field is OMITTED, matching the
|
|
647
|
+
* spec (no User property is `required`). Unmodelled enum values (`connection_status`,
|
|
648
|
+
* `receives_your_dm`, …) are accepted and omitted; each is a named manifest todo, not silent.
|
|
649
|
+
* `confirmed_email` additionally demands the `users.email` scope (X's April-2025 announcement —
|
|
650
|
+
* see xidentity-scopes.ts); OMISSION when the scope is missing is EXTRAPOLATION, pinned by
|
|
651
|
+
* `xidentity.users_me.confirmed_email_scope_behavior` (todo). */
|
|
652
|
+
function renderUser(account: Row, fields: readonly string[], heldScopes: readonly string[]): Record<string, unknown> {
|
|
653
|
+
// name/username are default fields on the wire, but a PARTIALLY-pulled persona may genuinely
|
|
654
|
+
// lack them — serving "" would be the connector's placeholder bug relocated (§9 round two), so
|
|
655
|
+
// an absent value is OMITTED here too.
|
|
656
|
+
const out: Record<string, unknown> = {
|
|
657
|
+
id: account.id,
|
|
658
|
+
...(typeof account.name === 'string' ? { name: account.name } : {}),
|
|
659
|
+
...(typeof account.username === 'string' ? { username: account.username } : {}),
|
|
660
|
+
};
|
|
661
|
+
const want = new Set(fields);
|
|
662
|
+
if (want.has('created_at') && typeof account.createdAt === 'string') out['created_at'] = account.createdAt;
|
|
663
|
+
if (want.has('description') && typeof account.description === 'string') out['description'] = account.description;
|
|
664
|
+
if (want.has('location') && typeof account.location === 'string') out['location'] = account.location;
|
|
665
|
+
if (want.has('profile_image_url') && typeof account.profileImageUrl === 'string') out['profile_image_url'] = account.profileImageUrl;
|
|
666
|
+
if (want.has('protected')) out['protected'] = account.protectedAccount === true;
|
|
667
|
+
if (want.has('public_metrics') && account.publicMetrics && typeof account.publicMetrics === 'object') out['public_metrics'] = account.publicMetrics;
|
|
668
|
+
if (want.has('url') && typeof account.url === 'string') out['url'] = account.url;
|
|
669
|
+
if (want.has('verified')) out['verified'] = account.verified === true;
|
|
670
|
+
if (want.has('verified_type') && typeof account.verifiedType === 'string') out['verified_type'] = account.verifiedType;
|
|
671
|
+
if (want.has('confirmed_email') && typeof account.confirmedEmail === 'string' && heldScopes.includes('users.email')) {
|
|
672
|
+
out['confirmed_email'] = account.confirmedEmail;
|
|
673
|
+
}
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function csvParam(query: URLSearchParams, name: string): string[] {
|
|
678
|
+
const raw = query.get(name);
|
|
679
|
+
if (raw === null || raw === '') return [];
|
|
680
|
+
return raw.split(',').map((s) => s.trim());
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** The invalid-enum message, shaped like the wire's: names the bad values and the allowed set. */
|
|
684
|
+
function enumMessage(param: string, bad: string[], allowed: readonly string[]): string {
|
|
685
|
+
return `The \`${param}\` query parameter value [${bad.join(', ')}] is not one of [${allowed.join(', ')}]`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function usersMe(req: XIdentityRequest, query: URLSearchParams): Promise<XResponse> {
|
|
689
|
+
const root = req.root;
|
|
690
|
+
const header = req.headers?.authorization ?? '';
|
|
691
|
+
const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
|
|
692
|
+
if (!bearer) return unauthorizedProblem();
|
|
693
|
+
|
|
694
|
+
// Parameter validation happens BEFORE rate accounting: a 400 is not a spent request.
|
|
695
|
+
// EXTRAPOLATION on the ordering (`xidentity.rate.what_counts`, todo).
|
|
696
|
+
const fields = csvParam(query, 'user.fields');
|
|
697
|
+
const badFields = fields.filter((f) => !(USER_FIELDS as readonly string[]).includes(f));
|
|
698
|
+
if (badFields.length > 0) {
|
|
699
|
+
return invalidRequestProblem({ 'user.fields': badFields }, enumMessage('user.fields', badFields, USER_FIELDS));
|
|
700
|
+
}
|
|
701
|
+
const expansions = csvParam(query, 'expansions');
|
|
702
|
+
const badExpansions = expansions.filter((e) => !(USER_EXPANSIONS as readonly string[]).includes(e));
|
|
703
|
+
if (badExpansions.length > 0) {
|
|
704
|
+
return invalidRequestProblem({ expansions: badExpansions }, enumMessage('expansions', badExpansions, USER_EXPANSIONS));
|
|
705
|
+
}
|
|
706
|
+
// `tweet.fields` is accepted surface (PostFieldsParameter) but no expansion the twin models
|
|
707
|
+
// hydrates a post yet, so it validates nothing and shapes nothing —
|
|
708
|
+
// `xidentity.users_me.expansion_hydration` (todo) owns the honest gap.
|
|
709
|
+
|
|
710
|
+
const evaluate = (resources: readonly TwinResource[], spend: boolean) => {
|
|
711
|
+
const tokenRow = resources.find((row) => row.type === 'access_token' && row.id === bearer) as Row | undefined;
|
|
712
|
+
if (!tokenRow || tokenRow.revoked === true
|
|
713
|
+
|| (typeof tokenRow.expiresAt === 'number' && nowSeconds(req.occurredAt) >= tokenRow.expiresAt)) {
|
|
714
|
+
return { response: unauthorizedProblem() };
|
|
715
|
+
}
|
|
716
|
+
const held = parseScopeParam(String(tokenRow.scope));
|
|
717
|
+
const missing = USERS_ME_REQUIRED_SCOPES.filter((scope) => !held.includes(scope));
|
|
718
|
+
if (missing.length > 0) {
|
|
719
|
+
return { response: forbiddenProblem(`The access token does not carry the scope(s) required by this endpoint: ${missing.join(', ')}.`) };
|
|
720
|
+
}
|
|
721
|
+
const account = resources.find((row) => row.type === 'account' && row.id === String(tokenRow.sub)) as Row | undefined;
|
|
722
|
+
if (!account) return { response: unauthorizedProblem() };
|
|
723
|
+
const now = nowSeconds(req.occurredAt);
|
|
724
|
+
const windowId = `users_me:${account.id}`;
|
|
725
|
+
const rateRow = resources.find((row) => row.type === 'rate_window' && row.id === windowId) as Row | undefined;
|
|
726
|
+
const active = rateRow && typeof rateRow.windowStart === 'number' && now < rateRow.windowStart + RATE_WINDOW_SECONDS;
|
|
727
|
+
const windowStart = active ? Number(rateRow.windowStart) : now;
|
|
728
|
+
const before = active && typeof rateRow.used === 'number' ? rateRow.used : 0;
|
|
729
|
+
const reset = String(windowStart + RATE_WINDOW_SECONDS);
|
|
730
|
+
if (before >= USERS_ME_RATE_LIMIT) {
|
|
731
|
+
return { response: rateLimitExceeded({ 'x-rate-limit-limit': String(USERS_ME_RATE_LIMIT), 'x-rate-limit-remaining': '0', 'x-rate-limit-reset': reset }) };
|
|
732
|
+
}
|
|
733
|
+
const used = spend ? before + 1 : before;
|
|
734
|
+
const response: XResponse = {
|
|
735
|
+
status: 200,
|
|
736
|
+
body: { data: renderUser(account, fields, held) },
|
|
737
|
+
headers: {
|
|
738
|
+
...NOSTORE,
|
|
739
|
+
'content-type': 'application/json; charset=utf-8',
|
|
740
|
+
'x-rate-limit-limit': String(USERS_ME_RATE_LIMIT),
|
|
741
|
+
'x-rate-limit-remaining': String(Math.max(0, USERS_ME_RATE_LIMIT - used)),
|
|
742
|
+
'x-rate-limit-reset': reset,
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
return { response, windowId, windowStart, used };
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
if (req.readOnly) return evaluate(readAll(root), false).response;
|
|
749
|
+
const committed = await applyTwinWriteAtomic(
|
|
750
|
+
'xidentity',
|
|
751
|
+
(resources) => {
|
|
752
|
+
const result = evaluate(resources, true);
|
|
753
|
+
if (!result.windowId) return { kind: 'skip', value: result.response };
|
|
754
|
+
return {
|
|
755
|
+
kind: 'write',
|
|
756
|
+
value: result.response,
|
|
757
|
+
write: {
|
|
758
|
+
operation: 'rate_window.spend',
|
|
759
|
+
subjectType: 'rate_window',
|
|
760
|
+
subjectId: result.windowId,
|
|
761
|
+
fields: { windowStart: result.windowStart, used: result.used, rev: nextRevIn(resources, 'rate_window', result.windowId) },
|
|
762
|
+
...(req.occurredAt ? { occurredAt: req.occurredAt } : {}),
|
|
763
|
+
actor: { kind: 'system' },
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
},
|
|
767
|
+
root,
|
|
768
|
+
);
|
|
769
|
+
return committed.value;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Every VENDOR endpoint this twin claims to serve. The conformance check drives one real request
|
|
773
|
+
* per entry and grades the OUTCOME, so an entry here is a promise with teeth. Twin-only routes
|
|
774
|
+
* (`/_twin/*`) are deliberately absent — they are scaffolding, not vendor surface. */
|
|
775
|
+
export function xIdentityTwinSnapshot(): { implementedEndpoints: string[]; resourceTypes: readonly string[]; grantTypes: string[] } {
|
|
776
|
+
return {
|
|
777
|
+
implementedEndpoints: [
|
|
778
|
+
'GET /i/oauth2/authorize',
|
|
779
|
+
'POST /2/oauth2/token',
|
|
780
|
+
'POST /2/oauth2/revoke',
|
|
781
|
+
'GET /2/users/me',
|
|
782
|
+
],
|
|
783
|
+
resourceTypes: STORE_RESOURCE_TYPES,
|
|
784
|
+
grantTypes: ['authorization_code', 'refresh_token'],
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── twin-only control routes (NOT vendor surface — see the manifest) ────────────────────────────
|
|
789
|
+
|
|
790
|
+
async function twinControl(req: XIdentityRequest, path: string, form: URLSearchParams): Promise<XResponse | null> {
|
|
791
|
+
const root = req.root;
|
|
792
|
+
const opts = { root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) };
|
|
793
|
+
const json = () => {
|
|
794
|
+
try {
|
|
795
|
+
return JSON.parse(req.body ?? '{}') as Record<string, unknown>;
|
|
796
|
+
} catch {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
if (req.method === 'GET' && path === '/_twin/consent') {
|
|
801
|
+
const { query } = splitPath(req.path);
|
|
802
|
+
return renderConsent(req, query.get('auth_request') ?? '');
|
|
803
|
+
}
|
|
804
|
+
if (req.method === 'POST' && path === '/_twin/consent') return consentDecision(req, form);
|
|
805
|
+
if (req.method === 'POST' && path === '/_twin/clients') {
|
|
806
|
+
const b = json();
|
|
807
|
+
if (!b) return oauthError('invalid_request', 'A JSON body is required.', 400);
|
|
808
|
+
// `:` is the grant-id separator (`clientId:sub`), so a colon in a caller-supplied id could
|
|
809
|
+
// forge a composite collision (client `A:B`+sub `C` vs client `A`+sub `B:C`). Real X client
|
|
810
|
+
// ids are base64url (colon-free); the scaffolding refuses the character outright.
|
|
811
|
+
if (typeof b['client_id'] === 'string' && b['client_id'].includes(':')) {
|
|
812
|
+
return oauthError('invalid_request', 'client_id must not contain ":".', 400);
|
|
813
|
+
}
|
|
814
|
+
const clientId = typeof b['client_id'] === 'string' && b['client_id'] ? (b['client_id'] as string) : mintClientId(root, instantOf(req.occurredAt));
|
|
815
|
+
const clientType = b['client_type'] === 'public' ? 'public' : 'confidential';
|
|
816
|
+
const row = await writeAtomic(
|
|
817
|
+
'oauth_client',
|
|
818
|
+
clientId,
|
|
819
|
+
'oauth_client.create',
|
|
820
|
+
() => ({
|
|
821
|
+
name: typeof b['name'] === 'string' ? b['name'] : 'Twin App',
|
|
822
|
+
secret: clientType === 'public' ? null : typeof b['client_secret'] === 'string' ? b['client_secret'] : `twin-secret-${clientId.slice(0, 16)}`,
|
|
823
|
+
clientType,
|
|
824
|
+
redirectUris: Array.isArray(b['redirect_uris']) ? b['redirect_uris'] : [],
|
|
825
|
+
}),
|
|
826
|
+
opts,
|
|
827
|
+
);
|
|
828
|
+
return { status: 200, body: row };
|
|
829
|
+
}
|
|
830
|
+
if (req.method === 'POST' && path === '/_twin/accounts') {
|
|
831
|
+
const b = json();
|
|
832
|
+
if (!b || typeof b['id'] !== 'string' || !b['id']) return oauthError('invalid_request', 'id is required', 400);
|
|
833
|
+
if (b['id'].includes(':')) return oauthError('invalid_request', 'id must not contain ":".', 400);
|
|
834
|
+
const id = b['id'] as string;
|
|
835
|
+
const row = await writeAtomic(
|
|
836
|
+
'account',
|
|
837
|
+
id,
|
|
838
|
+
'account.create',
|
|
839
|
+
() => ({
|
|
840
|
+
username: typeof b['username'] === 'string' ? b['username'] : `user_${id}`,
|
|
841
|
+
name: typeof b['name'] === 'string' ? b['name'] : 'Twin Persona',
|
|
842
|
+
...(typeof b['created_at'] === 'string' ? { createdAt: b['created_at'] } : {}),
|
|
843
|
+
...(typeof b['description'] === 'string' ? { description: b['description'] } : {}),
|
|
844
|
+
...(typeof b['location'] === 'string' ? { location: b['location'] } : {}),
|
|
845
|
+
...(typeof b['profile_image_url'] === 'string' ? { profileImageUrl: b['profile_image_url'] } : {}),
|
|
846
|
+
...(typeof b['protected'] === 'boolean' ? { protectedAccount: b['protected'] } : {}),
|
|
847
|
+
...(typeof b['verified'] === 'boolean' ? { verified: b['verified'] } : {}),
|
|
848
|
+
...(typeof b['verified_type'] === 'string' ? { verifiedType: b['verified_type'] } : {}),
|
|
849
|
+
...(b['public_metrics'] && typeof b['public_metrics'] === 'object' ? { publicMetrics: b['public_metrics'] } : {}),
|
|
850
|
+
...(typeof b['url'] === 'string' ? { url: b['url'] } : {}),
|
|
851
|
+
...(typeof b['confirmed_email'] === 'string' ? { confirmedEmail: b['confirmed_email'] } : {}),
|
|
852
|
+
}),
|
|
853
|
+
opts,
|
|
854
|
+
);
|
|
855
|
+
return { status: 200, body: row };
|
|
856
|
+
}
|
|
857
|
+
if (req.method === 'POST' && path === '/_twin/session') {
|
|
858
|
+
const b = json();
|
|
859
|
+
if (!b || typeof b['account_id'] !== 'string' || !b['account_id']) return oauthError('invalid_request', 'account_id is required', 400);
|
|
860
|
+
if (!readOne(root, 'account', b['account_id'] as string)) return oauthError('invalid_request', 'Unknown account.', 400);
|
|
861
|
+
const row = await writeAtomic('session', 'current', 'session.switch', () => ({ accountId: b['account_id'] }), opts);
|
|
862
|
+
return { status: 200, body: row };
|
|
863
|
+
}
|
|
864
|
+
if (req.method === 'POST' && path === '/_twin/rate_limit') {
|
|
865
|
+
// Arm a deterministic rate state (the figma armed-429 precedent): set the used count for a
|
|
866
|
+
// user's users_me window so a verify can prove the 429 without 75 real spends.
|
|
867
|
+
const b = json();
|
|
868
|
+
if (!b || typeof b['account_id'] !== 'string' || typeof b['used'] !== 'number') {
|
|
869
|
+
return oauthError('invalid_request', 'account_id and used are required', 400);
|
|
870
|
+
}
|
|
871
|
+
const windowId = `users_me:${b['account_id']}`;
|
|
872
|
+
const row = await writeAtomic(
|
|
873
|
+
'rate_window',
|
|
874
|
+
windowId,
|
|
875
|
+
'rate_window.arm',
|
|
876
|
+
() => ({ windowStart: nowSeconds(req.occurredAt), used: b['used'] }),
|
|
877
|
+
opts,
|
|
878
|
+
);
|
|
879
|
+
return { status: 200, body: row };
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── public entry + router ───────────────────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
export async function handleXIdentityTwinRequest(req: XIdentityRequest): Promise<XResponse> {
|
|
887
|
+
try {
|
|
888
|
+
return await routeXIdentityTwinRequest(req);
|
|
889
|
+
} catch (e) {
|
|
890
|
+
// MALFORMED-REQUEST GUARD (route boundary), the gemini/googleoauth precedent: a residual
|
|
891
|
+
// TypeError (a wrong-typed field the router walked) or URIError (bad percent-encoding) becomes
|
|
892
|
+
// the vendor's own 400 envelope instead of escaping as a non-vendor 500 — in the error FAMILY
|
|
893
|
+
// of the endpoint that failed: the /2 resource surface speaks problem envelopes, the OAuth
|
|
894
|
+
// endpoints speak the flat RFC body (§9 round one: one family for both misdescribed the /2
|
|
895
|
+
// contract). Every OTHER error type still propagates loudly rather than being masked as a
|
|
896
|
+
// caller mistake.
|
|
897
|
+
if (e instanceof TypeError || e instanceof URIError) {
|
|
898
|
+
// The flat OAuth body is the right family for every REACHABLE throw site: the only decode
|
|
899
|
+
// that can raise these lives in the token/revoke client authentication (the Basic
|
|
900
|
+
// percent-decode), and the /2/users read path walks nothing that throws them. A /2-family
|
|
901
|
+
// problem-envelope branch here was removed as unfalsifiable (§9 round two) — if a /2 path
|
|
902
|
+
// ever gains a throwing walk, add the branch WITH the test that reddens on its removal.
|
|
903
|
+
return oauthError('invalid_request', 'Request contains an invalid argument.', 400);
|
|
904
|
+
}
|
|
905
|
+
const { path } = splitPath(typeof req.path === 'string' ? req.path : '/');
|
|
906
|
+
if (path.startsWith('/2/') && path !== '/2/oauth2/token' && path !== '/2/oauth2/revoke') {
|
|
907
|
+
return internalServerProblem();
|
|
908
|
+
}
|
|
909
|
+
if (path === AUTHORIZE_PATH || path.startsWith('/_twin/consent')) {
|
|
910
|
+
return errorPage(req, { status: 500, code: 'server_error', detail: 'The authorization server could not complete the request.' });
|
|
911
|
+
}
|
|
912
|
+
return oauthError('server_error', 'The authorization server could not complete the request.', 500);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function routeXIdentityTwinRequest(req: XIdentityRequest): Promise<XResponse> {
|
|
917
|
+
const method = req.method.toUpperCase();
|
|
918
|
+
const { path, query } = splitPath(req.path);
|
|
919
|
+
const form = new URLSearchParams(method === 'GET' || method === 'HEAD' ? '' : (req.body ?? ''));
|
|
920
|
+
|
|
921
|
+
// D3: a read-only twin cannot mint credentials or settle consent. GET /2/users/me stays served
|
|
922
|
+
// (a read), with rate accounting suspended. The refusal is an OAuth error body, so a client sees
|
|
923
|
+
// a protocol error rather than an HTML surprise.
|
|
924
|
+
const writes = path.startsWith('/_twin/') || path === '/2/oauth2/token' || path === '/2/oauth2/revoke';
|
|
925
|
+
if (req.readOnly && writes) {
|
|
926
|
+
return oauthError('temporarily_unavailable', 'this twin was started read-only; omit readOnly to accept writes', 405);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Seeding is idempotent and cheap; doing it at the router boundary means every entry point sees
|
|
930
|
+
// a world with clients, personas and a signed-in session — exactly what a browser at x.com has.
|
|
931
|
+
if (!req.readOnly) await ensureSeed({ root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), ...((req.callbackOrigin ?? req.origin) ? { origin: req.callbackOrigin ?? req.origin } : {}) });
|
|
932
|
+
|
|
933
|
+
const control = await twinControl(req, path, form);
|
|
934
|
+
if (control) return control;
|
|
935
|
+
|
|
936
|
+
if (method === 'GET' && path === AUTHORIZE_PATH) return authorize(req, query);
|
|
937
|
+
if (method === 'POST' && path === '/2/oauth2/token') return token(req);
|
|
938
|
+
if (method === 'POST' && path === '/2/oauth2/revoke') return revoke(req);
|
|
939
|
+
if (method === 'GET' && path === '/2/users/me') return usersMe(req, query);
|
|
940
|
+
|
|
941
|
+
// An operation the twin does not model fails like the vendor — never a fake success. The /2
|
|
942
|
+
// problem envelope is the not-found shape; unclaimed paths on the twinned hosts refuse loudly.
|
|
943
|
+
return notFoundProblem();
|
|
944
|
+
}
|