@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.
@@ -1,6 +1,6 @@
1
1
  import { type TwinResource } from '@volter/world-core';
2
2
  export declare const SERVICE = "xidentity";
3
- export declare const RESOURCE_TYPES: readonly ["oauth_client", "account", "session", "auth_request", "authorization_code", "access_token", "refresh_token", "grant", "rate_window"];
3
+ export declare const RESOURCE_TYPES: readonly ["oauth_client", "account", "session", "auth_request", "authorization_code", "access_token", "refresh_token", "grant", "rate_window", "oauth1_app", "oauth1_request_token", "oauth1_token", "oauth1_grant"];
4
4
  export type ResourceType = (typeof RESOURCE_TYPES)[number];
5
5
  export type Row = TwinResource & Record<string, any>;
6
6
  export declare function readAll(root: string | undefined): Row[];
@@ -30,6 +30,9 @@ export declare const mintAuthorizationCode: (root: string | undefined, at: numbe
30
30
  export declare const mintAccessToken: (root: string | undefined, at: number, subject: string) => string;
31
31
  /** An X OAuth 2.0 refresh token (`…:rt:1`). `subject` is the credential exchanged for it. */
32
32
  export declare const mintRefreshToken: (root: string | undefined, at: number, subject: string) => string;
33
+ /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
34
+ * (the resend exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
35
+ export declare function stableHex(seed: string, n: number): string;
33
36
  /**
34
37
  * Twin-internal handle for an authorize screen in flight (never leaves the twin's own pages) —
35
38
  * `ar_` + 32 hex, the shape `randomUUID()` gave it, now a pure function of (request, stored state).
@@ -39,6 +39,13 @@ export const RESOURCE_TYPES = [
39
39
  'refresh_token',
40
40
  'grant',
41
41
  'rate_window',
42
+ // OAuth 1.0a (xidentity-oauth1.ts): an App registered through the twin door, a request token
43
+ // in flight between the three legs, an issued access token, and the (app, user) grant that lets
44
+ // GET oauth/authenticate skip a screen already approved.
45
+ 'oauth1_app',
46
+ 'oauth1_request_token',
47
+ 'oauth1_token',
48
+ 'oauth1_grant',
42
49
  ];
43
50
  export function readAll(root) {
44
51
  return projectResources(SERVICE, root);
@@ -132,7 +139,7 @@ export const mintRefreshToken = (root, at, subject) => b64url(`${stableHex(crede
132
139
  // ── the authorize screen's handle: DETERMINISTIC, because it is SERVED (R9) ─────────────────────
133
140
  /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
134
141
  * (the resend exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
135
- function stableHex(seed, n) {
142
+ export function stableHex(seed, n) {
136
143
  let out = '';
137
144
  for (let round = 0; out.length < n; round += 1) {
138
145
  let h = 0x811c9dc5;
@@ -47,6 +47,7 @@ import { applyTwinWriteAtomic } from '@volter/world-core';
47
47
  import { consentPageHtml, errorPageHtml, xIdentityConsentState } from "./xidentity-consent-ui.js";
48
48
  import { formatScopeParam, parseScopeParam } from "./xidentity-scopes.js";
49
49
  import { normalizeChallengeMethod, pkceVerifies } from "./xidentity-pkce.js";
50
+ import { OAUTH1_ENDPOINTS, OAUTH1_WRITE_PATHS, routeOAuth1, scopesOfAccessLevel, verifyUserContext } from "./xidentity-oauth1.js";
50
51
  import { authRequestIdFor, ensureSeed, mintAccessToken, mintAuthorizationCode, mintClientId, mintRefreshToken, nextRev, nextRevIn, readAll, readOne, readType, RESOURCE_TYPES as STORE_RESOURCE_TYPES, sessionAccount, redirectUriAllowed, write, writeAtomic, } from "./xidentity-store.js";
51
52
  export { RESOURCE_TYPES } from "./xidentity-store.js";
52
53
  /** X's real hosts. The authorize screen lives on the site host; the API on api.x.com. The legacy
@@ -613,8 +614,11 @@ function enumMessage(param, bad, allowed) {
613
614
  async function usersMe(req, query) {
614
615
  const root = req.root;
615
616
  const header = req.headers?.authorization ?? '';
616
- const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
617
- if (!bearer)
617
+ // Two schemes, as the OpenAPI's security for this operation lists them: an OAuth 2.0 user bearer
618
+ // (OAuth2UserToken) or OAuth 1.0a User Context (UserToken), verified in xidentity-oauth1.ts.
619
+ const oauth1 = /^oauth\s/i.test(header);
620
+ const bearer = oauth1 ? undefined : /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
621
+ if (!bearer && !oauth1)
618
622
  return unauthorizedProblem();
619
623
  // Parameter validation happens BEFORE rate accounting: a 400 is not a spent request.
620
624
  // EXTRAPOLATION on the ordering (`xidentity.rate.what_counts`, todo).
@@ -632,17 +636,31 @@ async function usersMe(req, query) {
632
636
  // hydrates a post yet, so it validates nothing and shapes nothing —
633
637
  // `xidentity.users_me.expansion_hydration` (todo) owns the honest gap.
634
638
  const evaluate = (resources, spend) => {
635
- const tokenRow = resources.find((row) => row.type === 'access_token' && row.id === bearer);
636
- if (!tokenRow || tokenRow.revoked === true
637
- || (typeof tokenRow.expiresAt === 'number' && nowSeconds(req.occurredAt) >= tokenRow.expiresAt)) {
638
- return { response: unauthorizedProblem() };
639
+ let sub;
640
+ let held;
641
+ if (oauth1) {
642
+ // A signed request with an access token this pack issued and has not revoked; anything else
643
+ // (unknown consumer, bad signature, revoked token) is the same about:blank 401 a bad bearer gets.
644
+ const who = verifyUserContext(req, resources);
645
+ if (!who)
646
+ return { response: unauthorizedProblem() };
647
+ sub = String(who.token.userId);
648
+ held = scopesOfAccessLevel(who.token.accessLevel);
649
+ }
650
+ else {
651
+ const tokenRow = resources.find((row) => row.type === 'access_token' && row.id === bearer);
652
+ if (!tokenRow || tokenRow.revoked === true
653
+ || (typeof tokenRow.expiresAt === 'number' && nowSeconds(req.occurredAt) >= tokenRow.expiresAt)) {
654
+ return { response: unauthorizedProblem() };
655
+ }
656
+ sub = String(tokenRow.sub);
657
+ held = parseScopeParam(String(tokenRow.scope));
639
658
  }
640
- const held = parseScopeParam(String(tokenRow.scope));
641
659
  const missing = USERS_ME_REQUIRED_SCOPES.filter((scope) => !held.includes(scope));
642
660
  if (missing.length > 0) {
643
661
  return { response: forbiddenProblem(`The access token does not carry the scope(s) required by this endpoint: ${missing.join(', ')}.`) };
644
662
  }
645
- const account = resources.find((row) => row.type === 'account' && row.id === String(tokenRow.sub));
663
+ const account = resources.find((row) => row.type === 'account' && row.id === sub);
646
664
  if (!account)
647
665
  return { response: unauthorizedProblem() };
648
666
  const now = nowSeconds(req.occurredAt);
@@ -700,6 +718,7 @@ export function xIdentityTwinSnapshot() {
700
718
  'POST /2/oauth2/token',
701
719
  'POST /2/oauth2/revoke',
702
720
  'GET /2/users/me',
721
+ ...OAUTH1_ENDPOINTS,
703
722
  ],
704
723
  resourceTypes: STORE_RESOURCE_TYPES,
705
724
  grantTypes: ['authorization_code', 'refresh_token'],
@@ -826,7 +845,8 @@ async function routeXIdentityTwinRequest(req) {
826
845
  // D3: a read-only twin cannot mint credentials or settle consent. GET /2/users/me stays served
827
846
  // (a read), with rate accounting suspended. The refusal is an OAuth error body, so a client sees
828
847
  // a protocol error rather than an HTML surprise.
829
- const writes = path.startsWith('/_twin/') || path === '/2/oauth2/token' || path === '/2/oauth2/revoke';
848
+ const writes = path.startsWith('/_twin/') || path === '/2/oauth2/token' || path === '/2/oauth2/revoke' || OAUTH1_WRITE_PATHS.has(path)
849
+ || (method === 'POST' && path === '/oauth/authorize');
830
850
  if (req.readOnly && writes) {
831
851
  return oauthError('temporarily_unavailable', 'this twin was started read-only; omit readOnly to accept writes', 405);
832
852
  }
@@ -834,6 +854,9 @@ async function routeXIdentityTwinRequest(req) {
834
854
  // a world with clients, personas and a signed-in session — exactly what a browser at x.com has.
835
855
  if (!req.readOnly)
836
856
  await ensureSeed({ root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), ...((req.callbackOrigin ?? req.origin) ? { origin: req.callbackOrigin ?? req.origin } : {}) });
857
+ const oauth1 = await routeOAuth1(req, method, path);
858
+ if (oauth1)
859
+ return oauth1;
837
860
  const control = await twinControl(req, path, form);
838
861
  if (control)
839
862
  return control;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-xidentity",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local X (Twitter) identity twin — the real x.com authorize screen, the full OAuth 2.0 authorization-code + PKCE round trip (token, refresh, revoke), and GET /2/users/me with X's real field/expansion/problem envelopes, so an unmodified X client completes sign-in-with-X against it. Built on @volter/world-core.",
5
5
  "keywords": [
6
6
  "twin",
@@ -56,17 +56,18 @@
56
56
  "react-dom": "^19.2.7"
57
57
  },
58
58
  "peerDependencies": {
59
- "@volter/world-core": "2.0.0"
59
+ "@volter/world-core": "2.0.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@volter/world-core": "2.0.0",
62
+ "@volter/world-core": "2.0.2",
63
63
  "@volter/world-tooling": "0.1.0",
64
64
  "@xdevplatform/xdk": "^0.6.6",
65
65
  "@types/bun": "^1.2.20",
66
66
  "@types/node": "^24.0.0",
67
67
  "@types/react": "^19.2.17",
68
68
  "@types/react-dom": "^19.2.3",
69
- "typescript": "^5.9.0"
69
+ "typescript": "^5.9.0",
70
+ "twitter-api-v2": "^1.29.1"
70
71
  },
71
72
  "engines": {
72
73
  "node": ">=22.3"
package/src/index.ts CHANGED
@@ -84,6 +84,21 @@ export {
84
84
  xIdentityConsentState,
85
85
  } from './xidentity-consent-ui.ts';
86
86
  export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './xidentity-consent-ui.ts';
87
+ export {
88
+ OAUTH1_ENDPOINTS,
89
+ oauth1AppOf,
90
+ oauth1Params,
91
+ parseOAuthHeader,
92
+ pct,
93
+ scopesOfAccessLevel,
94
+ signatureMatches,
95
+ signedBaseUrl,
96
+ signedRequestOf,
97
+ verifyUserContext,
98
+ WORLD_APP_ENV,
99
+ worldOAuth1App,
100
+ } from './xidentity-oauth1.ts';
101
+ export type { OAuth1App, OAuth1Principal, SignedRequest } from './xidentity-oauth1.ts';
87
102
 
88
103
  // Registry descriptor: the pack self-describes so tooling can discover it.
89
104
  import type { TwinPack } from '@volter/world-core';
@@ -100,13 +115,18 @@ import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from './xidentity-budget.ts';
100
115
  const XIDENTITY_CONSENT_PATHS = '^/i/oauth2/authorize/?$|^/_twin/';
101
116
 
102
117
  /**
103
- * The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints
104
- * and the authenticated-user read. api.x.com is the ENTIRE X API v2 host — posts, DMs, spaces — and
118
+ * The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints,
119
+ * the authenticated-user read, and OAuth 1.0a's three legs (request_token, the authorize and
120
+ * authenticate screens — served on api.x.com itself, where X serves them — and access_token) plus
121
+ * 1.1/oauth/invalidate_token, and the page assets that screen loads from the host it is on
122
+ * (`/_twin/assets/consent.{js,css}`; the `x` pack's `/_twin/` claim leaves exactly these two out, so
123
+ * the claims stay disjoint). The screen's Authorize app form posts to `POST oauth/authorize`, which
124
+ * the same rule already covers. api.x.com is the ENTIRE X API v2 host — posts, DMs, spaces — and
105
125
  * this pack models the identity slice only, so nothing else is claimed: an unmodelled X call must
106
- * refuse loudly rather than land in a twin that cannot serve it. Transcribed unchanged from
107
- * inject.cjs's former `isXIdentityApiPath` predicate.
126
+ * refuse loudly rather than land in a twin that cannot serve it. First transcribed from
127
+ * inject.cjs's former `isXIdentityApiPath` predicate; the asset paths were added 2026-09-28.
108
128
  */
109
- const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)/?$';
129
+ 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)$';
110
130
 
