@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,905 @@
|
|
|
1
|
+
// X (Twitter) identity capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target),
|
|
2
|
+
// authored top-down from the vendor's own artefacts, all fetched 2026-08-21:
|
|
3
|
+
// • the X API v2 OpenAPI document (https://api.x.com/2/openapi.json, version 2.167): the
|
|
4
|
+
// /2/users/me operation (parameters, security scopes, response schema), the Problem error
|
|
5
|
+
// family, and the 24-scope OAuth2UserToken catalog with X's consent descriptions;
|
|
6
|
+
// • docs.x.com OAuth 2.0 guides (authorization-code + user-access-token): the authorize URL and
|
|
7
|
+
// its parameters, the token/refresh/revoke requests, confidential-vs-public client rules;
|
|
8
|
+
// • docs.x.com rate-limit pages: the x-rate-limit-* headers, the 15-minute window, 75/15min per
|
|
9
|
+
// user for /2/users/me, 429 + legacy code 88;
|
|
10
|
+
// • the official SDK sources (@xdevplatform/xdk `oauth2_auth.ts`, twitter-api-typescript-sdk
|
|
11
|
+
// `OAuth2User.ts`): the token/revoke response shapes the docs never show as JSON, the
|
|
12
|
+
// authorize-URL builders (state required, PKCE always sent), the Basic-auth construction.
|
|
13
|
+
// NOT from what this twin has built. `verify()` (required to count as done) is ground truth; every
|
|
14
|
+
// `expected:'done'` is genuinely claimed, so a broken one shows as a regression.
|
|
15
|
+
//
|
|
16
|
+
// GRANULARITY: one capability per PROTOCOL BEHAVIOUR (a parameter honoured, an error produced, a
|
|
17
|
+
// field selected), the googleoauth precedent — this surface is four endpoints with dozens of
|
|
18
|
+
// behaviours, so per-endpoint entries would have been a denominator of four and a lie.
|
|
19
|
+
//
|
|
20
|
+
// EVIDENCE BOUNDARIES: a `done` whose behaviour is derived from RFC 6749/7009 or widely-reported
|
|
21
|
+
// wire captures rather than a fetched official artefact says so in its title/comment, and a
|
|
22
|
+
// sibling `todo` pins the live capture. The DENOMINATOR also includes X identity surface the twin
|
|
23
|
+
// does not model yet (OAuth 1.0a three-legged, the v1.1 app-only bearer flow, unmodelled user
|
|
24
|
+
// fields) as honest todos.
|
|
25
|
+
//
|
|
26
|
+
// TWIN-ONLY ROUTES ARE NOT COUNTED. `/_twin/consent`, `/_twin/clients`, `/_twin/accounts`,
|
|
27
|
+
// `/_twin/session` and `/_twin/rate_limit` are scaffolding (X's authorize form posts to an
|
|
28
|
+
// undocumented internal endpoint; no API creates an X App or switches the x.com session), so they
|
|
29
|
+
// are absent from this denominator exactly as ADDING_A_TWIN.md §6 requires.
|
|
30
|
+
//
|
|
31
|
+
// TWO DELIBERATE GRANULARITY CHOICES a reviewer will notice (§9 round one raised both):
|
|
32
|
+
// • PIN-todos ("PIN the live …") sit in the denominator alongside build-todos. They are not
|
|
33
|
+
// padding: each names a BEHAVIOUR the twin serves whose vendor truth is unverified, and
|
|
34
|
+
// closing one is real work against the live vendor (the googleoauth precedent:
|
|
35
|
+
// `googleoauth.tokeninfo.id_token`, `errors.error_page_http_status`). Coverage % only gets
|
|
36
|
+
// WORSE from carrying them.
|
|
37
|
+
// • The field-selection MECHANICS are one `done` (`users_me.user_fields_selection` — one
|
|
38
|
+
// behaviour: the enum parameter selects fields) while each UNMODELLED field is its own todo:
|
|
39
|
+
// a todo must be individually buildable and closeable, and "model entities" is a different
|
|
40
|
+
// work item from "model withheld". The asymmetry is per-WORK-ITEM granularity, not
|
|
41
|
+
// per-column inflation.
|
|
42
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
43
|
+
import { tmpdir } from 'node:os';
|
|
44
|
+
import { join } from 'node:path';
|
|
45
|
+
import { createElement } from 'react';
|
|
46
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
47
|
+
import { checkCapabilities, uiDataCoupled, verifyBoundary } from '@volter/world-tooling';
|
|
48
|
+
import { ConsentPage, ErrorPage } from "../client/xidentity-consent.js";
|
|
49
|
+
import { CONSENT_CLIENT_JS, xIdentityConsentState } from "./xidentity-consent-ui.js";
|
|
50
|
+
import { checkXIdentityConformance } from "./xidentity-conformance.js";
|
|
51
|
+
import { XIdentityBudgetError, XIDENTITY_BUDGET_BURST_CEILING } from "./xidentity-budget.js";
|
|
52
|
+
import { liveXIdentityExecute, mapUsersMeAccount, pullXIdentity, pushPendingXIdentityActions, syncXIdentityFromReal, } from "./xidentity-connector.js";
|
|
53
|
+
import { pkceS256 } from "./xidentity-pkce.js";
|
|
54
|
+
import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, DEFAULT_PUBLIC_CLIENT_ID, defaultRedirectUris } from "./xidentity-store.js";
|
|
55
|
+
import { handleXIdentityTwinRequest } from "./xidentity-twin.js";
|
|
56
|
+
const AT = '2026-02-01T00:00:00.000Z';
|
|
57
|
+
const atPlus = (seconds) => new Date(Date.parse(AT) + seconds * 1000).toISOString();
|
|
58
|
+
/** The origin this harness's world serves the twin at. The seeded demo clients' callbacks are
|
|
59
|
+
* DERIVED from it (runtime contract R7: the port belongs to the caller's world, never to the
|
|
60
|
+
* twin's source), so the probes below name no port of their own. */
|
|
61
|
+
const DEMO_ORIGIN = 'http://localhost:3000';
|
|
62
|
+
const REDIRECT_URIS = defaultRedirectUris(DEMO_ORIGIN);
|
|
63
|
+
const REDIRECT = REDIRECT_URIS[0];
|
|
64
|
+
const ADA = DEFAULT_ACCOUNTS[0];
|
|
65
|
+
const GRACE = DEFAULT_ACCOUNTS[1];
|
|
66
|
+
const SCOPE = 'tweet.read users.read offline.access';
|
|
67
|
+
const VERIFIER = 'cap-verifier-0123456789-0123456789-0123456789-012';
|
|
68
|
+
const BASIC = `Basic ${Buffer.from(`${DEFAULT_CLIENT_ID}:${DEFAULT_CLIENT_SECRET}`).toString('base64')}`;
|
|
69
|
+
async function withRoot(steps) {
|
|
70
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-cap-'));
|
|
71
|
+
const h = ((s) => handleXIdentityTwinRequest({
|
|
72
|
+
method: s.m,
|
|
73
|
+
path: s.p,
|
|
74
|
+
...(s.b === undefined ? {} : { body: s.b }),
|
|
75
|
+
...(s.h ? { headers: s.h } : {}),
|
|
76
|
+
root,
|
|
77
|
+
origin: DEMO_ORIGIN,
|
|
78
|
+
occurredAt: s.at ?? AT,
|
|
79
|
+
}));
|
|
80
|
+
h.root = root;
|
|
81
|
+
try {
|
|
82
|
+
return await verifyBoundary('xidentity.withRoot', () => steps(h));
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rmSync(root, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const ok = (r) => r.status >= 200 && r.status < 300;
|
|
89
|
+
const body = (r) => r.body;
|
|
90
|
+
const html = (r) => String(r.body);
|
|
91
|
+
const loc = (r) => r.headers?.['location'] ?? '';
|
|
92
|
+
const qp = (r, key) => {
|
|
93
|
+
try {
|
|
94
|
+
return new URL(loc(r)).searchParams.get(key);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const form = (params) => new URLSearchParams(params).toString();
|
|
101
|
+
const decoded = (token) => Buffer.from(String(token ?? ''), 'base64url').toString('utf8');
|
|
102
|
+
/** Build an authorize URL from parameters, so a verify can vary exactly one of them. `undefined`
|
|
103
|
+
* REMOVES a base parameter. */
|
|
104
|
+
function authUrl(params = {}) {
|
|
105
|
+
const q = new URLSearchParams();
|
|
106
|
+
const base = {
|
|
107
|
+
response_type: 'code',
|
|
108
|
+
client_id: DEFAULT_CLIENT_ID,
|
|
109
|
+
redirect_uri: REDIRECT,
|
|
110
|
+
scope: SCOPE,
|
|
111
|
+
state: 'cap-state',
|
|
112
|
+
code_challenge: pkceS256(VERIFIER),
|
|
113
|
+
code_challenge_method: 'S256',
|
|
114
|
+
...params,
|
|
115
|
+
};
|
|
116
|
+
for (const [k, v] of Object.entries(base))
|
|
117
|
+
if (v !== undefined)
|
|
118
|
+
q.set(k, v);
|
|
119
|
+
return `/i/oauth2/authorize?${q.toString()}`;
|
|
120
|
+
}
|
|
121
|
+
const AUTH_REQUEST_RE = /name="auth_request" value="([^"]+)"/;
|
|
122
|
+
/** Drive the REAL browser legs: authorization request → authorize screen → Authorize/Cancel →
|
|
123
|
+
* the 302. Everything a verify needs from the round trip comes back. */
|
|
124
|
+
async function consentFlow(h, opts = {}) {
|
|
125
|
+
const auth = await h({ m: 'GET', p: authUrl(opts.params ?? {}), ...(opts.at ? { at: opts.at } : {}) });
|
|
126
|
+
const requestId = AUTH_REQUEST_RE.exec(html(auth))?.[1] ?? '';
|
|
127
|
+
const redirect = await h({
|
|
128
|
+
m: 'POST',
|
|
129
|
+
p: '/_twin/consent',
|
|
130
|
+
b: form({ auth_request: requestId, decision: opts.decision ?? 'allow' }),
|
|
131
|
+
...(opts.at ? { at: opts.at } : {}),
|
|
132
|
+
});
|
|
133
|
+
return { auth, requestId, redirect, code: qp(redirect, 'code'), state: qp(redirect, 'state') };
|
|
134
|
+
}
|
|
135
|
+
/** The full round trip, ending in redeemed tokens (confidential client + Basic by default). */
|
|
136
|
+
async function fullFlow(h, opts = {}) {
|
|
137
|
+
const flow = await consentFlow(h, opts);
|
|
138
|
+
if (!flow.code)
|
|
139
|
+
return { code: null, tokens: {}, status: 0, redirect: flow.redirect };
|
|
140
|
+
const clientId = opts.clientId ?? DEFAULT_CLIENT_ID;
|
|
141
|
+
const basic = opts.basic === null ? undefined : (opts.basic ?? BASIC);
|
|
142
|
+
const res = await h({
|
|
143
|
+
m: 'POST',
|
|
144
|
+
p: '/2/oauth2/token',
|
|
145
|
+
b: form({
|
|
146
|
+
grant_type: 'authorization_code',
|
|
147
|
+
code: flow.code,
|
|
148
|
+
client_id: clientId,
|
|
149
|
+
redirect_uri: REDIRECT,
|
|
150
|
+
code_verifier: opts.verifier ?? VERIFIER,
|
|
151
|
+
}),
|
|
152
|
+
...(basic ? { h: { authorization: basic } } : {}),
|
|
153
|
+
...(opts.tokenAt ?? opts.at ? { at: opts.tokenAt ?? opts.at } : {}),
|
|
154
|
+
});
|
|
155
|
+
return { code: flow.code, tokens: body(res), status: res.status, redirect: flow.redirect };
|
|
156
|
+
}
|
|
157
|
+
// ── shorthands ──
|
|
158
|
+
const done = (id, area, title, dimension, tier, verify) => ({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
159
|
+
const todo = (id, area, title, dimension, tier) => ({ id, area, title, dimension, tier, expected: 'todo' });
|
|
160
|
+
// ── the connector's offline fake: a REAL-SHAPED X reply (the OpenAPI example user), never an
|
|
161
|
+
// invented one ──
|
|
162
|
+
const FAKE_USERS_ME = {
|
|
163
|
+
data: {
|
|
164
|
+
id: '2244994945',
|
|
165
|
+
name: 'X Developers',
|
|
166
|
+
username: 'XDevelopers',
|
|
167
|
+
created_at: '2013-12-14T04:35:55.000Z',
|
|
168
|
+
description: 'The voice of the X developer community',
|
|
169
|
+
profile_image_url: 'https://pbs.twimg.com/profile_images/xdevs_normal.png',
|
|
170
|
+
protected: false,
|
|
171
|
+
public_metrics: { followers_count: 583423, following_count: 2048, tweet_count: 14052, listed_count: 1672, like_count: 100, media_count: 300 },
|
|
172
|
+
verified: true,
|
|
173
|
+
verified_type: 'business',
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
const fakeExecute = async (_method, path) => {
|
|
177
|
+
if (path.startsWith('/2/users/me'))
|
|
178
|
+
return structuredClone(FAKE_USERS_ME);
|
|
179
|
+
return {};
|
|
180
|
+
};
|
|
181
|
+
// ── the manifest ──────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
export const XIDENTITY_CAPABILITIES = [
|
|
183
|
+
// ═══ authorize — GET x.com/i/oauth2/authorize ═════════════════════════════════════════════
|
|
184
|
+
done('xidentity.authorize.renders_consent', 'authorize', 'A valid authorization request renders the authorize screen (HTML, 200) with a settleable request handle', 'api', 'core', () => withRoot(async (h) => {
|
|
185
|
+
const r = await h({ m: 'GET', p: authUrl() });
|
|
186
|
+
return r.status === 200 && /ar_[0-9a-f]{32}/.test(html(r)) && html(r).includes('Authorize app');
|
|
187
|
+
})),
|
|
188
|
+
done('xidentity.authorize.requires_client_id', 'authorize', 'A missing client_id renders the error page (no redirect — there is no trusted callback yet)', 'api', 'core', () => withRoot(async (h) => {
|
|
189
|
+
const r = await h({ m: 'GET', p: authUrl({ client_id: undefined }) });
|
|
190
|
+
return r.status === 400 && html(r).includes('client_id') && !loc(r);
|
|
191
|
+
})),
|
|
192
|
+
done('xidentity.authorize.unknown_client', 'authorize', 'An unknown client_id renders the error page naming invalid_client (401), never a redirect', 'api', 'core', () => withRoot(async (h) => {
|
|
193
|
+
const r = await h({ m: 'GET', p: authUrl({ client_id: 'bm90LWEtcmVhbC1jbGllbnQ6MTpjaQ' }) });
|
|
194
|
+
return r.status === 401 && html(r).includes('invalid_client') && !loc(r);
|
|
195
|
+
})),
|
|
196
|
+
done('xidentity.authorize.requires_redirect_uri', 'authorize', 'A missing redirect_uri renders the error page (no redirect)', 'api', 'core', () => withRoot(async (h) => {
|
|
197
|
+
const r = await h({ m: 'GET', p: authUrl({ redirect_uri: undefined }) });
|
|
198
|
+
return r.status === 400 && html(r).includes('redirect_uri') && !loc(r);
|
|
199
|
+
})),
|
|
200
|
+
done('xidentity.authorize.redirect_uri_exact_match', 'authorize', 'Callback URLs are compared EXACTLY (docs: "must correspond to one of the Callback URLs… exact match validation") — a trailing-slash variant is refused with the error page, the registered one passes', 'api', 'core', () => withRoot(async (h) => {
|
|
201
|
+
const near = await h({ m: 'GET', p: authUrl({ redirect_uri: `${REDIRECT}/` }) });
|
|
202
|
+
const exact = await h({ m: 'GET', p: authUrl() });
|
|
203
|
+
return near.status === 400 && !loc(near) && html(near).includes('redirect_uri') && exact.status === 200 && html(exact).includes('Authorize app');
|
|
204
|
+
})),
|
|
205
|
+
done('xidentity.authorize.fragment_redirect_refused', 'authorize', 'A redirect_uri carrying a fragment is refused outright EVEN WHEN REGISTERED with one (RFC 6749 §3.1.2; a fragment never reaches the server)', 'api', 'niche', () => withRoot(async (h) => {
|
|
206
|
+
// The discriminating case (§9 round two): an unregistered fragment URI is refused by plain
|
|
207
|
+
// exact-match anyway, so the guard is only provable against a client REGISTERED with the
|
|
208
|
+
// fragment-bearing callback.
|
|
209
|
+
const fragClient = 'RnJhZ0NsaWVudDAxMjM0NTY3ODk';
|
|
210
|
+
const fragUri = 'http://localhost:3000/cb#frag';
|
|
211
|
+
await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_id: fragClient, client_secret: 's', name: 'Frag App', redirect_uris: [fragUri] }) });
|
|
212
|
+
const r = await h({ m: 'GET', p: authUrl({ client_id: fragClient, redirect_uri: fragUri }) });
|
|
213
|
+
return r.status === 400 && !loc(r);
|
|
214
|
+
})),
|
|
215
|
+
done('xidentity.authorize.requires_response_type_code', 'authorize', 'response_type is required and must be `code` — the only value X\'s OAuth 2.0 documents; anything else bounces unsupported_response_type to the validated callback', 'api', 'core', () => withRoot(async (h) => {
|
|
216
|
+
const missing = await h({ m: 'GET', p: authUrl({ response_type: undefined }) });
|
|
217
|
+
const wrong = await h({ m: 'GET', p: authUrl({ response_type: 'token' }) });
|
|
218
|
+
return missing.status === 302 && loc(missing).startsWith(REDIRECT) && qp(missing, 'error') === 'invalid_request'
|
|
219
|
+
&& wrong.status === 302 && qp(wrong, 'error') === 'unsupported_response_type' && qp(wrong, 'state') === 'cap-state';
|
|
220
|
+
})),
|
|
221
|
+
done('xidentity.authorize.requires_scope', 'authorize', 'scope is required (the docs\' parameter table); a request without one is refused', 'api', 'core', () => withRoot(async (h) => {
|
|
222
|
+
const r = await h({ m: 'GET', p: authUrl({ scope: undefined }) });
|
|
223
|
+
return r.status === 302 && qp(r, 'error') === 'invalid_request' && String(qp(r, 'error_description')).includes('scope');
|
|
224
|
+
})),
|
|
225
|
+
done('xidentity.authorize.requires_state', 'authorize', 'state is required as a PARAMETER (the docs\' table; the legacy official SDK makes it non-optional) — and the EMPTY string is accepted, because the current official SDK emits state:"" when its caller omits one', 'api', 'core', () => withRoot(async (h) => {
|
|
226
|
+
// §9 round one: the current xdk's getAuthorizationUrl(state?) emits `state: state || ""`,
|
|
227
|
+
// so an EMPTY state parameter is what the vendor's own client sends — refusing it would
|
|
228
|
+
// refuse the official SDK. Absent-vs-empty is decided deliberately: absent → refused
|
|
229
|
+
// (RFC-derived, pinned by xidentity.authorize.error_channel), empty → the screen.
|
|
230
|
+
const missing = await h({ m: 'GET', p: authUrl({ state: undefined }) });
|
|
231
|
+
const empty = await h({ m: 'GET', p: authUrl({ state: '' }) });
|
|
232
|
+
return missing.status === 302 && qp(missing, 'error') === 'invalid_request' && String(qp(missing, 'error_description')).includes('state')
|
|
233
|
+
&& empty.status === 200 && html(empty).includes('Authorize app');
|
|
234
|
+
})),
|
|
235
|
+
done('xidentity.authorize.state_max_500', 'authorize', 'state is capped at 500 characters (docs: "up to 500 characters"); 500 passes, 501 is refused', 'api', 'niche', () => withRoot(async (h) => {
|
|
236
|
+
const atCap = await h({ m: 'GET', p: authUrl({ state: 'x'.repeat(500) }) });
|
|
237
|
+
const over = await h({ m: 'GET', p: authUrl({ state: 'x'.repeat(501) }) });
|
|
238
|
+
return atCap.status === 200 && over.status === 302 && qp(over, 'error') === 'invalid_request';
|
|
239
|
+
})),
|
|
240
|
+
done('xidentity.authorize.state_roundtrip', 'authorize', 'The caller\'s state is echoed VERBATIM on the success redirect (the docs\' callback example carries it first)', 'api', 'core', () => withRoot(async (h) => {
|
|
241
|
+
const state = 'st~ate-With_Odd.Chars123';
|
|
242
|
+
const flow = await consentFlow(h, { params: { state } });
|
|
243
|
+
return flow.redirect.status === 302 && flow.state === state && !!flow.code;
|
|
244
|
+
})),
|
|
245
|
+
done('xidentity.authorize.requires_pkce', 'authorize', 'PKCE is REQUIRED: an authorization request without code_challenge is refused (the docs\' parameter table; the LEGACY official SDK always sends one — the current xdk only when setPkceParameters was called, so the docs table is the load-bearing evidence)', 'api', 'core', () => withRoot(async (h) => {
|
|
246
|
+
const r = await h({ m: 'GET', p: authUrl({ code_challenge: undefined, code_challenge_method: undefined }) });
|
|
247
|
+
return r.status === 302 && qp(r, 'error') === 'invalid_request' && String(qp(r, 'error_description')).includes('code_challenge');
|
|
248
|
+
})),
|
|
249
|
+
done('xidentity.authorize.challenge_method_values', 'authorize', 'code_challenge_method accepts the two documented values (S256, plain) and refuses anything else', 'api', 'core', () => withRoot(async (h) => {
|
|
250
|
+
const bad = await h({ m: 'GET', p: authUrl({ code_challenge_method: 'S512' }) });
|
|
251
|
+
const plain = await h({ m: 'GET', p: authUrl({ code_challenge: 'plain-challenge-value', code_challenge_method: 'plain' }) });
|
|
252
|
+
return bad.status === 302 && qp(bad, 'error') === 'invalid_request' && plain.status === 200;
|
|
253
|
+
})),
|
|
254
|
+
done('xidentity.authorize.deny_access_denied', 'authorize', 'Cancelling bounces error=access_denied to the callback with the state echoed, and NO code', 'api', 'core', () => withRoot(async (h) => {
|
|
255
|
+
const flow = await consentFlow(h, { decision: 'deny' });
|
|
256
|
+
return flow.redirect.status === 302 && qp(flow.redirect, 'error') === 'access_denied' && flow.state === 'cap-state' && flow.code === null;
|
|
257
|
+
})),
|
|
258
|
+
done('xidentity.authorize.callback_params_exact', 'authorize', 'The success redirect carries EXACTLY state and code — the docs\' callback example (`?state=state&code=…`) has no other parameter, and the twin invents none', 'api', 'common', () => withRoot(async (h) => {
|
|
259
|
+
const flow = await consentFlow(h);
|
|
260
|
+
if (flow.redirect.status !== 302)
|
|
261
|
+
return false;
|
|
262
|
+
const keys = [...new URL(loc(flow.redirect)).searchParams.keys()].sort();
|
|
263
|
+
return keys.length === 2 && keys[0] === 'code' && keys[1] === 'state';
|
|
264
|
+
})),
|
|
265
|
+
done('xidentity.authorize.request_single_use', 'authorize', 'A settled authorization request cannot be settled twice (the browser cannot replay a decision)', 'api', 'common', () => withRoot(async (h) => {
|
|
266
|
+
const flow = await consentFlow(h);
|
|
267
|
+
const again = await h({ m: 'POST', p: '/_twin/consent', b: form({ auth_request: flow.requestId, decision: 'allow' }) });
|
|
268
|
+
return !!flow.code && again.status === 400 && body(again)['error'] === 'invalid_request';
|
|
269
|
+
})),
|
|
270
|
+
todo('xidentity.authorize.challenge_method_case', 'authorize', 'PIN the live case tolerance for code_challenge_method: the docs write S256; the legacy official SDK emits lowercase s256 (the twin accepts both, unpinned)', 'api', 'niche'),
|
|
271
|
+
todo('xidentity.authorize.challenge_method_default', 'authorize', 'PIN the live default when code_challenge_method is omitted (the twin follows RFC 7636\'s `plain` default, unpinned)', 'api', 'niche'),
|
|
272
|
+
todo('xidentity.authorize.error_channel', 'authorize', 'PIN which authorize failures X renders as a page vs bounces to the callback (the twin follows RFC 6749 §4.1.2.1 after the redirect_uri boundary, unpinned)', 'api', 'common'),
|
|
273
|
+
todo('xidentity.authorize.error_page_fidelity', 'authorize', 'PIN x.com\'s live authorize error page (DOM, wording, HTTP status) — the twin renders a plain machine-readable card, unpinned', 'api', 'niche'),
|
|
274
|
+
todo('xidentity.authorize.openapi_authorize_host', 'authorize', 'RESOLVE the source discrepancy: the v2 OpenAPI names api.x.com/2/oauth2/authorize as authorizationUrl while docs and both official SDKs use x.com/i/oauth2/authorize (the twin serves the documented browser URL only)', 'api', 'niche'),
|
|
275
|
+
// ═══ consent semantics ════════════════════════════════════════════════════════════════════
|
|
276
|
+
done('xidentity.consent.all_or_nothing', 'consent', 'X\'s consent is ALL-OR-NOTHING (two buttons, no per-scope checkboxes): the grant and the token carry the FULL requested scope set', 'api', 'core', () => withRoot(async (h) => {
|
|
277
|
+
const { tokens, status } = await fullFlow(h);
|
|
278
|
+
return status === 200 && tokens['scope'] === SCOPE;
|
|
279
|
+
})),
|
|
280
|
+
done('xidentity.consent.session_account_consents', 'consent', 'The consent is made BY the x.com session\'s signed-in account — switching the session switches whose identity the flow yields', 'api', 'common', () => withRoot(async (h) => {
|
|
281
|
+
await h({ m: 'POST', p: '/_twin/session', b: JSON.stringify({ account_id: GRACE.id }) });
|
|
282
|
+
const { tokens, status } = await fullFlow(h);
|
|
283
|
+
if (status !== 200)
|
|
284
|
+
return false;
|
|
285
|
+
const me = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
286
|
+
return ok(me) && body(me)['data']?.id === GRACE.id && body(me)['data']?.username === GRACE.username;
|
|
287
|
+
})),
|
|
288
|
+
todo('xidentity.consent.regrant_replaces', 'consent', 'PIN whether a re-authorization REPLACES or UNIONS the client\'s prior grant (the twin replaces — the screen showed the new set — unpinned)', 'api', 'niche'),
|
|
289
|
+
todo('xidentity.consent.offline_access_wording', 'consent', 'PIN the consent screen\'s live wording for offline.access (the OpenAPI description is developer-facing; the twin shows the widely-reported "Stay connected…" line, unpinned)', 'ui', 'niche'),
|
|
290
|
+
// ═══ token — POST api.x.com/2/oauth2/token (authorization_code) ═══════════════════════════
|
|
291
|
+
done('xidentity.token.code_exchange', 'token', 'A minted code redeems for the SDK-shaped token body: token_type bearer, expires_in 7200 (docs: 2-hour default), the granted scope, an X-shaped opaque access token', 'api', 'core', () => withRoot(async (h) => {
|
|
292
|
+
const { tokens, status } = await fullFlow(h);
|
|
293
|
+
return status === 200
|
|
294
|
+
&& tokens['token_type'] === 'bearer'
|
|
295
|
+
&& tokens['expires_in'] === 7200
|
|
296
|
+
&& tokens['scope'] === SCOPE
|
|
297
|
+
&& decoded(tokens['access_token']).includes(':at:');
|
|
298
|
+
})),
|
|
299
|
+
done('xidentity.token.pkce_s256', 'token', 'S256 PKCE verifies with real SHA-256: the right verifier redeems, a wrong one is refused with the single indistinguishable bad-code answer', 'api', 'core', () => withRoot(async (h) => {
|
|
300
|
+
const good = await fullFlow(h);
|
|
301
|
+
const bad = await fullFlow(h, { verifier: 'wrong-verifier-0123456789-0123456789-01234567' });
|
|
302
|
+
return good.status === 200 && bad.status === 400 && bad.tokens['error'] === 'invalid_request';
|
|
303
|
+
})),
|
|
304
|
+
done('xidentity.token.pkce_plain', 'token', 'plain PKCE compares the verifier literally to the challenge', 'api', 'common', () => withRoot(async (h) => {
|
|
305
|
+
const r = await fullFlow(h, { params: { code_challenge: 'the-plain-secret', code_challenge_method: 'plain' }, verifier: 'the-plain-secret' });
|
|
306
|
+
const bad = await fullFlow(h, { params: { code_challenge: 'the-plain-secret', code_challenge_method: 'plain' }, verifier: 'not-the-secret' });
|
|
307
|
+
// The failure must be THE PKCE refusal, not any 400 (a missing parameter would also 400).
|
|
308
|
+
return r.status === 200 && bad.status === 400
|
|
309
|
+
&& bad.tokens['error'] === 'invalid_request'
|
|
310
|
+
&& String(bad.tokens['error_description']).includes('authorization code was invalid');
|
|
311
|
+
})),
|
|
312
|
+
done('xidentity.token.requires_code_verifier', 'token', 'code_verifier is required at the token endpoint (the docs\' parameter list)', 'api', 'core', () => withRoot(async (h) => {
|
|
313
|
+
const flow = await consentFlow(h);
|
|
314
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: flow.code ?? '', client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT }), h: { authorization: BASIC } });
|
|
315
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request' && String(body(r)['error_description']).includes('code_verifier');
|
|
316
|
+
})),
|
|
317
|
+
done('xidentity.token.redirect_uri_repeated_and_matching', 'token', 'redirect_uri must be repeated at the token endpoint and match the code\'s (RFC 6749 §4.1.3; the docs\' parameter list requires it)', 'api', 'core', () => withRoot(async (h) => {
|
|
318
|
+
const flow = await consentFlow(h);
|
|
319
|
+
const missing = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: flow.code ?? '', client_id: DEFAULT_CLIENT_ID, code_verifier: VERIFIER }), h: { authorization: BASIC } });
|
|
320
|
+
const wrong = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: flow.code ?? '', client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT_URIS[1], code_verifier: VERIFIER }), h: { authorization: BASIC } });
|
|
321
|
+
return missing.status === 400 && String(body(missing)['error_description']).includes('redirect_uri')
|
|
322
|
+
&& wrong.status === 400 && body(wrong)['error'] === 'invalid_request';
|
|
323
|
+
})),
|
|
324
|
+
done('xidentity.token.code_single_use', 'token', 'An authorization code redeems exactly ONCE; the replay is refused with the reported wire message ("Value passed for the authorization code was invalid.")', 'api', 'core', () => withRoot(async (h) => {
|
|
325
|
+
const { code, status } = await fullFlow(h);
|
|
326
|
+
if (status !== 200 || !code)
|
|
327
|
+
return false;
|
|
328
|
+
const replay = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT, code_verifier: VERIFIER }), h: { authorization: BASIC } });
|
|
329
|
+
return replay.status === 400 && body(replay)['error'] === 'invalid_request' && String(body(replay)['error_description']).includes('authorization code was invalid');
|
|
330
|
+
})),
|
|
331
|
+
done('xidentity.token.code_expires', 'token', 'An authorization code expires (30s modelled; the exact live TTL is a filed todo) — redemption before the deadline succeeds, after it is refused', 'api', 'common', () => withRoot(async (h) => {
|
|
332
|
+
const fresh = await fullFlow(h, { tokenAt: atPlus(29) });
|
|
333
|
+
const stale = await fullFlow(h, { tokenAt: atPlus(31) });
|
|
334
|
+
return fresh.status === 200 && stale.status === 400;
|
|
335
|
+
})),
|
|
336
|
+
done('xidentity.token.code_bound_to_client', 'token', 'A code minted for one client is refused when another client redeems it — indistinguishably from an unknown code', 'api', 'core', () => withRoot(async (h) => {
|
|
337
|
+
const flow = await consentFlow(h);
|
|
338
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: flow.code ?? '', client_id: DEFAULT_PUBLIC_CLIENT_ID, redirect_uri: REDIRECT, code_verifier: VERIFIER }) });
|
|
339
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request';
|
|
340
|
+
})),
|
|
341
|
+
done('xidentity.token.confidential_basic_auth', 'token', 'A confidential client authenticates with HTTP Basic (docs: base64(client_id:client_secret)); a wrong secret is 401 invalid_client', 'api', 'core', () => withRoot(async (h) => {
|
|
342
|
+
const good = await fullFlow(h);
|
|
343
|
+
const badBasic = `Basic ${Buffer.from(`${DEFAULT_CLIENT_ID}:not-the-secret`).toString('base64')}`;
|
|
344
|
+
const bad = await fullFlow(h, { basic: badBasic });
|
|
345
|
+
return good.status === 200 && bad.status === 401 && bad.tokens['error'] === 'invalid_client';
|
|
346
|
+
})),
|
|
347
|
+
done('xidentity.token.confidential_requires_basic', 'token', 'A confidential client presenting only a body client_id (no Basic header) is refused — the docs document Basic for confidential clients and body client_id for PUBLIC clients only', 'api', 'common', () => withRoot(async (h) => {
|
|
348
|
+
const r = await fullFlow(h, { basic: null });
|
|
349
|
+
return r.status === 401 && r.tokens['error'] === 'invalid_client';
|
|
350
|
+
})),
|
|
351
|
+
done('xidentity.token.public_client_body_id', 'token', 'A public client exchanges with client_id in the BODY and no secret (docs: "You still are required to include Client Id in the body… with a public client")', 'api', 'core', () => withRoot(async (h) => {
|
|
352
|
+
const r = await fullFlow(h, { params: { client_id: DEFAULT_PUBLIC_CLIENT_ID }, clientId: DEFAULT_PUBLIC_CLIENT_ID, basic: null });
|
|
353
|
+
return r.status === 200 && typeof r.tokens['access_token'] === 'string';
|
|
354
|
+
})),
|
|
355
|
+
done('xidentity.token.offline_access_gates_refresh', 'token', 'A refresh_token rides EXACTLY when the grant carries offline.access (docs: "The offline.access scope enables refresh token issuance") — present with it, absent without it', 'api', 'core', () => withRoot(async (h) => {
|
|
356
|
+
const withOffline = await fullFlow(h);
|
|
357
|
+
const without = await fullFlow(h, { params: { scope: 'tweet.read users.read' } });
|
|
358
|
+
return withOffline.status === 200 && decoded(withOffline.tokens['refresh_token']).includes(':rt:')
|
|
359
|
+
&& without.status === 200 && without.tokens['refresh_token'] === undefined;
|
|
360
|
+
})),
|
|
361
|
+
done('xidentity.token.requires_grant_type', 'token', 'A token request without grant_type is refused; an unknown grant_type answers unsupported_grant_type', 'api', 'common', () => withRoot(async (h) => {
|
|
362
|
+
const missing = await h({ m: 'POST', p: '/2/oauth2/token', b: '' });
|
|
363
|
+
const unknown = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'password', client_id: DEFAULT_PUBLIC_CLIENT_ID }) });
|
|
364
|
+
return missing.status === 400 && body(missing)['error'] === 'invalid_request'
|
|
365
|
+
&& unknown.status === 400 && body(unknown)['error'] === 'unsupported_grant_type';
|
|
366
|
+
})),
|
|
367
|
+
done('xidentity.token.requires_code', 'token', 'An authorization_code exchange without a code is refused as invalid_request', 'api', 'common', () => withRoot(async (h) => {
|
|
368
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT, code_verifier: VERIFIER }), h: { authorization: BASIC } });
|
|
369
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request' && String(body(r)['error_description']).includes('code');
|
|
370
|
+
})),
|
|
371
|
+
todo('xidentity.token.token_type_case', 'token', 'PIN the live token_type literal (the twin serves the widely-reported lowercase "bearer"; the docs show no response JSON)', 'api', 'niche'),
|
|
372
|
+
todo('xidentity.token.error_wording', 'token', 'PIN the token endpoint\'s live error codes and descriptions per failure (the docs show no error examples; the twin uses RFC codes with reported wire wording)', 'api', 'common'),
|
|
373
|
+
todo('xidentity.token.code_ttl', 'token', 'PIN the live authorization-code lifetime (the twin models the widely-reported 30 seconds)', 'api', 'niche'),
|
|
374
|
+
todo('xidentity.token.client_secret_post', 'token', 'PIN whether X accepts client_secret in the POST body (client_secret_post) — the docs document only Basic; the twin refuses, unpinned', 'api', 'niche'),
|
|
375
|
+
todo('xidentity.token.json_body_tolerance', 'token', 'PIN whether the live token endpoint tolerates a JSON body (the twin requires application/x-www-form-urlencoded, per the docs\' curl examples)', 'api', 'niche'),
|
|
376
|
+
// ═══ refresh — POST /2/oauth2/token (refresh_token) ═══════════════════════════════════════
|
|
377
|
+
done('xidentity.refresh.grant', 'refresh', 'A refresh_token redeems for a fresh 2-hour access token carrying the same scope (docs: refresh_token, grant_type=refresh_token, client_id)', 'api', 'core', () => withRoot(async (h) => {
|
|
378
|
+
const { tokens } = await fullFlow(h);
|
|
379
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
380
|
+
return r.status === 200 && body(r)['expires_in'] === 7200 && body(r)['scope'] === SCOPE && decoded(body(r)['access_token']).includes(':at:');
|
|
381
|
+
})),
|
|
382
|
+
done('xidentity.refresh.rotation', 'refresh', 'Refresh tokens ROTATE (widely-reported wire behaviour; live pin filed): the response carries a NEW refresh_token and the spent one is refused on second use', 'api', 'core', () => withRoot(async (h) => {
|
|
383
|
+
const { tokens } = await fullFlow(h);
|
|
384
|
+
const first = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
385
|
+
const replay = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
386
|
+
return first.status === 200
|
|
387
|
+
&& typeof body(first)['refresh_token'] === 'string'
|
|
388
|
+
&& body(first)['refresh_token'] !== tokens['refresh_token']
|
|
389
|
+
&& replay.status === 400 && String(body(replay)['error_description']).includes('token was invalid');
|
|
390
|
+
})),
|
|
391
|
+
done('xidentity.refresh.requires_refresh_token', 'refresh', 'A refresh request without refresh_token is refused as invalid_request', 'api', 'common', () => withRoot(async (h) => {
|
|
392
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
393
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request';
|
|
394
|
+
})),
|
|
395
|
+
done('xidentity.refresh.unknown_token_refused', 'refresh', 'An unknown refresh token is refused with the reported wire message ("Value passed for the token was invalid.")', 'api', 'common', () => withRoot(async (h) => {
|
|
396
|
+
await fullFlow(h);
|
|
397
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', refresh_token: 'bm90LWEtdG9rZW4', client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
398
|
+
return r.status === 400 && String(body(r)['error_description']).includes('token was invalid');
|
|
399
|
+
})),
|
|
400
|
+
done('xidentity.refresh.bound_to_client', 'refresh', 'A refresh token minted for one client is refused when another client presents it — with the same indistinguishable bad-token answer', 'api', 'common', () => withRoot(async (h) => {
|
|
401
|
+
const { tokens } = await fullFlow(h);
|
|
402
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']), client_id: DEFAULT_PUBLIC_CLIENT_ID }) });
|
|
403
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request' && String(body(r)['error_description']).includes('token was invalid');
|
|
404
|
+
})),
|
|
405
|
+
todo('xidentity.refresh.scope_param', 'refresh', 'PIN whether X honours a scope-narrowing parameter on refresh (RFC 6749 §6 allows it; X does not document it; the twin ignores it)', 'api', 'niche'),
|
|
406
|
+
todo('xidentity.refresh.token_lifetime', 'refresh', 'PIN the refresh token\'s own lifetime/expiry semantics (undocumented; the twin models no expiry)', 'api', 'niche'),
|
|
407
|
+
// ═══ revoke — POST api.x.com/2/oauth2/revoke ══════════════════════════════════════════════
|
|
408
|
+
done('xidentity.revoke.access_token', 'revoke', 'Revoking an access token answers {"revoked":true} (the legacy official SDK\'s typed response) and the token is dead at /2/users/me', 'api', 'core', () => withRoot(async (h) => {
|
|
409
|
+
const { tokens } = await fullFlow(h);
|
|
410
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: String(tokens['access_token']), client_id: DEFAULT_CLIENT_ID, token_type_hint: 'access_token' }), h: { authorization: BASIC } });
|
|
411
|
+
const me = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
412
|
+
return r.status === 200 && body(r)['revoked'] === true && me.status === 401;
|
|
413
|
+
})),
|
|
414
|
+
done('xidentity.revoke.pair_revocation', 'revoke', 'Revocation is "log out": revoking EITHER half kills the whole (client, user) grant — the refresh token dies with the access token (RFC-derived sweep; live pin filed)', 'api', 'core', () => withRoot(async (h) => {
|
|
415
|
+
const { tokens } = await fullFlow(h);
|
|
416
|
+
await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: String(tokens['access_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
417
|
+
const refresh = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'refresh_token', refresh_token: String(tokens['refresh_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
418
|
+
// The refusal must be THE dead-token answer, not any 400.
|
|
419
|
+
return refresh.status === 400 && String(body(refresh)['error_description']).includes('token was invalid');
|
|
420
|
+
})),
|
|
421
|
+
done('xidentity.revoke.refresh_token_hint', 'revoke', 'Revoking by refresh token (token_type_hint=refresh_token, the legacy SDK\'s other path) kills the grant too', 'api', 'common', () => withRoot(async (h) => {
|
|
422
|
+
const { tokens } = await fullFlow(h);
|
|
423
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: String(tokens['refresh_token']), client_id: DEFAULT_CLIENT_ID, token_type_hint: 'refresh_token' }), h: { authorization: BASIC } });
|
|
424
|
+
const me = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
425
|
+
return r.status === 200 && body(r)['revoked'] === true && me.status === 401;
|
|
426
|
+
})),
|
|
427
|
+
done('xidentity.revoke.requires_token', 'revoke', 'A revoke request without a token parameter is refused as invalid_request', 'api', 'common', () => withRoot(async (h) => {
|
|
428
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
429
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request';
|
|
430
|
+
})),
|
|
431
|
+
done('xidentity.revoke.client_auth', 'revoke', 'The revoke endpoint authenticates the client exactly as the token endpoint does — a wrong secret is 401 invalid_client', 'api', 'common', () => withRoot(async (h) => {
|
|
432
|
+
const { tokens } = await fullFlow(h);
|
|
433
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: String(tokens['access_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: `Basic ${Buffer.from(`${DEFAULT_CLIENT_ID}:wrong`).toString('base64')}` } });
|
|
434
|
+
return r.status === 401 && body(r)['error'] === 'invalid_client';
|
|
435
|
+
})),
|
|
436
|
+
done('xidentity.revoke.unknown_token_200', 'revoke', 'An unknown token still answers 200 {"revoked":true} (RFC 7009 §2.2 — a probe cannot learn which tokens exist; live pin filed)', 'api', 'niche', () => withRoot(async (h) => {
|
|
437
|
+
await fullFlow(h);
|
|
438
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: 'bm90LWEtdG9rZW4', client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
439
|
+
return r.status === 200 && body(r)['revoked'] === true;
|
|
440
|
+
})),
|
|
441
|
+
// Demoted from `done` by §9 round one: the twin answers a foreign-client revoke 200 while
|
|
442
|
+
// revoking nothing, but RFC 7009's two clauses point OPPOSITE ways here (§2.2 "invalid tokens do
|
|
443
|
+
// not cause an error" vs §2.1 "verifies whether the token was issued to the client … if this
|
|
444
|
+
// validation fails, the request is refused") and no X artefact settles which reading the vendor
|
|
445
|
+
// takes. The never-cross-revoke SECURITY property stays pinned as a twin invariant in
|
|
446
|
+
// xidentity-twin.test.ts; the vendor's status/body for this case is the todo.
|
|
447
|
+
todo('xidentity.revoke.cross_client_wire', 'revoke', 'PIN the live answer when a client revokes ANOTHER client\'s token (RFC 7009 §2.1 refusal vs §2.2 invalid-token 200 — the twin currently answers 200 and revokes nothing)', 'api', 'common'),
|
|
448
|
+
done('xidentity.revoke.hint_cannot_mislead', 'revoke', 'A wrong token_type_hint does not change the outcome (RFC 7009 §2.1: the hint only optimises the lookup)', 'api', 'niche', () => withRoot(async (h) => {
|
|
449
|
+
const { tokens } = await fullFlow(h);
|
|
450
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: String(tokens['access_token']), client_id: DEFAULT_CLIENT_ID, token_type_hint: 'refresh_token' }), h: { authorization: BASIC } });
|
|
451
|
+
const me = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
452
|
+
return r.status === 200 && me.status === 401;
|
|
453
|
+
})),
|
|
454
|
+
todo('xidentity.revoke.wire_pin', 'revoke', 'PIN the live revoke behaviour: unknown-token status, the pair-wide sweep, and the exact response body (the SDK types {revoked: boolean}; the docs show the request only)', 'api', 'common'),
|
|
455
|
+
// ═══ users_me — GET api.x.com/2/users/me ══════════════════════════════════════════════════
|
|
456
|
+
done('xidentity.users_me.default_fields', 'users_me', 'The default response is data:{id,name,username} and NOTHING else (the OpenAPI/docs default field set)', 'api', 'core', () => withRoot(async (h) => {
|
|
457
|
+
const { tokens } = await fullFlow(h);
|
|
458
|
+
const me = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
459
|
+
const d = body(me)['data'];
|
|
460
|
+
return ok(me) && d?.id === ADA.id && d?.name === ADA.name && d?.username === ADA.username
|
|
461
|
+
&& Object.keys(d).sort().join(',') === 'id,name,username';
|
|
462
|
+
})),
|
|
463
|
+
done('xidentity.users_me.bearer_required', 'users_me', 'No bearer → the 401 problem envelope ({"title":"Unauthorized","type":"about:blank",…} — REPORTED wire shape, pinned by errors.auth_envelope_pin; the OpenAPI default-error contract)', 'api', 'core', () => withRoot(async (h) => {
|
|
464
|
+
const r = await h({ m: 'GET', p: '/2/users/me' });
|
|
465
|
+
const b = body(r);
|
|
466
|
+
return r.status === 401 && b['title'] === 'Unauthorized' && b['type'] === 'about:blank' && b['status'] === 401 && b['detail'] === 'Unauthorized';
|
|
467
|
+
})),
|
|
468
|
+
done('xidentity.users_me.unknown_token', 'users_me', 'An unknown bearer gets the same 401 problem envelope', 'api', 'core', () => withRoot(async (h) => {
|
|
469
|
+
await fullFlow(h);
|
|
470
|
+
const r = await h({ m: 'GET', p: '/2/users/me', h: { authorization: 'Bearer bm90LWEtdG9rZW4' } });
|
|
471
|
+
return r.status === 401 && body(r)['title'] === 'Unauthorized';
|
|
472
|
+
})),
|
|
473
|
+
done('xidentity.users_me.expired_token', 'users_me', 'A token past its 2-hour expiry is refused with the 401 problem envelope', 'api', 'common', () => withRoot(async (h) => {
|
|
474
|
+
const { tokens } = await fullFlow(h);
|
|
475
|
+
const live = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` }, at: atPlus(7199) });
|
|
476
|
+
const dead = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` }, at: atPlus(7201) });
|
|
477
|
+
return live.status === 200 && dead.status === 401;
|
|
478
|
+
})),
|
|
479
|
+
done('xidentity.users_me.scope_enforcement', 'users_me', 'The operation demands BOTH tweet.read and users.read (the OpenAPI operation\'s OAuth2UserToken entry) — a token missing one is 403 naming the missing scope', 'api', 'core', () => withRoot(async (h) => {
|
|
480
|
+
const narrow = await fullFlow(h, { params: { scope: 'users.read offline.access' } });
|
|
481
|
+
const r = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${narrow.tokens['access_token']}` } });
|
|
482
|
+
// The 403 STATUS is the grounded fact; the envelope's title/type literals are unpinned
|
|
483
|
+
// vendor prose (xidentity.users_me.insufficient_scope_wording, todo), so they are
|
|
484
|
+
// deliberately NOT asserted here — only the status and the named missing scope.
|
|
485
|
+
return narrow.status === 200 && r.status === 403 && String(body(r)['detail']).includes('tweet.read');
|
|
486
|
+
})),
|
|
487
|
+
done('xidentity.users_me.user_fields_selection', 'users_me', 'user.fields selects the OpenAPI-enumerated optional fields: each modelled field appears when asked for and not otherwise', 'api', 'core', () => withRoot(async (h) => {
|
|
488
|
+
const { tokens } = await fullFlow(h);
|
|
489
|
+
const bare = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
490
|
+
const rich = await h({ m: 'GET', p: '/2/users/me?user.fields=created_at,description,location,profile_image_url,protected,url,verified,verified_type', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
491
|
+
const d = body(rich)['data'];
|
|
492
|
+
return ok(rich)
|
|
493
|
+
&& body(bare)['data']['created_at'] === undefined
|
|
494
|
+
&& d['created_at'] === ADA.createdAt
|
|
495
|
+
&& d['description'] === ADA.description
|
|
496
|
+
&& d['location'] === ADA.location
|
|
497
|
+
&& d['profile_image_url'] === ADA.profileImageUrl
|
|
498
|
+
&& d['protected'] === false
|
|
499
|
+
&& d['url'] === ADA.url
|
|
500
|
+
&& d['verified'] === true
|
|
501
|
+
&& d['verified_type'] === 'blue';
|
|
502
|
+
})),
|
|
503
|
+
done('xidentity.users_me.public_metrics', 'users_me', 'user.fields=public_metrics returns the metrics object with the vendor\'s six counters', 'api', 'common', () => withRoot(async (h) => {
|
|
504
|
+
const { tokens } = await fullFlow(h);
|
|
505
|
+
const r = await h({ m: 'GET', p: '/2/users/me?user.fields=public_metrics', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
506
|
+
const m = body(r)['data']?.['public_metrics'];
|
|
507
|
+
return ok(r) && m?.['followers_count'] === ADA.publicMetrics.followers_count && m?.['tweet_count'] === ADA.publicMetrics.tweet_count
|
|
508
|
+
&& typeof m?.['following_count'] === 'number' && typeof m?.['listed_count'] === 'number';
|
|
509
|
+
})),
|
|
510
|
+
done('xidentity.users_me.confirmed_email', 'users_me', 'user.fields=confirmed_email returns the e-mail ONLY to a token holding users.email (X\'s April-2025 announcement gates the field behind that scope); without it the field is withheld', 'api', 'niche', () => withRoot(async (h) => {
|
|
511
|
+
const withScope = await fullFlow(h, { params: { scope: 'tweet.read users.read users.email offline.access' } });
|
|
512
|
+
const without = await fullFlow(h);
|
|
513
|
+
const granted = await h({ m: 'GET', p: '/2/users/me?user.fields=confirmed_email', h: { authorization: `Bearer ${withScope.tokens['access_token']}` } });
|
|
514
|
+
const withheld = await h({ m: 'GET', p: '/2/users/me?user.fields=confirmed_email', h: { authorization: `Bearer ${without.tokens['access_token']}` } });
|
|
515
|
+
// OMISSION (rather than a 403) for the un-scoped request is EXTRAPOLATION — pinned by
|
|
516
|
+
// xidentity.users_me.confirmed_email_scope_behavior (todo).
|
|
517
|
+
return ok(granted) && body(granted)['data']?.['confirmed_email'] === ADA.confirmedEmail
|
|
518
|
+
&& ok(withheld) && body(withheld)['data']?.['confirmed_email'] === undefined;
|
|
519
|
+
})),
|
|
520
|
+
done('xidentity.users_me.invalid_field_400', 'users_me', 'An invalid user.fields value gets the invalid-request envelope: errors[].parameters names it, type is the …/problems/invalid-request URL (the errors[] array is the REPORTED WIRE shape — the OpenAPI\'s InvalidRequestProblem carries singular parameter/value; pin filed)', 'api', 'core', () => withRoot(async (h) => {
|
|
521
|
+
const { tokens } = await fullFlow(h);
|
|
522
|
+
const r = await h({ m: 'GET', p: '/2/users/me?user.fields=bogus,created_at', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
523
|
+
const b = body(r);
|
|
524
|
+
const err = b['errors']?.[0];
|
|
525
|
+
return r.status === 400
|
|
526
|
+
&& b['type'] === 'https://api.x.com/2/problems/invalid-request'
|
|
527
|
+
&& b['title'] === 'Invalid Request'
|
|
528
|
+
&& b['detail'] === 'One or more parameters to your request was invalid.'
|
|
529
|
+
&& Array.isArray(err?.['parameters']?.['user.fields'])
|
|
530
|
+
&& (err?.['parameters']?.['user.fields']).includes('bogus')
|
|
531
|
+
&& String(err?.['message']).includes('bogus');
|
|
532
|
+
})),
|
|
533
|
+
done('xidentity.users_me.invalid_expansion_400', 'users_me', 'An invalid expansions value gets the same invalid-request envelope (the enum is affiliation, most_recent_post_id, pinned_post_id)', 'api', 'common', () => withRoot(async (h) => {
|
|
534
|
+
const { tokens } = await fullFlow(h);
|
|
535
|
+
const r = await h({ m: 'GET', p: '/2/users/me?expansions=pinned_tweet_id', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
536
|
+
const err = body(r)['errors']?.[0];
|
|
537
|
+
return r.status === 400 && Array.isArray(err?.['parameters']?.['expansions']) && String(err?.['message']).includes('pinned_tweet_id');
|
|
538
|
+
})),
|
|
539
|
+
done('xidentity.users_me.revoked_session_401', 'users_me', 'DIRTY-STATE: consent → use → revoke → re-consent yields a NEW working token while the revoked one stays dead (state built up across a full lifecycle)', 'api', 'common', () => withRoot(async (h) => {
|
|
540
|
+
const first = await fullFlow(h);
|
|
541
|
+
await h({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: String(first.tokens['access_token']), client_id: DEFAULT_CLIENT_ID }), h: { authorization: BASIC } });
|
|
542
|
+
const second = await fullFlow(h);
|
|
543
|
+
const dead = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${first.tokens['access_token']}` } });
|
|
544
|
+
const alive = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${second.tokens['access_token']}` } });
|
|
545
|
+
return dead.status === 401 && alive.status === 200 && body(alive)['data']?.['id'] === ADA.id;
|
|
546
|
+
})),
|
|
547
|
+
todo('xidentity.users_me.expansion_hydration', 'users_me', 'HYDRATE the documented expansions (affiliation, most_recent_post_id, pinned_post_id) into includes.* — needs post objects the identity twin does not model', 'api', 'common'),
|
|
548
|
+
todo('xidentity.users_me.tweet_fields_validation', 'users_me', 'VALIDATE tweet.fields against the OpenAPI PostFieldsParameter enum (accepted but unvalidated today)', 'api', 'niche'),
|
|
549
|
+
todo('xidentity.users_me.field_connection_status', 'users_me', 'Model user.fields=connection_status (relational to a source user)', 'api', 'niche'),
|
|
550
|
+
todo('xidentity.users_me.field_entities', 'users_me', 'Model user.fields=entities (URL/description entity extraction)', 'api', 'niche'),
|
|
551
|
+
todo('xidentity.users_me.field_is_identity_verified', 'users_me', 'Model user.fields=is_identity_verified', 'api', 'niche'),
|
|
552
|
+
todo('xidentity.users_me.field_parody', 'users_me', 'Model user.fields=parody (parody-account label)', 'api', 'niche'),
|
|
553
|
+
todo('xidentity.users_me.field_profile_banner_url', 'users_me', 'Model user.fields=profile_banner_url', 'api', 'niche'),
|
|
554
|
+
todo('xidentity.users_me.field_receives_your_dm', 'users_me', 'Model user.fields=receives_your_dm (relational)', 'api', 'niche'),
|
|
555
|
+
todo('xidentity.users_me.field_subscribes_to_you', 'users_me', 'Model user.fields=subscribes_to_you (relational)', 'api', 'niche'),
|
|
556
|
+
todo('xidentity.users_me.field_subscription', 'users_me', 'Model user.fields=subscription and subscription_type (X Premium tier)', 'api', 'niche'),
|
|
557
|
+
todo('xidentity.users_me.field_verified_followers_count', 'users_me', 'Model user.fields=verified_followers_count', 'api', 'niche'),
|
|
558
|
+
todo('xidentity.users_me.field_withheld', 'users_me', 'Model user.fields=withheld (country-withheld metadata)', 'api', 'niche'),
|
|
559
|
+
todo('xidentity.users_me.insufficient_scope_wording', 'users_me', 'PIN the live 403 body for a token missing a required scope (the twin states the refusal plainly rather than inventing vendor prose)', 'api', 'common'),
|
|
560
|
+
todo('xidentity.users_me.invalid_request_envelope_pin', 'users_me', 'PIN the live invalid-parameter body against the OpenAPI\'s singular InvalidRequestProblem (the twin serves the reported errors[] wire shape)', 'api', 'common'),
|
|
561
|
+
todo('xidentity.users_me.confirmed_email_scope_behavior', 'users_me', 'PIN what the live endpoint does when confirmed_email is requested WITHOUT users.email (the twin withholds the field; a 403 is the other candidate)', 'api', 'niche'),
|
|
562
|
+
todo('xidentity.users_me.app_only_forbidden', 'users_me', 'Model the 403 Unsupported Authentication envelope an app-only bearer gets on this user-context endpoint', 'api', 'common'),
|
|
563
|
+
todo('xidentity.users_me.oauth1_user_context', 'users_me', 'Model OAuth 1.0a User Context as the alternate auth the OpenAPI lists for this operation', 'api', 'niche'),
|
|
564
|
+
todo('xidentity.users_me.partial_errors', 'users_me', 'Model the errors[] array alongside data (per-field Problem entries on a 200, per GetUsersMeResponse)', 'api', 'niche'),
|
|
565
|
+
// ═══ rate envelope ════════════════════════════════════════════════════════════════════════
|
|
566
|
+
done('xidentity.rate.headers', 'rate', 'Every /2/users/me response carries x-rate-limit-limit/remaining/reset (docs.x.com fundamentals): limit 75, remaining decrements per call, reset a unix timestamp', 'api', 'core', () => withRoot(async (h) => {
|
|
567
|
+
const { tokens } = await fullFlow(h);
|
|
568
|
+
const first = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
569
|
+
const second = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
570
|
+
return first.headers?.['x-rate-limit-limit'] === '75'
|
|
571
|
+
&& first.headers?.['x-rate-limit-remaining'] === '74'
|
|
572
|
+
&& second.headers?.['x-rate-limit-remaining'] === '73'
|
|
573
|
+
&& Number(first.headers?.['x-rate-limit-reset']) === Math.floor(Date.parse(AT) / 1000) + 900;
|
|
574
|
+
})),
|
|
575
|
+
done('xidentity.rate.429_code_88', 'rate', 'An exhausted 15-minute window answers HTTP 429 with legacy code 88 "Rate limit exceeded" (docs.x.com fundamentals, verbatim) and remaining 0', 'api', 'core', () => withRoot(async (h) => {
|
|
576
|
+
const { tokens } = await fullFlow(h);
|
|
577
|
+
await h({ m: 'POST', p: '/_twin/rate_limit', b: JSON.stringify({ account_id: ADA.id, used: 75 }) });
|
|
578
|
+
const r = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
579
|
+
const errs = body(r)['errors'];
|
|
580
|
+
return r.status === 429 && errs?.[0]?.['code'] === 88 && errs?.[0]?.['message'] === 'Rate limit exceeded'
|
|
581
|
+
&& r.headers?.['x-rate-limit-remaining'] === '0';
|
|
582
|
+
})),
|
|
583
|
+
done('xidentity.rate.window_resets', 'rate', 'DIRTY-STATE: an exhausted window RESETS after its 15 minutes — the same token is served again in the next window', 'api', 'common', () => withRoot(async (h) => {
|
|
584
|
+
const { tokens } = await fullFlow(h);
|
|
585
|
+
await h({ m: 'POST', p: '/_twin/rate_limit', b: JSON.stringify({ account_id: ADA.id, used: 75 }) });
|
|
586
|
+
const blocked = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
587
|
+
const next = await h({ m: 'GET', p: '/2/users/me', h: { authorization: `Bearer ${tokens['access_token']}` }, at: atPlus(901) });
|
|
588
|
+
return blocked.status === 429 && next.status === 200 && next.headers?.['x-rate-limit-remaining'] === '74';
|
|
589
|
+
})),
|
|
590
|
+
todo('xidentity.rate.what_counts', 'rate', 'PIN which outcomes spend the window on the live vendor (the twin counts 200s only; 4xx are free, unpinned)', 'api', 'niche'),
|
|
591
|
+
todo('xidentity.rate.per_app_windows', 'rate', 'Model per-APP rate windows for endpoints that document them (users/me documents per-user only)', 'api', 'niche'),
|
|
592
|
+
// ═══ errors / router ══════════════════════════════════════════════════════════════════════
|
|
593
|
+
done('xidentity.errors.unmodeled_404_problem', 'errors', 'An unmodelled /2 route fails like the vendor: the 404 problem envelope (REPORTED shape, pinned by errors.not_found_envelope_pin), never a fake success', 'api', 'core', () => withRoot(async (h) => {
|
|
594
|
+
const r = await h({ m: 'GET', p: '/2/users/by?usernames=ada_twin' });
|
|
595
|
+
const b = body(r);
|
|
596
|
+
return r.status === 404 && b['title'] === 'Not Found Error' && b['type'] === 'about:blank' && b['status'] === 404;
|
|
597
|
+
})),
|
|
598
|
+
done('xidentity.errors.wrong_method_404', 'errors', 'A wrong method on a modelled path is refused with the not-found envelope, not served', 'api', 'niche', () => withRoot(async (h) => {
|
|
599
|
+
const r = await h({ m: 'POST', p: '/2/users/me', b: '{}' });
|
|
600
|
+
return r.status === 404 && body(r)['title'] === 'Not Found Error';
|
|
601
|
+
})),
|
|
602
|
+
done('xidentity.errors.malformed_client_auth_contained', 'errors', 'A Basic credential whose percent-decoding blows up becomes the vendor\'s own 400 OAuth envelope (the route-boundary guard), never an escaped 500', 'api', 'niche', () => withRoot(async (h) => {
|
|
603
|
+
// base64("client%E0%A4%A:secret") — decodeURIComponent throws URIError on the client half.
|
|
604
|
+
const evil = `Basic ${Buffer.from('client%E0%A4%A:secret').toString('base64')}`;
|
|
605
|
+
const r = await h({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: 'x', redirect_uri: REDIRECT, code_verifier: VERIFIER }), h: { authorization: evil } });
|
|
606
|
+
return r.status === 400 && body(r)['error'] === 'invalid_request';
|
|
607
|
+
})),
|
|
608
|
+
todo('xidentity.errors.problem_family', 'errors', 'Model the remaining OpenAPI Problem subtypes (not-authorized-for-field, field-hydration-failure, resource-unavailable, …) on the surfaces that emit them', 'api', 'niche'),
|
|
609
|
+
todo('xidentity.errors.auth_envelope_pin', 'errors', 'PIN the live 401 body for a bad/absent bearer (the twin serves the reported about:blank problem shape the bearer_required/unknown_token verifies assert)', 'api', 'common'),
|
|
610
|
+
todo('xidentity.errors.not_found_envelope_pin', 'errors', 'PIN the live 404 body for an unmodelled /2 route (the twin serves the reported "Not Found Error" about:blank shape)', 'api', 'niche'),
|
|
611
|
+
// ═══ scopes catalog ═══════════════════════════════════════════════════════════════════════
|
|
612
|
+
done('xidentity.scopes.catalog_is_vendor_verbatim', 'scopes', 'The scope catalog bijects with the vendor\'s: the OpenAPI\'s 24 OAuth2UserToken scopes plus the announcement-added users.email, and nothing else (literal list, so drift in EITHER direction goes red)', 'api', 'core', async () => {
|
|
613
|
+
// The 24 scopes from api.x.com/2/openapi.json (2.167) securitySchemes — a LITERAL, never an
|
|
614
|
+
// import of the catalog under test (the two-constants tautology §6 names). `users.email` is
|
|
615
|
+
// NOT in that artefact: it comes from X's April-2025 email-retrieval announcement (see
|
|
616
|
+
// xidentity-scopes.ts) — a recorded intra-vendor discrepancy, asserted separately.
|
|
617
|
+
const OPENAPI_SCOPES = [
|
|
618
|
+
'block.read', 'block.write', 'bookmark.read', 'bookmark.write', 'broadcast.read', 'broadcast.write',
|
|
619
|
+
'dm.read', 'dm.write', 'follows.read', 'follows.write', 'like.read', 'like.write', 'list.read',
|
|
620
|
+
'list.write', 'media.write', 'mute.read', 'mute.write', 'offline.access', 'space.read',
|
|
621
|
+
'timeline.read', 'tweet.moderate.write', 'tweet.read', 'tweet.write', 'users.read',
|
|
622
|
+
];
|
|
623
|
+
const { SCOPE_CATALOG } = await import("./xidentity-scopes.js");
|
|
624
|
+
const have = Object.keys(SCOPE_CATALOG).sort();
|
|
625
|
+
return have.length === 25 && OPENAPI_SCOPES.every((s) => have.includes(s)) && have.includes('users.email')
|
|
626
|
+
&& SCOPE_CATALOG['users.read'].label === 'View any account you can see, including protected accounts.'
|
|
627
|
+
&& SCOPE_CATALOG['dm.write'].label === 'Send and manage your Direct Messages.';
|
|
628
|
+
}),
|
|
629
|
+
done('xidentity.scopes.space_separated', 'scopes', 'The scope parameter is space-separated on the wire (the docs\' example: scope=tweet.read%20users.read…) and the token response echoes it space-joined', 'api', 'common', () => withRoot(async (h) => {
|
|
630
|
+
const r = await fullFlow(h, { params: { scope: 'users.read tweet.read' } });
|
|
631
|
+
return r.status === 200 && r.tokens['scope'] === 'users.read tweet.read';
|
|
632
|
+
})),
|
|
633
|
+
todo('xidentity.scopes.unknown_scope_live', 'scopes', 'PIN what the live authorize endpoint does with an unknown scope value (the twin renders it flagged on the screen rather than inventing a rejection)', 'api', 'niche'),
|
|
634
|
+
todo('xidentity.scopes.users_email_wording', 'scopes', 'PIN the consent screen\'s live wording for users.email (the label is twin prose; the scope itself is announcement-grounded)', 'ui', 'niche'),
|
|
635
|
+
// ═══ read-only twin ═══════════════════════════════════════════════════════════════════════
|
|
636
|
+
done('xidentity.readonly.writes_refused', 'readonly', 'A read-only twin refuses every credential-minting leg (405, protocol-shaped error body) — D3', 'api', 'common', () => withRoot(async (h) => {
|
|
637
|
+
await fullFlow(h); // seed a world first, through the ordinary write path
|
|
638
|
+
const ro = (s) => handleXIdentityTwinRequest({ method: s.m, path: s.p, ...(s.b !== undefined ? { body: s.b } : {}), root: h.root, origin: DEMO_ORIGIN, occurredAt: AT, readOnly: true });
|
|
639
|
+
const tok = await ro({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: 'x', client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT, code_verifier: VERIFIER }) });
|
|
640
|
+
const rev = await ro({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: 'x', client_id: DEFAULT_CLIENT_ID }) });
|
|
641
|
+
return tok.status === 405 && body(tok)['error'] === 'temporarily_unavailable' && rev.status === 405;
|
|
642
|
+
})),
|
|
643
|
+
done('xidentity.readonly.reads_served', 'readonly', 'A read-only twin still serves GET /2/users/me over existing state (rate accounting suspended, headers intact)', 'api', 'niche', () => withRoot(async (h) => {
|
|
644
|
+
const { tokens } = await fullFlow(h);
|
|
645
|
+
const r = await handleXIdentityTwinRequest({ method: 'GET', path: '/2/users/me', headers: { authorization: `Bearer ${tokens['access_token']}` }, root: h.root, occurredAt: AT, readOnly: true });
|
|
646
|
+
return r.status === 200 && body(r)['data']?.['id'] === ADA.id && r.headers?.['x-rate-limit-limit'] === '75';
|
|
647
|
+
})),
|
|
648
|
+
// ═══ conformance (the endpoint census with teeth) ═════════════════════════════════════════
|
|
649
|
+
done('xidentity.conformance.census', 'conformance', 'The conformance check passes: one real probe per claimed endpoint, the ROUTER_SURFACE census, the live round trip, and a reachability witness per resource type', 'api', 'core', async () => {
|
|
650
|
+
const report = await checkXIdentityConformance();
|
|
651
|
+
return report.ok && report.endpointsProbed === 4 && report.resourceTypesChecked === 9;
|
|
652
|
+
}),
|
|
653
|
+
// ═══ the authorize SCREEN (the vendor's own UI — data-coupled, per ADDING_A_TWIN.md §6) ═══
|
|
654
|
+
done('xidentity.ui.authorize_screen', 'ui', 'The authorize screen names the REGISTERED app and the SIGNED-IN account from the projection (data-coupled: a seeded client + persona appear on screen)', 'ui', 'core', uiDataCoupled({
|
|
655
|
+
withRoot,
|
|
656
|
+
// Seed a THIRD persona and a SECOND client through the twin's own write paths, then sign the
|
|
657
|
+
// new persona in — the screen cannot be passing on the seeded defaults alone.
|
|
658
|
+
seed: async (h) => {
|
|
659
|
+
await h({ m: 'POST', p: '/_twin/clients', b: JSON.stringify({ client_id: 'QWNtZVNjaGVkdWxpbmc6MTpjaQ', client_secret: 'acme-secret', name: 'Acme Scheduling', client_type: 'confidential', redirect_uris: [REDIRECT] }) });
|
|
660
|
+
await h({ m: 'POST', p: '/_twin/accounts', b: JSON.stringify({ id: '9000000000000000003', username: 'katherine_twin', name: 'Katherine Johnson' }) });
|
|
661
|
+
await h({ m: 'POST', p: '/_twin/session', b: JSON.stringify({ account_id: '9000000000000000003' }) });
|
|
662
|
+
},
|
|
663
|
+
fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ client_id: 'QWNtZVNjaGVkdWxpbmc6MTpjaQ' }) })),
|
|
664
|
+
assert: (markup) => markup.includes('Acme Scheduling')
|
|
665
|
+
&& markup.includes('wants to access your X account')
|
|
666
|
+
&& markup.includes('Katherine Johnson') && markup.includes('@katherine_twin')
|
|
667
|
+
&& !markup.includes('Twin Demo App') && !markup.includes('@ada_twin'),
|
|
668
|
+
})),
|
|
669
|
+
done('xidentity.ui.scope_sections', 'ui', 'The screen groups scopes under "Things … can view" / "Things … can do" with X\'s own consent wording from the catalog (data-coupled to the auth request)', 'ui', 'core', uiDataCoupled({
|
|
670
|
+
withRoot,
|
|
671
|
+
seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'tweet.read dm.write offline.access' }) }),
|
|
672
|
+
fetch: async (h) => {
|
|
673
|
+
const a = await h({ m: 'GET', p: authUrl({ scope: 'tweet.read dm.write offline.access' }) });
|
|
674
|
+
const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
|
|
675
|
+
return html(await h({ m: 'GET', p: `/_twin/consent?auth_request=${rid}` }));
|
|
676
|
+
},
|
|
677
|
+
assert: (markup) => markup.includes('can view')
|
|
678
|
+
&& markup.includes('can do')
|
|
679
|
+
&& markup.includes('View all posts you can see, including those from protected accounts.')
|
|
680
|
+
&& markup.includes('Send and manage your Direct Messages.')
|
|
681
|
+
&& markup.includes('Stay connected to your account until you revoke access.'),
|
|
682
|
+
})),
|
|
683
|
+
done('xidentity.ui.decision_buttons', 'ui', 'The screen\'s two decisions are REAL form submits — "Authorize app" and "Cancel" carry decision values into the twin\'s consent post', 'ui', 'core', uiDataCoupled({
|
|
684
|
+
withRoot,
|
|
685
|
+
seed: (h) => h({ m: 'GET', p: authUrl() }),
|
|
686
|
+
fetch: async (h) => {
|
|
687
|
+
const a = await h({ m: 'GET', p: authUrl() });
|
|
688
|
+
const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
|
|
689
|
+
return html(await h({ m: 'GET', p: `/_twin/consent?auth_request=${rid}` }));
|
|
690
|
+
},
|
|
691
|
+
// Attribute ORDER is the renderer's business (React emits value before name) — assert each
|
|
692
|
+
// decision button as a whole tag, order-independent within it.
|
|
693
|
+
assert: (markup) => /<button[^>]*value="allow"[^>]*>Authorize app<\/button>/.test(markup)
|
|
694
|
+
&& /<button[^>]*value="deny"[^>]*>Cancel<\/button>/.test(markup)
|
|
695
|
+
&& (markup.match(/name="decision"/g) ?? []).length === 2
|
|
696
|
+
&& markup.includes('/_twin/consent'),
|
|
697
|
+
})),
|
|
698
|
+
done('xidentity.ui.redirect_notice', 'ui', 'The screen shows WHERE the browser will be sent — the validated redirect_uri\'s host, from the auth request (data-coupled: the SECOND registered callback\'s host, so a hardcoded first/default host fails)', 'ui', 'common', uiDataCoupled({
|
|
699
|
+
withRoot,
|
|
700
|
+
seed: (h) => h({ m: 'GET', p: authUrl({ redirect_uri: REDIRECT_URIS[1] }) }),
|
|
701
|
+
fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ redirect_uri: REDIRECT_URIS[1] }) })),
|
|
702
|
+
// §9 round one: asserting the FIRST registered callback's host could not discriminate a
|
|
703
|
+
// data-coupled notice from a hardcoded one. The request names the second (127.0.0.1) and
|
|
704
|
+
// the first's host must be ABSENT.
|
|
705
|
+
// Both hosts are DERIVED from the registered callbacks, never named as ports here (R7).
|
|
706
|
+
// The assertion is pinned to the NOTICE ELEMENT itself rather than the whole page: the twin
|
|
707
|
+
// is served at the first callback's origin, so that host also appears in the consent form's
|
|
708
|
+
// own action — a whole-markup `!includes` would now fail for a reason unrelated to coupling.
|
|
709
|
+
assert: (markup) => markup.includes('redirected to')
|
|
710
|
+
&& markup.includes(`class="redirect-host">${new URL(REDIRECT_URIS[1]).host}<`)
|
|
711
|
+
&& !markup.includes(`class="redirect-host">${new URL(REDIRECT_URIS[0]).host}<`),
|
|
712
|
+
})),
|
|
713
|
+
done('xidentity.ui.unknown_scope_flagged', 'ui', 'A scope outside the vendor catalog renders VISIBLY FLAGGED rather than silently or prettily (a typo\'d scope is a real integration bug)', 'ui', 'common', uiDataCoupled({
|
|
714
|
+
withRoot,
|
|
715
|
+
seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'tweet.read totally.bogus' }) }),
|
|
716
|
+
fetch: async (h) => html(await h({ m: 'GET', p: authUrl({ scope: 'tweet.read totally.bogus' }) })),
|
|
717
|
+
assert: (markup) => markup.includes('totally.bogus') && markup.includes('not in the twin's scope catalog'),
|
|
718
|
+
})),
|
|
719
|
+
done('xidentity.ui.consent_state_renders_real_component', 'ui', 'The SHIPPED ConsentPage component renders the twin\'s OWN view model (the same state builder the served page uses) — one renderer, no lookalike', 'ui', 'core', uiDataCoupled({
|
|
720
|
+
withRoot,
|
|
721
|
+
seed: (h) => h({ m: 'GET', p: authUrl({ scope: 'tweet.read users.read' }) }),
|
|
722
|
+
fetch: async (h) => {
|
|
723
|
+
const a = await h({ m: 'GET', p: authUrl({ scope: 'tweet.read users.read' }) });
|
|
724
|
+
const rid = AUTH_REQUEST_RE.exec(html(a))?.[1] ?? '';
|
|
725
|
+
return xIdentityConsentState({ root: h.root, requestId: rid, origin: 'https://x.com' });
|
|
726
|
+
},
|
|
727
|
+
render: (view) => (view ? renderToStaticMarkup(createElement(ConsentPage, { view })) : ''),
|
|
728
|
+
assert: (view, markup) => !!view && !!markup
|
|
729
|
+
&& view.scopes.length === 2
|
|
730
|
+
&& view.account.username === ADA.username
|
|
731
|
+
&& markup.includes('View any account you can see, including protected accounts.')
|
|
732
|
+
&& markup.includes(`@${ADA.username}`),
|
|
733
|
+
})),
|
|
734
|
+
done('xidentity.ui.error_page_renders_real_component', 'ui', 'The authorize endpoint SERVES the real ErrorPage component for an un-redirectable failure (driven through the handler, not a lookalike render)', 'ui', 'common', () =>
|
|
735
|
+
// §9 round one: rendering the component over literal props proved the component, not the
|
|
736
|
+
// route. This drives the failure the way a browser hits it and asserts the SERVED markup.
|
|
737
|
+
withRoot(async (h) => {
|
|
738
|
+
const served = await h({ m: 'GET', p: authUrl({ client_id: 'bm90LWEtcmVhbC1jbGllbnQ6MTpjaQ' }) });
|
|
739
|
+
// The SERVED page must literally CONTAIN the real component's own render for the same
|
|
740
|
+
// failure facts — a hand-rolled template carrying the same headline strings cannot pass
|
|
741
|
+
// (§9 round two: asserting the direct render only against itself was a tautology).
|
|
742
|
+
const direct = renderToStaticMarkup(createElement(ErrorPage, {
|
|
743
|
+
status: 401,
|
|
744
|
+
code: 'invalid_client',
|
|
745
|
+
detail: 'The client_id does not name a known App.',
|
|
746
|
+
requestParam: 'client_id=bm90LWEtcmVhbC1jbGllbnQ6MTpjaQ',
|
|
747
|
+
}));
|
|
748
|
+
return served.status === 401
|
|
749
|
+
&& html(served).includes('Something went wrong')
|
|
750
|
+
&& html(served).includes(direct);
|
|
751
|
+
})),
|
|
752
|
+
done('xidentity.ui.client_transpiles', 'ui', 'The consent client bundles for the browser (structural: the shipped TSX transpiles and carries the hydration entry)', 'ui', 'niche', async () => {
|
|
753
|
+
// The REAL bundle the twin serves — the COMMITTED xidentity-consent-client.gen.ts, which
|
|
754
|
+
// consent-clients.test.ts proves is a fresh build of the TSX.
|
|
755
|
+
const js = CONSENT_CLIENT_JS;
|
|
756
|
+
return js.length > 1000 && js.includes('consent-enhanced');
|
|
757
|
+
}),
|
|
758
|
+
// ═══ connector (dimension: connector — the saboteur phase keys on this) ═══════════════════
|
|
759
|
+
done('xidentity.connector.pull_identity', 'connector', 'Pull observes the real account behind the operator\'s token (real-shaped /2/users/me reply over an injected fake) and folds it into the twin — the persona then serves through the projection', 'connector', 'core', async () => verifyBoundary('xidentity.connector.pull_identity', async () => {
|
|
760
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-conn-'));
|
|
761
|
+
try {
|
|
762
|
+
const n = await pullXIdentity(fakeExecute, root, '2026-02-01T00:00:01.000Z');
|
|
763
|
+
const { readOne } = await import("./xidentity-store.js");
|
|
764
|
+
const row = readOne(root, 'account', '2244994945');
|
|
765
|
+
return n === 1 && row?.username === 'XDevelopers' && row?.verifiedType === 'business'
|
|
766
|
+
&& row?.publicMetrics?.followers_count === 583423 && row?.pulled === true;
|
|
767
|
+
}
|
|
768
|
+
finally {
|
|
769
|
+
rmSync(root, { recursive: true, force: true });
|
|
770
|
+
}
|
|
771
|
+
})),
|
|
772
|
+
done('xidentity.connector.pull_idempotent', 'connector', 'A re-pull of identical vendor state appends NOTHING (shadow-diffed syncPull; deltasAppended 0 on the second pass)', 'connector', 'core', async () => verifyBoundary('xidentity.connector.pull_idempotent', async () => {
|
|
773
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-conn-'));
|
|
774
|
+
try {
|
|
775
|
+
const first = await syncXIdentityFromReal(fakeExecute, { root, occurredAt: '2026-02-01T00:00:01.000Z' });
|
|
776
|
+
const second = await syncXIdentityFromReal(fakeExecute, { root, occurredAt: '2026-02-01T00:00:02.000Z' });
|
|
777
|
+
return first.deltasAppended > 0 && second.deltasAppended === 0 && second.observed === 1;
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
rmSync(root, { recursive: true, force: true });
|
|
781
|
+
}
|
|
782
|
+
})),
|
|
783
|
+
done('xidentity.connector.pull_requests_modeled_fields', 'connector', 'The pull asks for every modelled user.field (a default-field pull would silently observe a bald persona)', 'connector', 'common', async () => verifyBoundary('xidentity.connector.pull_requests_modeled_fields', async () => {
|
|
784
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-conn-'));
|
|
785
|
+
const seen = [];
|
|
786
|
+
const recording = async (m, p) => {
|
|
787
|
+
seen.push(`${m} ${p}`);
|
|
788
|
+
return structuredClone(FAKE_USERS_ME);
|
|
789
|
+
};
|
|
790
|
+
try {
|
|
791
|
+
await pullXIdentity(recording, root, '2026-02-01T00:00:01.000Z');
|
|
792
|
+
const call = seen[0] ?? '';
|
|
793
|
+
return seen.length === 1 && call.startsWith('GET /2/users/me?user.fields=')
|
|
794
|
+
&& ['created_at', 'profile_image_url', 'public_metrics', 'verified_type', 'confirmed_email'].every((f) => call.includes(f));
|
|
795
|
+
}
|
|
796
|
+
finally {
|
|
797
|
+
rmSync(root, { recursive: true, force: true });
|
|
798
|
+
}
|
|
799
|
+
})),
|
|
800
|
+
done('xidentity.connector.refused_pull_throws', 'connector', 'A REFUSED pull is NOT an empty account: a problem-envelope reply (even under HTTP 200) throws the REFUSAL instead of folding emptiness over observed state', 'connector', 'core', async () => verifyBoundary('xidentity.connector.refused_pull_throws', async () => {
|
|
801
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-conn-'));
|
|
802
|
+
const refusing = async () => ({ title: 'Unauthorized', type: 'about:blank', status: 401, detail: 'Unauthorized' });
|
|
803
|
+
try {
|
|
804
|
+
// The HAPPY path must still work first — otherwise a dead connector that throws on
|
|
805
|
+
// EVERYTHING would satisfy the refusal expectation below (the mutation gate caught
|
|
806
|
+
// exactly that: a bare "it threw" is what a sabotaged pull also does).
|
|
807
|
+
const n = await pullXIdentity(fakeExecute, root, '2026-02-01T00:00:01.000Z');
|
|
808
|
+
if (n !== 1)
|
|
809
|
+
return false;
|
|
810
|
+
await pullXIdentity(refusing, root, '2026-02-01T00:00:02.000Z');
|
|
811
|
+
return false; // the refusal must throw
|
|
812
|
+
}
|
|
813
|
+
catch (e) {
|
|
814
|
+
// …and the throw must be the connector's OWN refusal, not an arbitrary failure.
|
|
815
|
+
if (!String(e).includes('refused'))
|
|
816
|
+
return false;
|
|
817
|
+
const { readOne, readType } = await import("./xidentity-store.js");
|
|
818
|
+
// The earlier good pull SURVIVES the refused one — nothing was folded over it.
|
|
819
|
+
return readType(root, 'account').length === 1 && readOne(root, 'account', '2244994945')?.username === 'XDevelopers';
|
|
820
|
+
}
|
|
821
|
+
finally {
|
|
822
|
+
rmSync(root, { recursive: true, force: true });
|
|
823
|
+
}
|
|
824
|
+
})),
|
|
825
|
+
done('xidentity.connector.mapping_invents_nothing', 'connector', 'The account mapping records ONLY what the reply disclosed — omitted optional fields stay absent, never placeholders, INCLUDING name/username', 'connector', 'common', async () => {
|
|
826
|
+
const mapped = mapUsersMeAccount({ data: { id: '99', name: 'Terse', username: 'terse' } });
|
|
827
|
+
const f = mapped.fields;
|
|
828
|
+
// …and a partial reply (id only — the shape a partial-errors response can take) must not
|
|
829
|
+
// fold null over a previously-pulled persona's real name/username (§9 round one).
|
|
830
|
+
const partial = mapUsersMeAccount({ data: { id: '99' } });
|
|
831
|
+
const pf = partial.fields;
|
|
832
|
+
return mapped.id === '99' && f['username'] === 'terse'
|
|
833
|
+
&& !('createdAt' in f) && !('profileImageUrl' in f) && !('publicMetrics' in f) && f['pulled'] === true
|
|
834
|
+
&& !('username' in pf) && !('name' in pf) && pf['pulled'] === true;
|
|
835
|
+
}),
|
|
836
|
+
done('xidentity.connector.budget_enforced', 'connector', 'The live executor is fail-closed: at the burst ceiling the next call THROWS with the injected fetch\'s call count UNCHANGED (counted on the fake — nothing reached the vendor)', 'connector', 'core', async () => verifyBoundary('xidentity.connector.budget_enforced', async () => {
|
|
837
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-budget-'));
|
|
838
|
+
let calls = 0;
|
|
839
|
+
const fakeFetch = (async () => {
|
|
840
|
+
calls += 1;
|
|
841
|
+
return new Response(JSON.stringify(structuredClone(FAKE_USERS_ME)), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
842
|
+
});
|
|
843
|
+
try {
|
|
844
|
+
const clock = { t: 1_700_000_000_000 };
|
|
845
|
+
const execute = liveXIdentityExecute('cap-test-token', { fetchImpl: fakeFetch, budgetOptions: { path: join(root, 'ledger.json'), now: () => clock.t } });
|
|
846
|
+
for (let i = 0; i < XIDENTITY_BUDGET_BURST_CEILING; i += 1)
|
|
847
|
+
await execute('GET', '/2/users/me');
|
|
848
|
+
if (calls !== XIDENTITY_BUDGET_BURST_CEILING)
|
|
849
|
+
return false;
|
|
850
|
+
try {
|
|
851
|
+
await execute('GET', '/2/users/me');
|
|
852
|
+
return false; // the ceiling must throw
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
return e instanceof XIdentityBudgetError && calls === XIDENTITY_BUDGET_BURST_CEILING;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
finally {
|
|
859
|
+
rmSync(root, { recursive: true, force: true });
|
|
860
|
+
}
|
|
861
|
+
})),
|
|
862
|
+
done('xidentity.connector.unmapped_path_refused', 'connector', 'The live executor refuses an unmapped X path OUTRIGHT — zero requests issued (the raw-vendor-call ban, D8)', 'connector', 'common', async () => verifyBoundary('xidentity.connector.unmapped_path_refused', async () => {
|
|
863
|
+
const root = mkdtempSync(join(tmpdir(), 'xidentity-budget-'));
|
|
864
|
+
let calls = 0;
|
|
865
|
+
const fakeFetch = (async () => {
|
|
866
|
+
calls += 1;
|
|
867
|
+
return new Response('{}', { status: 200 });
|
|
868
|
+
});
|
|
869
|
+
try {
|
|
870
|
+
const execute = liveXIdentityExecute('cap-test-token', { fetchImpl: fakeFetch, budgetOptions: { path: join(root, 'ledger.json'), now: () => 1_700_000_000_000 } });
|
|
871
|
+
try {
|
|
872
|
+
await execute('GET', '/2/tweets/search/recent');
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return calls === 0;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
finally {
|
|
880
|
+
rmSync(root, { recursive: true, force: true });
|
|
881
|
+
}
|
|
882
|
+
})),
|
|
883
|
+
done('xidentity.connector.push_reports_impossible', 'connector', 'Push never fakes: X has no write API for this surface, so push confirms nothing and REPORTS the real pending count as unpushable', 'connector', 'common', () =>
|
|
884
|
+
// Runs inside withRoot so real local writes exist first — on a virgin root the pending count
|
|
885
|
+
// is zero BY CONSTRUCTION and "reports the pending count" would be unproven (§9 round one:
|
|
886
|
+
// deleting the pendingActions() read entirely stayed green against the old verify).
|
|
887
|
+
withRoot(async (h) => {
|
|
888
|
+
const before = pushPendingXIdentityActions(h.root);
|
|
889
|
+
const { status } = await fullFlow(h);
|
|
890
|
+
const after = pushPendingXIdentityActions(h.root);
|
|
891
|
+
return status === 200 && before.pushed === 0 && after.pushed === 0 && after.unpushable > before.unpushable;
|
|
892
|
+
})),
|
|
893
|
+
todo('xidentity.connector.push', 'connector', 'PUSH remains structurally impossible (X exposes no client/grant/user write API); revisit if X ever ships one', 'connector', 'niche'),
|
|
894
|
+
todo('xidentity.connector.pull_clients', 'connector', 'PULL the OAuth client registry — impossible today (X Apps live in the developer portal; no API reads them)', 'connector', 'niche'),
|
|
895
|
+
todo('xidentity.connector.pull_grants', 'connector', 'PULL the token\'s granted scope set — impossible today (X has no tokeninfo/introspection endpoint)', 'connector', 'niche'),
|
|
896
|
+
// ═══ adjacent X auth surface the identity twin does not model yet (honest denominator) ═════
|
|
897
|
+
todo('xidentity.app_only.bearer_token', 'app_only', 'Model the app-only bearer flow (POST /oauth2/token, grant_type=client_credentials, the v1.1 path)', 'api', 'common'),
|
|
898
|
+
todo('xidentity.app_only.invalidate_token', 'app_only', 'Model POST /oauth2/invalidate_token for app-only bearers', 'api', 'niche'),
|
|
899
|
+
todo('xidentity.oauth1.request_token', 'oauth1', 'Model OAuth 1.0a step 1: POST /oauth/request_token', 'api', 'niche'),
|
|
900
|
+
todo('xidentity.oauth1.authorize', 'oauth1', 'Model OAuth 1.0a step 2: GET /oauth/authorize (the legacy consent screen)', 'api', 'niche'),
|
|
901
|
+
todo('xidentity.oauth1.access_token', 'oauth1', 'Model OAuth 1.0a step 3: POST /oauth/access_token', 'api', 'niche'),
|
|
902
|
+
];
|
|
903
|
+
export function xidentityCapabilities() {
|
|
904
|
+
return checkCapabilities('xidentity', XIDENTITY_CAPABILITIES);
|
|
905
|
+
}
|