@volter/twin-xidentity 0.1.0 → 0.1.2

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.
@@ -22,13 +22,29 @@ export type ConsentView = {
22
22
  /** The x.com session this browser is signed in as — the account that will consent. */
23
23
  account: ConsentAccount;
24
24
  scopes: ConsentScopeRow[];
25
- /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. */
25
+ /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. Empty for
26
+ * an OAuth 1.0a out-of-band (PIN) request, which redirects nowhere. */
26
27
  redirectHost: string;
28
+ /** Where the decision posts, under `origin`: `/_twin/consent` for an OAuth 2.0 authorize screen,
29
+ * `/oauth/authorize` for an OAuth 1.0a one, as X's own screen posts (the request token is the handle). */
30
+ decisionPath?: string;
27
31
  };
28
32
  /** The authorize screen — X's consent page for the app named by the pending auth request. */
29
33
  export declare function ConsentPage({ view }: {
30
34
  view: ConsentView;
31
35
  }): import("react").JSX.Element;
36
+ export type PinPageProps = {
37
+ appName: string;
38
+ /** The oauth_verifier, shown as the PIN the person types into the app (out-of-band flow). */
39
+ pin: string;
40
+ };
41
+ /**
42
+ * The OAuth 1.0a out-of-band ending: an app that registered `oauth_callback=oob` gets no redirect,
43
+ * so X shows the verifier as a PIN for the person to type into the app (docs.x.com PIN-based
44
+ * authorization). The layout follows the classic screen as widely screenshotted; its live DOM was
45
+ * not captured (`xidentity.oauth1.consent_wording`, todo).
46
+ */
47
+ export declare function PinPage({ appName, pin }: PinPageProps): import("react").JSX.Element;
32
48
  export type ErrorPageProps = {
33
49
  status: number;
34
50
  /** The OAuth error code the failure classifies as (e.g. `invalid_request`). */
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  // X's AUTHORIZE SCREEN, as React — the vendor's own product UI, not a dashboard mirror.
3
3
  //
4
4
  // These components are the ONE renderer for the screen: the twin server-renders them with
@@ -29,7 +29,16 @@ function ScopeSection({ heading, rows }) {
29
29
  /** The authorize screen — X's consent page for the app named by the pending auth request. */
30
30
  export function ConsentPage({ view }) {
31
31
  const appName = view.app.name;
32
- return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsx("div", { className: "signed-in", children: _jsxs("span", { className: "account-text", children: [_jsx("span", { className: "account-name", children: view.account.name || view.account.username || view.account.id }), view.account.username ? _jsxs("span", { className: "account-username", children: ["@", view.account.username] }) : null] }) }), _jsxs("h1", { className: "title", children: [_jsx("strong", { className: "app-name", children: appName }), " wants to access your X account"] }), _jsx(ScopeSection, { heading: `Things ${appName} can view`, rows: view.scopes.filter((s) => s.group === 'view') }), _jsx(ScopeSection, { heading: `Things ${appName} can do`, rows: view.scopes.filter((s) => s.group === 'do') }), _jsx(ScopeSection, { heading: "Until you revoke access", rows: view.scopes.filter((s) => s.group === 'session') }), _jsxs("form", { method: "POST", action: `${view.origin}/_twin/consent`, className: "consent-form", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), _jsxs("div", { className: "actions", children: [_jsx("button", { className: "btn btn-primary", type: "submit", name: "decision", value: "allow", children: "Authorize app" }), _jsx("button", { className: "btn btn-secondary", type: "submit", name: "decision", value: "deny", children: "Cancel" })] })] }), _jsxs("p", { className: "legal", children: ["You'll be redirected to ", _jsx("strong", { className: "redirect-host", children: view.redirectHost }), ". You can revoke access to any app at any time from the Apps and sessions section of your X settings."] })] }));
32
+ return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsx("div", { className: "signed-in", children: _jsxs("span", { className: "account-text", children: [_jsx("span", { className: "account-name", children: view.account.name || view.account.username || view.account.id }), view.account.username ? _jsxs("span", { className: "account-username", children: ["@", view.account.username] }) : null] }) }), _jsxs("h1", { className: "title", children: [_jsx("strong", { className: "app-name", children: appName }), " wants to access your X account"] }), _jsx(ScopeSection, { heading: `Things ${appName} can view`, rows: view.scopes.filter((s) => s.group === 'view') }), _jsx(ScopeSection, { heading: `Things ${appName} can do`, rows: view.scopes.filter((s) => s.group === 'do') }), _jsx(ScopeSection, { heading: "Until you revoke access", rows: view.scopes.filter((s) => s.group === 'session') }), _jsxs("form", { method: "POST", action: `${view.origin}${view.decisionPath ?? '/_twin/consent'}`, className: "consent-form", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), _jsxs("div", { className: "actions", children: [_jsx("button", { className: "btn btn-primary", type: "submit", name: "decision", value: "allow", children: "Authorize app" }), _jsx("button", { className: "btn btn-secondary", type: "submit", name: "decision", value: "deny", children: "Cancel" })] })] }), _jsxs("p", { className: "legal", children: [view.redirectHost ? (_jsxs(_Fragment, { children: ["You'll be redirected to ", _jsx("strong", { className: "redirect-host", children: view.redirectHost }), ".", ' '] })) : null, "You can revoke access to any app at any time from the Apps and sessions section of your X settings."] })] }));
33
+ }
34
+ /**
35
+ * The OAuth 1.0a out-of-band ending: an app that registered `oauth_callback=oob` gets no redirect,
36
+ * so X shows the verifier as a PIN for the person to type into the app (docs.x.com PIN-based
37
+ * authorization). The layout follows the classic screen as widely screenshotted; its live DOM was
38
+ * not captured (`xidentity.oauth1.consent_wording`, todo).
39
+ */
40
+ export function PinPage({ appName, pin }) {
41
+ return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsxs("h1", { className: "title", children: ["You've granted access to ", _jsx("strong", { className: "app-name", children: appName }), "!"] }), _jsxs("p", { className: "legal", children: ["Next, return to ", appName, " and enter this PIN to complete the authorization process:"] }), _jsx("p", { className: "title", children: _jsx("code", { className: "oauth-pin", children: pin }) })] }));
33
42
  }