111
131
  export const pack: TwinPack = {
112
132
  // STILL PROTOCOL 1, and the reason is the gate rather than the pack (ROADMAP.md,
@@ -124,15 +144,17 @@ export const pack: TwinPack = {
124
144
  transport: 'rest',
125
145
  archetype: 'crud',
126
146
  bin: 'world-xidentity',
127
- resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window'],
147
+ 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'],
128
148
  specSource:
129
149
  'api.x.com/2/openapi.json (X API v2 OpenAPI 2.167: /2/users/me, the Problem family, the '
130
150
  + '24-scope OAuth2UserToken catalog) + docs.x.com OAuth 2.0 authorization-code/user-access-token '
131
151
  + 'guides + the official SDK sources (@xdevplatform/xdk, twitter-api-typescript-sdk), all '
132
- + 'fetched 2026-08-21',
152
+ + 'fetched 2026-08-21; OAuth 1.0a from docs.x.com authentication/api-reference, obtaining-user-access-tokens, '
153
+ + 'creating-a-signature and developer-apps, fetched 2026-09-27',
133
154
  description:
134
155
  'X (Twitter) identity twin — the real x.com authorize screen, the full OAuth 2.0 '
135
- + 'authorization-code + PKCE round trip (token, refresh, revoke), and GET /2/users/me with '
156
+ + 'authorization-code + PKCE round trip (token, refresh, revoke), the OAuth 1.0a three-legged flow '
157
+ + '(request_token, the authorize/authenticate screen, access_token, invalidate_token), and GET /2/users/me with '
136
158
  + "X's real field/expansion/problem envelopes. Non-OIDC, exactly as the vendor is.",
137
159
  // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
138
160
  // 2026-08-31).
@@ -47,7 +47,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
47
47
  import { checkCapabilities, uiDataCoupled, verifyBoundary, type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
48
48
  import { ConsentPage, ErrorPage, type ConsentView } from '../client/xidentity-consent.tsx';
49
49
  import { CONSENT_CLIENT_JS, xIdentityConsentState } from './xidentity-consent-ui.ts';
50
- import { checkXIdentityConformance } from './xidentity-conformance.ts';
50
+ import { checkXIdentityConformance, oauth1Fixtures, oauth1Header, OAUTH1_PROBE_APP, OAUTH1_PROBE_CALLBACK } from './xidentity-conformance.ts';
51
51
  import { XIdentityBudgetError, XIDENTITY_BUDGET_BURST_CEILING } from './xidentity-budget.ts';
52
52
  import {
53
53
  liveXIdentityExecute,
@@ -117,6 +117,15 @@ const qp = (r: XResponse, key: string) => {
117
117
  const form = (params: Record<string, string>) => new URLSearchParams(params).toString();
118
118
  const decoded = (token: unknown) => Buffer.from(String(token ?? ''), 'base64url').toString('utf8');
119
119
 
120
+ // ── OAuth 1.0a helpers: the probe App and its independent signer live in xidentity-conformance.ts ──
121
+ const FORM_H = { 'content-type': 'application/x-www-form-urlencoded' };
122
+ /** Register the probe App through the twin door (X's developer console has no API). */
123
+ const oauth1App = (h: 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] }) });
124
+ /** The conformance fixtures' call shape over this verify's handler. */
125
+ const oauthCall = (h: H) => (m: string, p: string, b?: string, headers?: Record<string, string>) => h({ m, p, ...(b === undefined ? {} : { b }), ...(headers ? { h: headers } : {}) });
126
+ /** The legacy v1.1 error code of a refusal. */
127
+ const v1Code = (r: XResponse): number | undefined => (r.body as Body)?.errors?.[0]?.code;
128
+
120
129
  /** Build an authorize URL from parameters, so a verify can vary exactly one of them. `undefined`
121
130
  * REMOVES a base parameter. */
122
131
  function authUrl(params: Record<string, string | undefined> = {}): string {
@@ -581,6 +590,11 @@ export const XIDENTITY_CAPABILITIES: CapabilitySpec[] = [
581
590
  && d['verified'] === true
582
591
  && d['verified_type'] === 'blue';
583
592
  })),