34
43
  /**
35
44
  * The authorize-endpoint error PAGE — what a browser sees when the twin cannot trust the
@@ -44,8 +44,12 @@ export type ConsentView = {
44
44
  /** The x.com session this browser is signed in as — the account that will consent. */
45
45
  account: ConsentAccount;
46
46
  scopes: ConsentScopeRow[];
47
- /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. */
47
+ /** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. Empty for
48
+ * an OAuth 1.0a out-of-band (PIN) request, which redirects nowhere. */
48
49
  redirectHost: string;
50
+ /** Where the decision posts, under `origin`: `/_twin/consent` for an OAuth 2.0 authorize screen,
51
+ * `/oauth/authorize` for an OAuth 1.0a one, as X's own screen posts (the request token is the handle). */
52
+ decisionPath?: string;
49
53
  };
50
54
 
51
55
  /** The X mark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
@@ -96,7 +100,7 @@ export function ConsentPage({ view }: { view: ConsentView }) {
96
100
  <ScopeSection heading={`Things ${appName} can view`} rows={view.scopes.filter((s) => s.group === 'view')} />
97
101
  <ScopeSection heading={`Things ${appName} can do`} rows={view.scopes.filter((s) => s.group === 'do')} />
98
102
  <ScopeSection heading="Until you revoke access" rows={view.scopes.filter((s) => s.group === 'session')} />
99
- <form method="POST" action={`${view.origin}/_twin/consent`} className="consent-form">
103
+ <form method="POST" action={`${view.origin}${view.decisionPath ?? '/_twin/consent'}`} className="consent-form">
100
104
  <input type="hidden" name="auth_request" value={view.requestId} />
101
105
  <div className="actions">
102
106
  <button className="btn btn-primary" type="submit" name="decision" value="allow">
@@ -108,8 +112,41 @@ export function ConsentPage({ view }: { view: ConsentView }) {
108
112
  </div>
109
113
  </form>
110
114
  <p className="legal">
111
- You&apos;ll be redirected to <strong className="redirect-host">{view.redirectHost}</strong>. You can revoke
112
- access to any app at any time from the Apps and sessions section of your X settings.
115
+ {view.redirectHost ? (
116
+ <>
117
+ You&apos;ll be redirected to <strong className="redirect-host">{view.redirectHost}</strong>.{' '}
118
+ </>
119
+ ) : null}
120
+ You can revoke access to any app at any time from the Apps and sessions section of your X settings.
121
+ </p>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ export type PinPageProps = {
127
+ appName: string;
128
+ /** The oauth_verifier, shown as the PIN the person types into the app (out-of-band flow). */
129
+ pin: string;
130
+ };
131
+
132
+ /**
133
+ * The OAuth 1.0a out-of-band ending: an app that registered `oauth_callback=oob` gets no redirect,
134
+ * so X shows the verifier as a PIN for the person to type into the app (docs.x.com PIN-based
135
+ * authorization). The layout follows the classic screen as widely screenshotted; its live DOM was
136
+ * not captured (`xidentity.oauth1.consent_wording`, todo).
137
+ */
138
+ export function PinPage({ appName, pin }: PinPageProps) {
139
+ return (
140
+ <div className="card">
141
+ <header className="x-header">
142
+ <XMark />
143
+ </header>
144
+ <h1 className="title">
145
+ You&apos;ve granted access to <strong className="app-name">{appName}</strong>!
146
+ </h1>
147
+ <p className="legal">Next, return to {appName} and enter this PIN to complete the authorization process:</p>
148
+ <p className="title">
149
+ <code className="oauth-pin">{pin}</code>
113
150
  </p>
114
151
  </div>
115
152
  );
@@ -11,5 +11,7 @@ export { XIDENTITY_BUDGET_CEILING, XIDENTITY_BUDGET_MAX_RETRY_AFTER_S, XIDENTITY
11
11
  export type { XIdentityBudgetErrorKind, XIdentityBudgetOptions, XIdentityBudgetReservation, XIdentityBudgetSnapshot, } from './xidentity-budget.js';
12
12
  export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, xIdentityConsentState, } from './xidentity-consent-ui.js';
13
13
  export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './xidentity-consent-ui.js';
14
+ export { OAUTH1_ENDPOINTS, oauth1AppOf, oauth1Params, parseOAuthHeader, pct, scopesOfAccessLevel, signatureMatches, signedBaseUrl, signedRequestOf, verifyUserContext, WORLD_APP_ENV, worldOAuth1App, } from './xidentity-oauth1.js';
15
+ export type { OAuth1App, OAuth1Principal, SignedRequest } from './xidentity-oauth1.js';
14
16
  import type { TwinPack } from '@volter/world-core';
15
17
  export declare const pack: TwinPack;
package/dist/src/index.js CHANGED
@@ -20,6 +20,7 @@ export { liveXIdentityExecute, mapUsersMeAccount, PULL_USER_FIELDS, pullXIdentit
20
20
  // `XIdentityBudgetError` by type; there is deliberately no export that disables the guard.
21
21
  export { XIDENTITY_BUDGET_CEILING, XIDENTITY_BUDGET_MAX_RETRY_AFTER_S, XIDENTITY_BUDGET_WINDOW_MS, XIDENTITY_CALL_WEIGHTS, XIDENTITY_RATE_BUDGET, XIdentityBudget, XIdentityBudgetError, xIdentityBudgetPath, xIdentityCallWeight, } from "./xidentity-budget.js";
22
22
  export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, xIdentityConsentState, } from "./xidentity-consent-ui.js";
23
+ export { OAUTH1_ENDPOINTS, oauth1AppOf, oauth1Params, parseOAuthHeader, pct, scopesOfAccessLevel, signatureMatches, signedBaseUrl, signedRequestOf, verifyUserContext, WORLD_APP_ENV, worldOAuth1App, } from "./xidentity-oauth1.js";
23
24
  import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from "./xidentity-budget.js";
24
25
  /**
25
26
  * The x.com / twitter.com paths THIS pack serves — the consent leg — as a RegExp SOURCE for the
@@ -32,13 +33,18 @@ import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from "./xidentity-budget.js";
32
33
  */
33
34
  const XIDENTITY_CONSENT_PATHS = '^/i/oauth2/authorize/?$|^/_twin/';
34
35
  /**
35
- * The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints
36
- * and the authenticated-user read. api.x.com is the ENTIRE X API v2 host — posts, DMs, spaces — and
36
+ * The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints,
37
+ * the authenticated-user read, and OAuth 1.0a's three legs (request_token, the authorize and
38
+ * authenticate screens — served on api.x.com itself, where X serves them — and access_token) plus
39
+ * 1.1/oauth/invalidate_token, and the page assets that screen loads from the host it is on
40
+ * (`/_twin/assets/consent.{js,css}`; the `x` pack's `/_twin/` claim leaves exactly these two out, so
41
+ * the claims stay disjoint). The screen's Authorize app form posts to `POST oauth/authorize`, which
42
+ * the same rule already covers. api.x.com is the ENTIRE X API v2 host — posts, DMs, spaces — and
37
43
  * this pack models the identity slice only, so nothing else is claimed: an unmodelled X call must
38
- * refuse loudly rather than land in a twin that cannot serve it. Transcribed unchanged from
39
- * inject.cjs's former `isXIdentityApiPath` predicate.
44
+ * refuse loudly rather than land in a twin that cannot serve it. First transcribed from
45
+ * inject.cjs's former `isXIdentityApiPath` predicate; the asset paths were added 2026-09-28.
40
46
  */
41
- const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)/?$';
47
+ const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)/?$|^/oauth/(?:request_token|authorize|authenticate|access_token)/?$|^/1\\.1/oauth/invalidate_token(?:\\.json)?/?$|^/_twin/assets/consent\\.(?:js|css)$';
42
48
  export const pack = {
43
49
  // STILL PROTOCOL 1, and the reason is the gate rather than the pack (ROADMAP.md,
44
50
  // shape-parity-refusals: a refusal the harness cannot express). Everything else is ready: the connector
@@ -54,13 +60,15 @@ export const pack = {
54
60
  transport: 'rest',
55
61
  archetype: 'crud',
56
62
  bin: 'world-xidentity',
57
- resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window'],
63
+ resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window', 'oauth1_app', 'oauth1_request_token', 'oauth1_token', 'oauth1_grant'],
58
64
  specSource: 'api.x.com/2/openapi.json (X API v2 OpenAPI 2.167: /2/users/me, the Problem family, the '
59
65
  + '24-scope OAuth2UserToken catalog) + docs.x.com OAuth 2.0 authorization-code/user-access-token '
60
66
  + 'guides + the official SDK sources (@xdevplatform/xdk, twitter-api-typescript-sdk), all '
61
- + 'fetched 2026-08-21',
67
+ + 'fetched 2026-08-21; OAuth 1.0a from docs.x.com authentication/api-reference, obtaining-user-access-tokens, '
68
+ + 'creating-a-signature and developer-apps, fetched 2026-09-27',
62
69
  description: 'X (Twitter) identity twin — the real x.com authorize screen, the full OAuth 2.0 '
63
- + 'authorization-code + PKCE round trip (token, refresh, revoke), and GET /2/users/me with '
70
+ + 'authorization-code + PKCE round trip (token, refresh, revoke), the OAuth 1.0a three-legged flow '
71
+ + '(request_token, the authorize/authenticate screen, access_token, invalidate_token), and GET /2/users/me with '
64
72
  + "X's real field/expansion/problem envelopes. Non-OIDC, exactly as the vendor is.",
65
73
  // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
66
74
  // 2026-08-31).
@@ -47,7 +47,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
47
47
  import { checkCapabilities, uiDataCoupled, verifyBoundary } from '@volter/world-tooling';
48
48
  import { ConsentPage, ErrorPage } from "../client/xidentity-consent.js";
49
49
  import { CONSENT_CLIENT_JS, xIdentityConsentState } from "./xidentity-consent-ui.js";
50
- import { checkXIdentityConformance } from "./xidentity-conformance.js";
50
+ import { checkXIdentityConformance, oauth1Fixtures, oauth1Header, OAUTH1_PROBE_APP, OAUTH1_PROBE_CALLBACK } from "./xidentity-conformance.js";
51
51
  import { XIdentityBudgetError, XIDENTITY_BUDGET_BURST_CEILING } from "./xidentity-budget.js";
52
52
  import { liveXIdentityExecute, mapUsersMeAccount, pullXIdentity, pushPendingXIdentityActions, syncXIdentityFromReal, } from "./xidentity-connector.js";
53
53
  import { pkceS256 } from "./xidentity-pkce.js";
@@ -99,6 +99,14 @@ const qp = (r, key) => {
99
99
  };
100
100
  const form = (params) => new URLSearchParams(params).toString();
101
101
  const decoded = (token) => Buffer.from(String(token ?? ''), 'base64url').toString('utf8');
102
+ // ── OAuth 1.0a helpers: the probe App and its independent signer live in xidentity-conformance.ts ──
103
+ const FORM_H = { 'content-type': 'application/x-www-form-urlencoded' };
104
+ /** Register the probe App through the twin door (X's developer console has no API). */
105
+ const oauth1App = (h) => h({ m: 'POST', p: '/_twin/oauth1_apps', b: JSON.stringify({ consumer_key: OAUTH1_PROBE_APP.key, consumer_secret: OAUTH1_PROBE_APP.secret, name: 'Probe App', callback_urls: [OAUTH1_PROBE_CALLBACK] }) });
106
+ /** The conformance fixtures' call shape over this verify's handler. */
107
+ const oauthCall = (h) => (m, p, b, headers) => h({ m, p, ...(b === undefined ? {} : { b }), ...(headers ? { h: headers } : {}) });
108
+ /** The legacy v1.1 error code of a refusal. */
109
+ const v1Code = (r) => r.body?.errors?.[0]?.code;
102
110
  /** Build an authorize URL from parameters, so a verify can vary exactly one of them. `undefined`
103
111
  * REMOVES a base parameter. */
104
112
  function authUrl(params = {}) {
@@ -500,6 +508,11 @@ export const XIDENTITY_CAPABILITIES = [
500
508
  && d['verified'] === true
501
509
  && d['verified_type'] === 'blue';
502
510
  })),
511
+ // The x pack answers user.fields=profile_image_url with X's default avatar for an account seeded
512
+ // without a photo (every X account has one); this pack omits it, because a PARTIALLY-pulled persona
513
+ // cannot tell "no photo" from "not pulled" (renderUser). Serving the default for a seeded persona
514
+ // needs that distinction recorded on the row — until then the two packs answer differently (§9).
515
+ todo('xidentity.users_me.default_profile_image', 'users_me', "user.fields=profile_image_url for a persona seeded without a photo: X's default avatar URL (abs.twimg.com default_profile_normal.png), as the x pack serves it, rather than omission", 'api', 'common'),
503
516
  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
517
  const { tokens } = await fullFlow(h);
505
518
  const r = await h({ m: 'GET', p: '/2/users/me?user.fields=public_metrics', h: { authorization: `Bearer ${tokens['access_token']}` } });
@@ -560,7 +573,17 @@ export const XIDENTITY_CAPABILITIES = [
560
573
  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
574
  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
575
  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'),
576
+ done('xidentity.users_me.oauth1_user_context', 'users_me', 'GET /2/users/me honours OAuth 1.0a User Context, the alternate scheme the OpenAPI lists: a signed request answers the token\'s user, a wrong secret or unknown token is the 401', 'api', 'common', () => withRoot(async (h) => {
577
+ await oauth1App(h);
578
+ const at = await oauth1Fixtures(oauthCall(h)).accessToken();
579
+ const sign = (t, q = '') => oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me${q}`, OAUTH1_PROBE_APP, t);
580
+ const r = await h({ m: 'GET', p: '/2/users/me?user.fields=username,verified', h: { authorization: sign(at, '?user.fields=username,verified') } });
581
+ // the query is signed: the same header over a different query does not verify
582
+ const unsignedQuery = await h({ m: 'GET', p: '/2/users/me?user.fields=location', h: { authorization: sign(at, '?user.fields=username,verified') } });
583
+ const unknown = await h({ m: 'GET', p: '/2/users/me', h: { authorization: sign({ key: `${ADA.id}-NotIssued`, secret: at.secret }) } });
584
+ return r.status === 200 && body(r).data.id === ADA.id && body(r).data.username === ADA.username && body(r).data.verified === ADA.verified
585
+ && unsignedQuery.status === 401 && unknown.status === 401;
586
+ })),
564
587
  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
588
  // ═══ rate envelope ════════════════════════════════════════════════════════════════════════
566
589
  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) => {
@@ -633,12 +656,14 @@ export const XIDENTITY_CAPABILITIES = [
633
656
  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
657
  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
658
  // ═══ 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) => {
659
+ done('xidentity.readonly.writes_refused', 'readonly', 'A read-only twin refuses every credential-minting leg and the OAuth 1.0a screen\'s decision (405, protocol-shaped error body) — D3', 'api', 'common', () => withRoot(async (h) => {
637
660
  await fullFlow(h); // seed a world first, through the ordinary write path
638
661
  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
662
  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
663
  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;
664
+ // the OAuth 1.0a screen's decision settles consent: a write, though its path is also the GET screen's
665
+ const decide = await ro({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: 'x', decision: 'allow' }) });
666
+ return tok.status === 405 && body(tok)['error'] === 'temporarily_unavailable' && rev.status === 405 && decide.status === 405;
642
667
  })),
643
668
  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
669
  const { tokens } = await fullFlow(h);
@@ -648,7 +673,7 @@ export const XIDENTITY_CAPABILITIES = [
648
673
  // ═══ conformance (the endpoint census with teeth) ═════════════════════════════════════════
649
674
  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
675
  const report = await checkXIdentityConformance();
651
- return report.ok && report.endpointsProbed === 4 && report.resourceTypesChecked === 9;
676
+ return report.ok && report.endpointsProbed === 11 && report.resourceTypesChecked === 13;
652
677
  }),
653
678
  // ═══ the authorize SCREEN (the vendor's own UI — data-coupled, per ADDING_A_TWIN.md §6) ═══
654
679
  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({
@@ -896,9 +921,219 @@ export const XIDENTITY_CAPABILITIES = [
896
921
  // ═══ adjacent X auth surface the identity twin does not model yet (honest denominator) ═════
897
922
  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
923
  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'),
924
+ // ═══ OAuth 1.0a (xidentity-oauth1.ts; docs.x.com api-reference + obtaining-user-access-tokens) ══
925
+ done('xidentity.oauth1.request_token', 'oauth1', 'POST oauth/request_token signed by a registered App answers oauth_token, oauth_token_secret and oauth_callback_confirmed=true, and the token opens that App\'s screen', 'api', 'common', () => withRoot(async (h) => {
926
+ await oauth1App(h);
927
+ const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
928
+ const got = new URLSearchParams(String(r.body));
929
+ const token = got.get('oauth_token') ?? '';
930
+ const screen = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` });
931
+ return r.status === 200 && got.get('oauth_callback_confirmed') === 'true' && /^[A-Za-z0-9]{27}$/.test(token)
932
+ && (got.get('oauth_token_secret') ?? '').length > 0 && html(screen).includes('>Probe App<');
933
+ })),
934
+ done('xidentity.oauth1.authorize', 'oauth1', 'GET oauth/authorize shows X\'s screen naming the App and the signed-in account; Authorize app sends the person to the callback with exactly oauth_token and oauth_verifier', 'api', 'common', () => withRoot(async (h) => {
935
+ await oauth1App(h);
936
+ const fx = oauth1Fixtures(oauthCall(h));
937
+ const t = await fx.requestToken();
938
+ const screen = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
939
+ const handle = AUTH_REQUEST_RE.exec(html(screen))?.[1] ?? '';
940
+ const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: handle, decision: 'allow' }), h: FORM_H });
941
+ const back = new URL(loc(d) || 'http://invalid.test/');
942
+ return screen.status === 200 && html(screen).includes('<strong class="app-name">Probe App</strong> wants to access your X account')
943
+ && html(screen).includes(`@${ADA.username}`) && html(screen).includes('action="http://localhost:3000/oauth/authorize"')
944
+ && d.status === 302 && `${back.origin}${back.pathname}` === OAUTH1_PROBE_CALLBACK
945
+ && back.searchParams.get('oauth_token') === t.key && /^[A-Za-z0-9]{32}$/.test(back.searchParams.get('oauth_verifier') ?? '')
946
+ && [...back.searchParams.keys()].length === 2;
947
+ })),
948
+ // api.x.com is shared with the `x` pack, which claims that host's `/_twin/*` and comes first in
949
+ // the World's first-match routing. A decision door there reached the x pack in a World (the
950
+ // Postiz walk, 2026-09-28: "Authorize app" answered the x pack's JSON 400 and X never connected),
951
+ // while every in-process test posted straight to this twin's port. So the verify asks the
952
+ // World's own router which pack the screen's form lands on, for both API hosts, and then drives it.
953
+ done('xidentity.oauth1.authorize_form_routes_here', 'oauth1', 'Authorize app posts to POST oauth/authorize on the host that served the screen, as X\'s own screen does, and the screen\'s stylesheet and script load from that host — paths a World routes to this pack on api.x.com and api.twitter.com, not to the x pack beside it', 'ui', 'common', () => withRoot(async (h) => {
954
+ // the injector's own first-match table (compiled from pack-facts.json), untyped CommonJS
955
+ const { createRequire } = await import('node:module');
956
+ const { VENDOR_HOSTS } = createRequire(import.meta.url)('@volter/world-core/vendor-hosts');
957
+ const routedTo = (host, path) => Object.keys(VENDOR_HOSTS).find((k) => VENDOR_HOSTS[k](host, path));
958
+ await oauth1App(h);
959
+ const t = await oauth1Fixtures(oauthCall(h)).requestToken();
960
+ const screen = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent(t.key)}` });
961
+ const action = /<form [^>]*method="POST"[^>]*action="([^"]+)"|<form [^>]*action="([^"]+)"[^>]*method="POST"/.exec(html(screen));
962
+ const actionPath = new URL(action?.[1] ?? action?.[2] ?? 'http://invalid.test/nowhere').pathname;
963
+ const d = await h({ m: 'POST', p: actionPath, b: form({ auth_request: t.key, decision: 'allow' }), h: FORM_H });
964
+ // …and the page's own stylesheet and script, loaded from the same host (else it renders unstyled)
965
+ const assets = [...html(screen).matchAll(/(?:href|src)="([^"]+\/_twin\/assets\/[^"]+)"/g)].map((m) => new URL(m[1]).pathname);
966
+ const { createXIdentityTwinFetch } = await import("./xidentity-server.js");
967
+ const fetchTwin = createXIdentityTwinFetch({ root: h.root });
968
+ const served = await Promise.all(assets.map((p) => fetchTwin(new Request(`https://api.x.com${p}`))));
969
+ return actionPath === '/oauth/authorize' && routedTo('api.x.com', actionPath) === 'xidentity' && routedTo('api.twitter.com', actionPath) === 'xidentity'
970
+ && assets.length === 2 && assets.every((p) => routedTo('api.x.com', p) === 'xidentity' && routedTo('api.twitter.com', p) === 'xidentity')
971
+ && served.every((r) => r.status === 200)
972
+ // the x pack keeps the rest of the host's control prefix
973
+ && routedTo('api.x.com', '/_twin/accounts') === 'x'
974
+ && d.status === 302 && qp(d, 'oauth_token') === t.key && (qp(d, 'oauth_verifier') ?? '').length === 32;
975
+ })),
976
+ done('xidentity.oauth1.authorize_read_access', 'oauth1', 'x_auth_access_type=read narrows a Read-and-write App: the screen lists only what reading allows', 'ui', 'niche', () => withRoot(async (h) => {
977
+ await oauth1App(h);
978
+ const read = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK, x_auth_access_type: 'read' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK, x_auth_access_type: 'read' }) } });
979
+ const token = new URLSearchParams(String(read.body)).get('oauth_token') ?? '';
980
+ const screen = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` }));
981
+ const write = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent((await oauth1Fixtures(oauthCall(h)).requestToken()).key)}` }));
982
+ return screen.includes('Accounts you follow, mute, and block.') && !screen.includes('Post and delete Posts for you')
983
+ && write.includes('Post and delete Posts for you');
984
+ })),
985
+ done('xidentity.oauth1.authenticate_skips_approved', 'oauth1', 'GET oauth/authenticate (Sign in with X) redirects without a screen once the person has approved the App, and shows it the first time or with force_login=true', 'api', 'common', () => withRoot(async (h) => {
986
+ await oauth1App(h);
987
+ const fx = oauth1Fixtures(oauthCall(h));
988
+ const first = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}` });
989
+ await fx.approved();
990
+ const again = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}` });
991
+ const forced = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}&force_login=true` });
992
+ const back = new URL(loc(again) || 'http://invalid.test/');
993
+ return first.status === 200 && html(first).includes('Authorize app') && again.status === 302
994
+ && `${back.origin}${back.pathname}` === OAUTH1_PROBE_CALLBACK && (back.searchParams.get('oauth_verifier') ?? '').length === 32
995
+ && forced.status === 200 && html(forced).includes('Authorize app');
996
+ })),
997
+ done('xidentity.oauth1.cancel_denied', 'oauth1', 'Cancel sends the person back with denied=<request token>, and that token can no longer be exchanged', 'api', 'common', () => withRoot(async (h) => {
998
+ await oauth1App(h);
999
+ const t = await oauth1Fixtures(oauthCall(h)).requestToken();
1000
+ await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1001
+ const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: t.key, decision: 'deny' }), h: FORM_H });
1002
+ const x = await h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_verifier: 'x' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: 'x' }) } });
1003
+ return d.status === 302 && qp(d, 'denied') === t.key && qp(d, 'oauth_verifier') === null && x.status === 401 && v1Code(x) === 89;
1004
+ })),
1005
+ done('xidentity.oauth1.oob_pin', 'oauth1', 'oauth_callback=oob (PIN-based): approval shows the verifier as a 7-digit PIN, which access_token accepts', 'api', 'niche', () => withRoot(async (h) => {
1006
+ await oauth1App(h);
1007
+ const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: 'oob' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: 'oob' }) } });
1008
+ const t = { key: new URLSearchParams(String(r.body)).get('oauth_token') ?? '', secret: new URLSearchParams(String(r.body)).get('oauth_token_secret') ?? '' };
1009
+ await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1010
+ const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: t.key, decision: 'allow' }), h: FORM_H });
1011
+ const pin = /<code class="oauth-pin">([^<]+)<\/code>/.exec(html(d))?.[1] ?? '';
1012
+ const x = await h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_verifier: pin }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: pin }) } });
1013
+ return d.status === 200 && /^\d{7}$/.test(pin) && x.status === 200 && new URLSearchParams(String(x.body)).get('user_id') === ADA.id;
1014
+ })),
1015
+ done('xidentity.oauth1.access_token', 'oauth1', 'POST oauth/access_token trades an approved request token + verifier for `<user id>-<token>`, its secret, user_id and screen_name — once', 'api', 'common', () => withRoot(async (h) => {
1016
+ await oauth1App(h);
1017
+ const t = await oauth1Fixtures(oauthCall(h)).approved();
1018
+ // twitter-api-v2's shape (Postiz's login): oauth_token in the header AND the body, signed once
1019
+ const exchange = (verifier) => h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_token: t.key, oauth_verifier: verifier }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: verifier }) } });
1020
+ const wrong = await exchange('not-the-verifier');
1021
+ const r = await exchange(t.verifier);
1022
+ const got = new URLSearchParams(String(r.body));
1023
+ const replay = await exchange(t.verifier);
1024
+ return wrong.status === 401 && r.status === 200 && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(got.get('oauth_token') ?? '')
1025
+ && (got.get('oauth_token_secret') ?? '').length === 40 && got.get('user_id') === ADA.id && got.get('screen_name') === ADA.username
1026
+ && replay.status === 401 && v1Code(replay) === 89;
1027
+ })),
1028
+ done('xidentity.oauth1.reauthorize_same_token', 'oauth1', 'A person who approves the same App again gets the access token they already hold (X\'s tokens "do not expire"); a revoked one is replaced', 'api', 'niche', () => withRoot(async (h) => {
1029
+ await oauth1App(h);
1030
+ const fx = oauth1Fixtures(oauthCall(h));
1031
+ const a = await fx.accessToken();
1032
+ const b = await fx.accessToken();
1033
+ await h({ m: 'POST', p: '/1.1/oauth/invalidate_token', h: { authorization: oauth1Header('POST', `${DEMO_ORIGIN}/1.1/oauth/invalidate_token`, OAUTH1_PROBE_APP, a) } });
1034
+ const c = await fx.accessToken();
1035
+ return a.key !== '' && a.key === b.key && a.secret === b.secret && c.key !== a.key && c.key !== '';
1036
+ })),
1037
+ done('xidentity.oauth1.unknown_consumer_refused', 'oauth1', 'A consumer key X does not hold is 401 with code 32 "Could not authenticate you." and issues nothing', 'api', 'core', () => withRoot(async (h) => {
1038
+ await oauth1App(h);
1039
+ const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, { key: 'UnknownConsumer', secret: OAUTH1_PROBE_APP.secret }, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
1040
+ const none = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: FORM_H });
1041
+ return r.status === 401 && v1Code(r) === 32 && body(r).errors[0].message === 'Could not authenticate you.'
1042
+ && none.status === 400 && v1Code(none) === 215;
1043
+ })),
1044
+ done('xidentity.oauth1.bad_signature_refused', 'oauth1', 'A signature made with the wrong consumer secret, the wrong token secret, or over different parameters is refused at every leg and at /2/users/me', 'api', 'core', () => withRoot(async (h) => {
1045
+ await oauth1App(h);
1046
+ const wrongApp = { key: OAUTH1_PROBE_APP.key, secret: 'wrong-consumer-secret' };
1047
+ const leg1 = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, wrongApp, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
1048
+ // the body says one callback, the signature covered another
1049
+ const tampered = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: 'https://evil.local/cb' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
1050
+ const fx = oauth1Fixtures(oauthCall(h));
1051
+ const t = await fx.approved();
1052
+ const leg3 = await h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_verifier: t.verifier }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, { key: t.key, secret: 'wrong' }, { oauth_verifier: t.verifier }) } });
1053
+ const at = await fx.accessToken();
1054
+ const me = (secret, url = `${DEMO_ORIGIN}/2/users/me`) => h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', url, OAUTH1_PROBE_APP, { key: at.key, secret }) } });
1055
+ const good = await me(at.secret);
1056
+ const badToken = await me('wrong');
1057
+ const otherUrl = await me(at.secret, `${DEMO_ORIGIN}/2/users/me/other`);
1058
+ // a query realm is an ordinary parameter (only the header's is unsigned), and a repeated key is refused
1059
+ const signedPlain = oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me?user.fields=username`, OAUTH1_PROBE_APP, at);
1060
+ const realmInjected = await h({ m: 'GET', p: '/2/users/me?user.fields=username&realm=injected', h: { authorization: signedPlain } });
1061
+ // even signed over it: X accepts no duplicate keys
1062
+ const dup = '/2/users/me?user.fields=username&user.fields=username';
1063
+ const repeated = await h({ m: 'GET', p: dup, h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}${dup}`, OAUTH1_PROBE_APP, at) } });
1064
+ const plain = await h({ m: 'GET', p: '/2/users/me?user.fields=username', h: { authorization: signedPlain } });
1065
+ return leg1.status === 401 && v1Code(leg1) === 32 && tampered.status === 401 && v1Code(tampered) === 32
1066
+ && leg3.status === 401 && v1Code(leg3) === 32 && good.status === 200 && plain.status === 200
1067
+ && badToken.status === 401 && body(badToken).type === 'about:blank' && otherUrl.status === 401
1068
+ && realmInjected.status === 401 && repeated.status === 401;
1069
+ })),
1070
+ done('xidentity.oauth1.callback_not_approved', 'oauth1', 'A callback the App\'s settings do not list is refused with code 415 "Callback URL not approved for this client application."', 'api', 'common', () => withRoot(async (h) => {
1071
+ await oauth1App(h);
1072
+ const cb = 'https://probe-app.local/x/callback/';
1073
+ const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: cb }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: cb }) } });
1074
+ return r.status === 403 && v1Code(r) === 415 && body(r).errors[0].message === 'Callback URL not approved for this client application.';
1075
+ })),
1076
+ done('xidentity.oauth1.invalid_request_token_page', 'oauth1', 'An authorize link whose request token X does not hold (unknown or already used) shows the invalid-token page, not a screen', 'api', 'niche', () => withRoot(async (h) => {
1077
+ await oauth1App(h);
1078
+ const fx = oauth1Fixtures(oauthCall(h));
1079
+ const t = await fx.approved();
1080
+ const used = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1081
+ const unknown = await h({ m: 'GET', p: '/oauth/authorize?oauth_token=NotARequestToken' });
1082
+ return used.status === 400 && html(used).includes('The request token for this page is invalid') && !html(used).includes('Authorize app')
1083
+ && unknown.status === 400 && html(unknown).includes('The request token for this page is invalid');
1084
+ })),
1085
+ done('xidentity.oauth1.invalidate_token', 'oauth1', 'POST 1.1/oauth/invalidate_token answers {"access_token": …} and the token is then refused (401) at /2/users/me and again at invalidate (code 89)', 'api', 'niche', () => withRoot(async (h) => {
1086
+ await oauth1App(h);
1087
+ const at = await oauth1Fixtures(oauthCall(h)).accessToken();
1088
+ const inv = () => h({ m: 'POST', p: '/1.1/oauth/invalidate_token', h: { authorization: oauth1Header('POST', `${DEMO_ORIGIN}/1.1/oauth/invalidate_token`, OAUTH1_PROBE_APP, at) } });
1089
+ const before = await h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me`, OAUTH1_PROBE_APP, at) } });
1090
+ const r = await inv();
1091
+ const after = await h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me`, OAUTH1_PROBE_APP, at) } });
1092
+ const again = await inv();
1093
+ return before.status === 200 && r.status === 200 && body(r).access_token === at.key && after.status === 401 && again.status === 401 && v1Code(again) === 89;
1094
+ })),
1095
+ done('xidentity.oauth1.world_app', 'oauth1', 'The World\'s own App (X_API_KEY / X_API_SECRET the World sets) signs request_token and is named on the screen for the World; the same values from the caller\'s shell alone are no App', 'api', 'common', () => withRoot(async (h) => {
1096
+ const names = ['X_API_KEY', 'X_API_SECRET', 'VOLTER_WORLD_ENV_NAMES', 'VOLTER_WORLD_NAME'];
1097
+ const saved = Object.fromEntries(names.map((n) => [n, process.env[n]]));
1098
+ const app = { key: 'WorldConsumerKey000000001', secret: 'WorldConsumerSecret0000000000000000000000000001' };
1099
+ const ask = () => h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: 'https://postiz.local/integrations/social/x' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, app, null, { oauth_callback: 'https://postiz.local/integrations/social/x' }) } });
1100
+ try {
1101
+ Object.assign(process.env, { X_API_KEY: app.key, X_API_SECRET: app.secret, VOLTER_WORLD_NAME: 'acme' });
1102
+ delete process.env.VOLTER_WORLD_ENV_NAMES;
1103
+ const shellOnly = await ask();
1104
+ if (shellOnly.status !== 401 || v1Code(shellOnly) !== 32)
1105
+ return false;
1106
+ process.env.VOLTER_WORLD_ENV_NAMES = JSON.stringify(['X_API_KEY', 'X_API_SECRET']);
1107
+ const r = await ask();
1108
+ const token = new URLSearchParams(String(r.body)).get('oauth_token') ?? '';
1109
+ const screen = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` }));
1110
+ return r.status === 200 && screen.includes('<strong class="app-name">acme</strong>');
1111
+ }
1112
+ finally {
1113
+ for (const [n, v] of Object.entries(saved)) {
1114
+ if (v === undefined)
1115
+ delete process.env[n];
1116
+ else
1117
+ process.env[n] = v;
1118
+ }
1119
+ }
1120
+ })),
1121
+ todo('xidentity.oauth1.sign_in_with_x_setting', 'oauth1', 'The App\'s "Use Sign in with X" setting, which oauth/authenticate\'s skip depends on (the twin treats every App as having it on)', 'api', 'niche'),
1122
+ todo('xidentity.oauth1.reauthorize_token_reuse_pin', 'oauth1', 'PIN that re-authorizing an App returns the access token it already holds (the twin reads it from the docs\' "do not expire" and invalidate_token sentences)', 'api', 'niche'),
1123
+ todo('xidentity.oauth1.timestamp_window', 'oauth1', 'Refuse an oauth_timestamp outside X\'s window (code 135) — needs the app\'s clock and the World\'s to be one', 'api', 'niche'),
1124
+ todo('xidentity.oauth1.nonce_replay', 'oauth1', 'Refuse a repeated oauth_nonce within the timestamp window', 'api', 'niche'),
1125
+ todo('xidentity.oauth1.request_token_ttl', 'oauth1', 'Expire an unused request token (X\'s lifetime is undocumented)', 'api', 'niche'),
1126
+ todo('xidentity.oauth1.login_screen', 'oauth1', 'force_login / screen_name: the x.com login step before the screen (the twin authenticates nobody; the session is the seeded persona)', 'ui', 'niche'),
1127
+ todo('xidentity.oauth1.dm_access_level', 'oauth1', 'The "Read, write, and Direct Messages" App permission and x_auth_access_type\'s interaction with it', 'api', 'niche'),
1128
+ todo('xidentity.oauth1.consent_wording', 'oauth1', 'PIN the OAuth 1.0a screen\'s live lines and the PIN page (the twin shows the widely-screenshotted wording)', 'ui', 'niche'),
1129
+ todo('xidentity.oauth1.callback_refusal_status', 'oauth1', 'PIN the HTTP status of the 415 callback refusal (the docs show the body; the twin answers 403)', 'api', 'niche'),
1130
+ todo('xidentity.oauth1.request_token_error_wording', 'oauth1', 'PIN the live answer to a request_token without oauth_callback (the twin answers 400 code 215)', 'api', 'niche'),
1131
+ todo('xidentity.oauth1.access_token_error_wording', 'oauth1', 'PIN the live answers at access_token for a wrong verifier (the twin answers the widely-reported plain-text 401)', 'api', 'niche'),
1132
+ todo('xidentity.oauth1.access_type_unknown_value', 'oauth1', 'PIN what X does with an x_auth_access_type other than read/write (the twin ignores it)', 'api', 'niche'),
1133
+ todo('xidentity.oauth1.invalid_token_page', 'oauth1', 'PIN the live page and status for an unknown or used request token (the twin serves the "Whoa there!" wording at 400)', 'ui', 'niche'),
1134
+ todo('xidentity.oauth1.denied_callback', 'oauth1', 'PIN the live callback for Cancel (the twin sends denied=<request token>, as widely reported)', 'api', 'niche'),
1135
+ todo('xidentity.oauth1.response_content_type', 'oauth1', 'PIN the content type X serves the form-encoded leg bodies with (the twin serves text/html)', 'api', 'niche'),
1136
+ todo('xidentity.oauth1.verify_credentials', 'oauth1', 'GET 1.1/account/verify_credentials, the identity read the OAuth 1.0a guide names (v1.1 is not modelled)', 'api', 'niche'),
902
1137
  ];