593
+ // The x pack answers user.fields=profile_image_url with X's default avatar for an account seeded
594
+ // without a photo (every X account has one); this pack omits it, because a PARTIALLY-pulled persona
595
+ // cannot tell "no photo" from "not pulled" (renderUser). Serving the default for a seeded persona
596
+ // needs that distinction recorded on the row — until then the two packs answer differently (§9).
597
+ 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'),
584
598
  done('xidentity.users_me.public_metrics', 'users_me', 'user.fields=public_metrics returns the metrics object with the vendor\'s six counters', 'api', 'common', () =>
585
599
  withRoot(async (h) => {
586
600
  const { tokens } = await fullFlow(h);
@@ -646,7 +660,18 @@ export const XIDENTITY_CAPABILITIES: CapabilitySpec[] = [
646
660
  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'),
647
661
  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'),
648
662
  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'),
649
- 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'),
663
+ 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', () =>
664
+ withRoot(async (h) => {
665
+ await oauth1App(h);
666
+ const at = await oauth1Fixtures(oauthCall(h)).accessToken();
667
+ const sign = (t: { key: string; secret: string }, q = '') => oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me${q}`, OAUTH1_PROBE_APP, t);
668
+ const r = await h({ m: 'GET', p: '/2/users/me?user.fields=username,verified', h: { authorization: sign(at, '?user.fields=username,verified') } });
669
+ // the query is signed: the same header over a different query does not verify
670
+ const unsignedQuery = await h({ m: 'GET', p: '/2/users/me?user.fields=location', h: { authorization: sign(at, '?user.fields=username,verified') } });
671
+ const unknown = await h({ m: 'GET', p: '/2/users/me', h: { authorization: sign({ key: `${ADA.id}-NotIssued`, secret: at.secret }) } });
672
+ return r.status === 200 && body(r).data.id === ADA.id && body(r).data.username === ADA.username && body(r).data.verified === ADA.verified
673
+ && unsignedQuery.status === 401 && unknown.status === 401;
674
+ })),
650
675
  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'),
651
676
 
652
677
  // ═══ rate envelope ════════════════════════════════════════════════════════════════════════
@@ -730,13 +755,15 @@ export const XIDENTITY_CAPABILITIES: CapabilitySpec[] = [
730
755
  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'),
731
756
 
732
757
  // ═══ read-only twin ═══════════════════════════════════════════════════════════════════════
733
- done('xidentity.readonly.writes_refused', 'readonly', 'A read-only twin refuses every credential-minting leg (405, protocol-shaped error body) — D3', 'api', 'common', () =>
758
+ 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', () =>
734
759
  withRoot(async (h) => {
735
760
  await fullFlow(h); // seed a world first, through the ordinary write path
736
761
  const ro = (s: Step) => handleXIdentityTwinRequest({ method: s.m, path: s.p, ...(s.b !== undefined ? { body: s.b } : {}), root: h.root, origin: DEMO_ORIGIN, occurredAt: AT, readOnly: true });
737
762
  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 }) });
738
763
  const rev = await ro({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: 'x', client_id: DEFAULT_CLIENT_ID }) });
739
- return tok.status === 405 && body(tok)['error'] === 'temporarily_unavailable' && rev.status === 405;
764
+ // the OAuth 1.0a screen's decision settles consent: a write, though its path is also the GET screen's
765
+ const decide = await ro({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: 'x', decision: 'allow' }) });
766
+ return tok.status === 405 && body(tok)['error'] === 'temporarily_unavailable' && rev.status === 405 && decide.status === 405;
740
767
  })),
741
768
  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', () =>
742
769
  withRoot(async (h) => {
@@ -748,7 +775,7 @@ export const XIDENTITY_CAPABILITIES: CapabilitySpec[] = [
748
775
  // ═══ conformance (the endpoint census with teeth) ═════════════════════════════════════════
749
776
  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 () => {
750
777
  const report = await checkXIdentityConformance();
751
- return report.ok && report.endpointsProbed === 4 && report.resourceTypesChecked === 9;
778
+ return report.ok && report.endpointsProbed === 11 && report.resourceTypesChecked === 13;
752
779
  }),
753
780
 
754
781
  // ═══ the authorize SCREEN (the vendor's own UI — data-coupled, per ADDING_A_TWIN.md §6) ═══
@@ -1002,9 +1029,228 @@ export const XIDENTITY_CAPABILITIES: CapabilitySpec[] = [
1002
1029
  // ═══ adjacent X auth surface the identity twin does not model yet (honest denominator) ═════
1003
1030
  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'),
1004
1031
  todo('xidentity.app_only.invalidate_token', 'app_only', 'Model POST /oauth2/invalidate_token for app-only bearers', 'api', 'niche'),
1005
- todo('xidentity.oauth1.request_token', 'oauth1', 'Model OAuth 1.0a step 1: POST /oauth/request_token', 'api', 'niche'),
1006
- todo('xidentity.oauth1.authorize', 'oauth1', 'Model OAuth 1.0a step 2: GET /oauth/authorize (the legacy consent screen)', 'api', 'niche'),
1007
- todo('xidentity.oauth1.access_token', 'oauth1', 'Model OAuth 1.0a step 3: POST /oauth/access_token', 'api', 'niche'),
1032
+
1033
+ // ═══ OAuth 1.0a (xidentity-oauth1.ts; docs.x.com api-reference + obtaining-user-access-tokens) ══
1034
+ 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', () =>
1035
+ withRoot(async (h) => {
1036
+ await oauth1App(h);
1037
+ 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 }) } });
1038
+ const got = new URLSearchParams(String(r.body));
1039
+ const token = got.get('oauth_token') ?? '';
1040
+ const screen = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` });
1041
+ return r.status === 200 && got.get('oauth_callback_confirmed') === 'true' && /^[A-Za-z0-9]{27}$/.test(token)
1042
+ && (got.get('oauth_token_secret') ?? '').length > 0 && html(screen).includes('>Probe App<');
1043
+ })),
1044
+ 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', () =>
1045
+ withRoot(async (h) => {
1046
+ await oauth1App(h);
1047
+ const fx = oauth1Fixtures(oauthCall(h));
1048
+ const t = await fx.requestToken();
1049
+ const screen = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1050
+ const handle = AUTH_REQUEST_RE.exec(html(screen))?.[1] ?? '';
1051
+ const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: handle, decision: 'allow' }), h: FORM_H });
1052
+ const back = new URL(loc(d) || 'http://invalid.test/');
1053
+ return screen.status === 200 && html(screen).includes('<strong class="app-name">Probe App</strong> wants to access your X account')
1054
+ && html(screen).includes(`@${ADA.username}`) && html(screen).includes('action="http://localhost:3000/oauth/authorize"')
1055
+ && d.status === 302 && `${back.origin}${back.pathname}` === OAUTH1_PROBE_CALLBACK
1056
+ && back.searchParams.get('oauth_token') === t.key && /^[A-Za-z0-9]{32}$/.test(back.searchParams.get('oauth_verifier') ?? '')
1057
+ && [...back.searchParams.keys()].length === 2;
1058
+ })),
1059
+ // api.x.com is shared with the `x` pack, which claims that host's `/_twin/*` and comes first in
1060
+ // the World's first-match routing. A decision door there reached the x pack in a World (the
1061
+ // Postiz walk, 2026-09-28: "Authorize app" answered the x pack's JSON 400 and X never connected),
1062
+ // while every in-process test posted straight to this twin's port. So the verify asks the
1063
+ // World's own router which pack the screen's form lands on, for both API hosts, and then drives it.
1064
+ 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', () =>
1065
+ withRoot(async (h) => {
1066
+ // the injector's own first-match table (compiled from pack-facts.json), untyped CommonJS
1067
+ const { createRequire } = await import('node:module');
1068
+ const { VENDOR_HOSTS } = createRequire(import.meta.url)('@volter/world-core/vendor-hosts') as { VENDOR_HOSTS: Record<string, (host: string, path?: string) => boolean> };
1069
+ const routedTo = (host: string, path: string) => Object.keys(VENDOR_HOSTS).find((k) => VENDOR_HOSTS[k]!(host, path));
1070
+ await oauth1App(h);
1071
+ const t = await oauth1Fixtures(oauthCall(h)).requestToken();
1072
+ const screen = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent(t.key)}` });
1073
+ const action = /<form [^>]*method="POST"[^>]*action="([^"]+)"|<form [^>]*action="([^"]+)"[^>]*method="POST"/.exec(html(screen));
1074
+ const actionPath = new URL(action?.[1] ?? action?.[2] ?? 'http://invalid.test/nowhere').pathname;
1075
+ const d = await h({ m: 'POST', p: actionPath, b: form({ auth_request: t.key, decision: 'allow' }), h: FORM_H });
1076
+ // …and the page's own stylesheet and script, loaded from the same host (else it renders unstyled)
1077
+ const assets = [...html(screen).matchAll(/(?:href|src)="([^"]+\/_twin\/assets\/[^"]+)"/g)].map((m) => new URL(m[1]!).pathname);
1078
+ const { createXIdentityTwinFetch } = await import('./xidentity-server.ts');
1079
+ const fetchTwin = createXIdentityTwinFetch({ root: h.root });
1080
+ const served = await Promise.all(assets.map((p) => fetchTwin(new Request(`https://api.x.com${p}`))));
1081
+ return actionPath === '/oauth/authorize' && routedTo('api.x.com', actionPath) === 'xidentity' && routedTo('api.twitter.com', actionPath) === 'xidentity'
1082
+ && assets.length === 2 && assets.every((p) => routedTo('api.x.com', p) === 'xidentity' && routedTo('api.twitter.com', p) === 'xidentity')
1083
+ && served.every((r) => r.status === 200)
1084
+ // the x pack keeps the rest of the host's control prefix
1085
+ && routedTo('api.x.com', '/_twin/accounts') === 'x'
1086
+ && d.status === 302 && qp(d, 'oauth_token') === t.key && (qp(d, 'oauth_verifier') ?? '').length === 32;
1087
+ })),
1088
+ 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', () =>
1089
+ withRoot(async (h) => {
1090
+ await oauth1App(h);
1091
+ 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' }) } });
1092
+ const token = new URLSearchParams(String(read.body)).get('oauth_token') ?? '';
1093
+ const screen = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` }));
1094
+ const write = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent((await oauth1Fixtures(oauthCall(h)).requestToken()).key)}` }));
1095
+ return screen.includes('Accounts you follow, mute, and block.') && !screen.includes('Post and delete Posts for you')
1096
+ && write.includes('Post and delete Posts for you');
1097
+ })),
1098
+ 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', () =>
1099
+ withRoot(async (h) => {
1100
+ await oauth1App(h);
1101
+ const fx = oauth1Fixtures(oauthCall(h));
1102
+ const first = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}` });
1103
+ await fx.approved();
1104
+ const again = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}` });
1105
+ const forced = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}&force_login=true` });
1106
+ const back = new URL(loc(again) || 'http://invalid.test/');
1107
+ return first.status === 200 && html(first).includes('Authorize app') && again.status === 302
1108
+ && `${back.origin}${back.pathname}` === OAUTH1_PROBE_CALLBACK && (back.searchParams.get('oauth_verifier') ?? '').length === 32
1109
+ && forced.status === 200 && html(forced).includes('Authorize app');
1110
+ })),
1111
+ 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', () =>
1112
+ withRoot(async (h) => {
1113
+ await oauth1App(h);
1114
+ const t = await oauth1Fixtures(oauthCall(h)).requestToken();
1115
+ await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1116
+ const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: t.key, decision: 'deny' }), h: FORM_H });
1117
+ 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' }) } });
1118
+ return d.status === 302 && qp(d, 'denied') === t.key && qp(d, 'oauth_verifier') === null && x.status === 401 && v1Code(x) === 89;
1119
+ })),
1120
+ 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', () =>
1121
+ withRoot(async (h) => {
1122
+ await oauth1App(h);
1123
+ 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' }) } });
1124
+ const t = { key: new URLSearchParams(String(r.body)).get('oauth_token') ?? '', secret: new URLSearchParams(String(r.body)).get('oauth_token_secret') ?? '' };
1125
+ await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1126
+ const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: t.key, decision: 'allow' }), h: FORM_H });
1127
+ const pin = /<code class="oauth-pin">([^<]+)<\/code>/.exec(html(d))?.[1] ?? '';
1128
+ 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 }) } });
1129
+ return d.status === 200 && /^\d{7}$/.test(pin) && x.status === 200 && new URLSearchParams(String(x.body)).get('user_id') === ADA.id;
1130
+ })),
1131
+ 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', () =>
1132
+ withRoot(async (h) => {
1133
+ await oauth1App(h);
1134
+ const t = await oauth1Fixtures(oauthCall(h)).approved();
1135
+ // twitter-api-v2's shape (Postiz's login): oauth_token in the header AND the body, signed once
1136
+ const exchange = (verifier: string) => 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 }) } });
1137
+ const wrong = await exchange('not-the-verifier');
1138
+ const r = await exchange(t.verifier);
1139
+ const got = new URLSearchParams(String(r.body));
1140
+ const replay = await exchange(t.verifier);
1141
+ return wrong.status === 401 && r.status === 200 && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(got.get('oauth_token') ?? '')
1142
+ && (got.get('oauth_token_secret') ?? '').length === 40 && got.get('user_id') === ADA.id && got.get('screen_name') === ADA.username
1143
+ && replay.status === 401 && v1Code(replay) === 89;
1144
+ })),
1145
+ 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', () =>
1146
+ withRoot(async (h) => {
1147
+ await oauth1App(h);
1148
+ const fx = oauth1Fixtures(oauthCall(h));
1149
+ const a = await fx.accessToken();
1150
+ const b = await fx.accessToken();
1151
+ 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) } });
1152
+ const c = await fx.accessToken();
1153
+ return a.key !== '' && a.key === b.key && a.secret === b.secret && c.key !== a.key && c.key !== '';
1154
+ })),
1155
+ 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', () =>
1156
+ withRoot(async (h) => {
1157
+ await oauth1App(h);
1158
+ 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 }) } });
1159
+ const none = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: FORM_H });
1160
+ return r.status === 401 && v1Code(r) === 32 && body(r).errors[0].message === 'Could not authenticate you.'
1161
+ && none.status === 400 && v1Code(none) === 215;
1162
+ })),
1163
+ 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', () =>
1164
+ withRoot(async (h) => {
1165
+ await oauth1App(h);
1166
+ const wrongApp = { key: OAUTH1_PROBE_APP.key, secret: 'wrong-consumer-secret' };
1167
+ 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 }) } });
1168
+ // the body says one callback, the signature covered another
1169
+ 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 }) } });
1170
+ const fx = oauth1Fixtures(oauthCall(h));
1171
+ const t = await fx.approved();
1172
+ 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 }) } });
1173
+ const at = await fx.accessToken();
1174
+ const me = (secret: string, 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 }) } });
1175
+ const good = await me(at.secret);
1176
+ const badToken = await me('wrong');
1177
+ const otherUrl = await me(at.secret, `${DEMO_ORIGIN}/2/users/me/other`);
1178
+ // a query realm is an ordinary parameter (only the header's is unsigned), and a repeated key is refused
1179
+ const signedPlain = oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me?user.fields=username`, OAUTH1_PROBE_APP, at);
1180
+ const realmInjected = await h({ m: 'GET', p: '/2/users/me?user.fields=username&realm=injected', h: { authorization: signedPlain } });
1181
+ // even signed over it: X accepts no duplicate keys
1182
+ const dup = '/2/users/me?user.fields=username&user.fields=username';
1183
+ const repeated = await h({ m: 'GET', p: dup, h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}${dup}`, OAUTH1_PROBE_APP, at) } });
1184
+ const plain = await h({ m: 'GET', p: '/2/users/me?user.fields=username', h: { authorization: signedPlain } });
1185
+ return leg1.status === 401 && v1Code(leg1) === 32 && tampered.status === 401 && v1Code(tampered) === 32
1186
+ && leg3.status === 401 && v1Code(leg3) === 32 && good.status === 200 && plain.status === 200
1187
+ && badToken.status === 401 && body(badToken).type === 'about:blank' && otherUrl.status === 401
1188
+ && realmInjected.status === 401 && repeated.status === 401;
1189
+ })),
1190
+ 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', () =>
1191
+ withRoot(async (h) => {
1192
+ await oauth1App(h);
1193
+ const cb = 'https://probe-app.local/x/callback/';
1194
+ 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 }) } });
1195
+ return r.status === 403 && v1Code(r) === 415 && body(r).errors[0].message === 'Callback URL not approved for this client application.';
1196
+ })),
1197
+ 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', () =>
1198
+ withRoot(async (h) => {
1199
+ await oauth1App(h);
1200
+ const fx = oauth1Fixtures(oauthCall(h));
1201
+ const t = await fx.approved();
1202
+ const used = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
1203
+ const unknown = await h({ m: 'GET', p: '/oauth/authorize?oauth_token=NotARequestToken' });
1204
+ return used.status === 400 && html(used).includes('The request token for this page is invalid') && !html(used).includes('Authorize app')
1205
+ && unknown.status === 400 && html(unknown).includes('The request token for this page is invalid');
1206
+ })),
1207
+ 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', () =>
1208
+ withRoot(async (h) => {
1209
+ await oauth1App(h);
1210
+ const at = await oauth1Fixtures(oauthCall(h)).accessToken();
1211
+ 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) } });
1212
+ const before = await h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me`, OAUTH1_PROBE_APP, at) } });
1213
+ const r = await inv();
1214
+ const after = await h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me`, OAUTH1_PROBE_APP, at) } });
1215
+ const again = await inv();
1216
+ return before.status === 200 && r.status === 200 && body(r).access_token === at.key && after.status === 401 && again.status === 401 && v1Code(again) === 89;
1217
+ })),
1218
+ 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', () =>
1219
+ withRoot(async (h) => {
1220
+ const names = ['X_API_KEY', 'X_API_SECRET', 'VOLTER_WORLD_ENV_NAMES', 'VOLTER_WORLD_NAME'];
1221
+ const saved = Object.fromEntries(names.map((n) => [n, process.env[n]]));
1222
+ const app = { key: 'WorldConsumerKey000000001', secret: 'WorldConsumerSecret0000000000000000000000000001' };
1223
+ 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' }) } });
1224
+ try {
1225
+ Object.assign(process.env, { X_API_KEY: app.key, X_API_SECRET: app.secret, VOLTER_WORLD_NAME: 'acme' });
1226
+ delete process.env.VOLTER_WORLD_ENV_NAMES;
1227
+ const shellOnly = await ask();
1228
+ if (shellOnly.status !== 401 || v1Code(shellOnly) !== 32) return false;
1229
+ process.env.VOLTER_WORLD_ENV_NAMES = JSON.stringify(['X_API_KEY', 'X_API_SECRET']);
1230
+ const r = await ask();
1231
+ const token = new URLSearchParams(String(r.body)).get('oauth_token') ?? '';
1232
+ const screen = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` }));
1233
+ return r.status === 200 && screen.includes('<strong class="app-name">acme</strong>');
1234
+ } finally {
1235
+ for (const [n, v] of Object.entries(saved)) { if (v === undefined) delete process.env[n]; else process.env[n] = v; }
1236
+ }
1237
+ })),
1238
+ 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'),
1239
+ 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'),
1240
+ 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'),
1241
+ todo('xidentity.oauth1.nonce_replay', 'oauth1', 'Refuse a repeated oauth_nonce within the timestamp window', 'api', 'niche'),
1242
+ todo('xidentity.oauth1.request_token_ttl', 'oauth1', 'Expire an unused request token (X\'s lifetime is undocumented)', 'api', 'niche'),
1243
+ 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'),
1244
+ 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'),
1245
+ 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'),
1246
+ 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'),
1247
+ 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'),
1248
+ 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'),
1249
+ 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'),
1250
+ 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'),
1251
+ todo('xidentity.oauth1.denied_callback', 'oauth1', 'PIN the live callback for Cancel (the twin sends denied=<request token>, as widely reported)', 'api', 'niche'),
1252
+ 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'),
1253
+ 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'),
1008
1254
  ];
1009
1255
 
1010
1256
  export function xidentityCapabilities(): Promise<CapabilityReport> {