903
1138
  export function xidentityCapabilities() {
904
1139
  return checkCapabilities('xidentity', XIDENTITY_CAPABILITIES);
@@ -1,3 +1,4 @@
1
+ import { type XResponse } from './xidentity-twin.js';
1
2
  export type XIdentityConformanceReport = {
2
3
  ok: boolean;
3
4
  endpointsChecked: number;
@@ -5,6 +6,46 @@ export type XIdentityConformanceReport = {
5
6
  resourceTypesChecked: number;
6
7
  violations: string[];
7
8
  };
9
+ /** The Authorization header for `method url` with these extra signed params (query + form body). */
10
+ export declare function oauth1Header(method: string, url: string, consumer: {
11
+ key: string;
12
+ secret: string;
13
+ }, token: {
14
+ key: string;
15
+ secret: string;
16
+ } | null, signed?: Record<string, string>): string;
17
+ /** The App the OAuth 1.0a probes run under, registered through the twin door. */
18
+ export declare const OAUTH1_PROBE_APP: {
19
+ key: string;
20
+ secret: string;
21
+ };
22
+ export declare const OAUTH1_PROBE_CALLBACK = "https://probe-app.local/x/callback";
23
+ type Call = (method: string, path: string, body?: string, headers?: Record<string, string>) => Promise<XResponse>;
24
+ export type OAuth1Fixtures = {
25
+ /** A fresh request token (and its secret) from step 1. */
26
+ requestToken(): Promise<{
27
+ key: string;
28
+ secret: string;
29
+ }>;
30
+ /** A fresh request token whose screen the person has open (the decision is still to make). */
31
+ presented(): Promise<{
32
+ key: string;
33
+ secret: string;
34
+ }>;
35
+ /** A request token taken through the screen and approved: its verifier. */
36
+ approved(): Promise<{
37
+ key: string;
38
+ secret: string;
39
+ verifier: string;
40
+ }>;
41
+ /** A usable access token for the signed-in persona. */
42
+ accessToken(): Promise<{
43
+ key: string;
44
+ secret: string;
45
+ }>;
46
+ };
47
+ export declare function oauth1Fixtures(call: Call): OAuth1Fixtures;
8
48
  export declare function checkXIdentityConformance(opts?: {
9
49
  root?: string;
10
50
  }): Promise<XIdentityConformanceReport>;
51
+ export {};