@volter/twin-xidentity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +112 -0
  2. package/client/xidentity-consent.css +204 -0
  3. package/client/xidentity-consent.tsx +162 -0
  4. package/dist/client/xidentity-consent.bundle.js +235 -0
  5. package/dist/client/xidentity-consent.css +204 -0
  6. package/dist/client/xidentity-consent.d.ts +53 -0
  7. package/dist/client/xidentity-consent.js +57 -0
  8. package/dist/client/xidentity-consent.tsx +162 -0
  9. package/dist/src/cli.d.ts +2 -0
  10. package/dist/src/cli.js +44 -0
  11. package/dist/src/index.d.ts +15 -0
  12. package/dist/src/index.js +105 -0
  13. package/dist/src/xidentity-budget.d.ts +50 -0
  14. package/dist/src/xidentity-budget.js +108 -0
  15. package/dist/src/xidentity-capabilities.d.ts +3 -0
  16. package/dist/src/xidentity-capabilities.js +905 -0
  17. package/dist/src/xidentity-conformance.d.ts +10 -0
  18. package/dist/src/xidentity-conformance.js +332 -0
  19. package/dist/src/xidentity-connector.d.ts +84 -0
  20. package/dist/src/xidentity-connector.js +239 -0
  21. package/dist/src/xidentity-consent-client.gen.d.ts +2 -0
  22. package/dist/src/xidentity-consent-client.gen.js +10 -0
  23. package/dist/src/xidentity-consent-ui.d.ts +21 -0
  24. package/dist/src/xidentity-consent-ui.js +94 -0
  25. package/dist/src/xidentity-pkce.d.ts +7 -0
  26. package/dist/src/xidentity-pkce.js +27 -0
  27. package/dist/src/xidentity-problems.d.ts +38 -0
  28. package/dist/src/xidentity-problems.js +108 -0
  29. package/dist/src/xidentity-scopes.d.ts +23 -0
  30. package/dist/src/xidentity-scopes.js +81 -0
  31. package/dist/src/xidentity-server.d.ts +33 -0
  32. package/dist/src/xidentity-server.js +85 -0
  33. package/dist/src/xidentity-store.d.ts +97 -0
  34. package/dist/src/xidentity-store.js +358 -0
  35. package/dist/src/xidentity-twin.d.ts +54 -0
  36. package/dist/src/xidentity-twin.js +851 -0
  37. package/package.json +74 -0
  38. package/src/cli.ts +43 -0
  39. package/src/index.ts +177 -0
  40. package/src/xidentity-budget.ts +135 -0
  41. package/src/xidentity-capabilities.ts +1012 -0
  42. package/src/xidentity-conformance.ts +370 -0
  43. package/src/xidentity-connector.ts +269 -0
  44. package/src/xidentity-consent-client.gen.ts +10 -0
  45. package/src/xidentity-consent-ui.ts +113 -0
  46. package/src/xidentity-journey.uitest.ts +277 -0
  47. package/src/xidentity-pkce.ts +29 -0
  48. package/src/xidentity-problems.ts +128 -0
  49. package/src/xidentity-scopes.ts +96 -0
  50. package/src/xidentity-server.ts +97 -0
  51. package/src/xidentity-store.ts +419 -0
  52. package/src/xidentity-twin.ts +944 -0
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@volter/twin-xidentity",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "twin",
7
+ "local",
8
+ "mock",
9
+ "mirror",
10
+ "simulator",
11
+ "fixtures",
12
+ "testing",
13
+ "oauth",
14
+ "oauth2",
15
+ "pkce",
16
+ "x",
17
+ "twitter",
18
+ "identity",
19
+ "sign-in-with-x"
20
+ ],
21
+ "author": "Volter (https://github.com/volter-ai)",
22
+ "license": "Apache-2.0",
23
+ "files": [
24
+ "src",
25
+ "client",
26
+ "README.md",
27
+ "!**/*.test.ts",
28
+ "!**/*.test.tsx",
29
+ "dist"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/volter-ai/twin.git",
34
+ "directory": "packages/twin/xidentity"
35
+ },
36
+ "homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/xidentity#readme",
37
+ "type": "module",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/src/index.d.ts",
41
+ "default": "./dist/src/index.js"
42
+ }
43
+ },
44
+ "bin": {
45
+ "world-xidentity": "dist/src/cli.js"
46
+ },
47
+ "scripts": {
48
+ "test": "bun test src/*.test.ts",
49
+ "typecheck": "tsc --noEmit",
50
+ "build": "node ../../../scripts/publish/build.mjs",
51
+ "prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
52
+ "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
53
+ },
54
+ "dependencies": {
55
+ "react": "^19.2.7",
56
+ "react-dom": "^19.2.7"
57
+ },
58
+ "peerDependencies": {
59
+ "@volter/world-core": "2.0.0"
60
+ },
61
+ "devDependencies": {
62
+ "@volter/world-core": "2.0.0",
63
+ "@volter/world-tooling": "0.1.0",
64
+ "@xdevplatform/xdk": "^0.6.6",
65
+ "@types/bun": "^1.2.20",
66
+ "@types/node": "^24.0.0",
67
+ "@types/react": "^19.2.17",
68
+ "@types/react-dom": "^19.2.3",
69
+ "typescript": "^5.9.0"
70
+ },
71
+ "engines": {
72
+ "node": ">=22.3"
73
+ }
74
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-xidentity CLI: serve the X identity twin, or run conformance.
4
+ //
5
+ // `serve` and `mirror` start the SAME server, because the authorize screen is served by the twin
6
+ // at the vendor's own path — there is no second renderer to start. `mirror` additionally prints a
7
+ // ready-to-open authorization URL, which is the thing an operator actually wants when they ask to
8
+ // "see the UI".
9
+ import { hasFlag, optionValue } from '@volter/world-core/args';
10
+ import { createXIdentityTwinServer } from './xidentity-server.ts';
11
+ import { pkceS256 } from './xidentity-pkce.ts';
12
+ import { DEFAULT_CLIENT_ID, defaultRedirectUris } from './xidentity-store.ts';
13
+
14
+ const [cmd, ...rest] = process.argv.slice(2);
15
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
16
+ const root = optionValue(rest, '--root') || undefined;
17
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
18
+
19
+ if (cmd === 'serve' || cmd === 'mirror') {
20
+ const s = await createXIdentityTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
21
+ const origin = `http://127.0.0.1:${s.port}`;
22
+ process.stdout.write(`xidentity twin (X OAuth 2.0 + /2/users/me)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
23
+ if (cmd === 'mirror') {
24
+ const url = new URL(`${origin}/i/oauth2/authorize`);
25
+ url.searchParams.set('response_type', 'code');
26
+ url.searchParams.set('client_id', DEFAULT_CLIENT_ID);
27
+ url.searchParams.set('redirect_uri', defaultRedirectUris(origin)[0]!);
28
+ url.searchParams.set('scope', 'tweet.read users.read offline.access');
29
+ url.searchParams.set('state', 'twin-demo-state');
30
+ url.searchParams.set('code_challenge', pkceS256('twin-demo-verifier'));
31
+ url.searchParams.set('code_challenge_method', 'S256');
32
+ process.stdout.write(`authorize screen: ${url.toString()}\n`);
33
+ }
34
+ await keepProcessAlive();
35
+ } else if (cmd === 'conformance') {
36
+ // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
37
+ const { checkXIdentityConformance } = await import('./xidentity-conformance.ts');
38
+ const report = await checkXIdentityConformance({ ...(root ? { root } : {}) });
39
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
40
+ if (!report.ok) process.exitCode = 1;
41
+ } else {
42
+ process.stdout.write('Usage: world-xidentity serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,177 @@
1
+ // @volter/twin-xidentity — the X (Twitter) identity twin (one vendor, one package), built on the
2
+ // shared @volter/world-core kernel.
3
+ //
4
+ // A BROWSER-FACING protocol pack (the googleoauth class): it serves X's own authorize screen as
5
+ // HTML at x.com's real path, completes the full OAuth 2.0 authorization-code + PKCE round trip
6
+ // (consent → 302 with code+state → redemption at the token endpoint → refresh → revoke), and
7
+ // answers GET /2/users/me with X's real field/expansion/problem envelopes. Deliberately NON-OIDC,
8
+ // exactly as the vendor is: opaque tokens, no id_token, no JWKS — identity comes from the API.
9
+ // Conformance tooling lives in @volter/world-tooling (a dev dependency) — not shipped here.
10
+ export {
11
+ ACCESS_TOKEN_TTL_SECONDS,
12
+ API_ORIGIN,
13
+ AUTH_CODE_TTL_SECONDS,
14
+ AUTHORIZE_ORIGIN,
15
+ handleXIdentityTwinRequest,
16
+ LEGACY_API_ORIGIN,
17
+ LEGACY_AUTHORIZE_ORIGIN,
18
+ RATE_WINDOW_SECONDS,
19
+ RESOURCE_TYPES,
20
+ USER_EXPANSIONS,
21
+ USER_FIELDS,
22
+ USERS_ME_RATE_LIMIT,
23
+ USERS_ME_REQUIRED_SCOPES,
24
+ xIdentityTwinSnapshot,
25
+ } from './xidentity-twin.ts';
26
+ export type { XIdentityRequest, XIdentityResponse, XResponse } from './xidentity-twin.ts';
27
+ export { createXIdentityConsentServer, createXIdentityTwinFetch, createXIdentityTwinServer, type XIdentityTwinFetchOptions } from './xidentity-server.ts';
28
+ export {
29
+ DEFAULT_ACCOUNTS,
30
+ DEFAULT_CLIENT_ID,
31
+ DEFAULT_CLIENT_SECRET,
32
+ DEFAULT_PUBLIC_CLIENT_ID,
33
+ defaultRedirectUris,
34
+ redirectUriAllowed,
35
+ sessionAccount,
36
+ } from './xidentity-store.ts';
37
+ export { normalizeChallengeMethod, pkceS256, pkceVerifies } from './xidentity-pkce.ts';
38
+ export {
39
+ describeScope,
40
+ describeScopes,
41
+ formatScopeParam,
42
+ KNOWN_SCOPES,
43
+ parseScopeParam,
44
+ SCOPE_CATALOG,
45
+ sortScopesForConsent,
46
+ } from './xidentity-scopes.ts';
47
+ export type { ScopeInfo } from './xidentity-scopes.ts';
48
+ export {
49
+ liveXIdentityExecute,
50
+ mapUsersMeAccount,
51
+ PULL_USER_FIELDS,
52
+ pullXIdentity,
53
+ pushPendingXIdentityActions,
54
+ syncXIdentityFromReal,
55
+ } from './xidentity-connector.ts';
56
+ export type { LiveXIdentityOptions, XIdentityExecute } from './xidentity-connector.ts';
57
+ // The client-side rate budget — the fail-closed backstop `liveXIdentityExecute` routes every live
58
+ // request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
59
+ // here is this vendor's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
60
+ // bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
61
+ // `XIdentityBudgetError` by type; there is deliberately no export that disables the guard.
62
+ export {
63
+ XIDENTITY_BUDGET_CEILING,
64
+ XIDENTITY_BUDGET_MAX_RETRY_AFTER_S,
65
+ XIDENTITY_BUDGET_WINDOW_MS,
66
+ XIDENTITY_CALL_WEIGHTS,
67
+ XIDENTITY_RATE_BUDGET,
68
+ XIdentityBudget,
69
+ XIdentityBudgetError,
70
+ xIdentityBudgetPath,
71
+ xIdentityCallWeight,
72
+ } from './xidentity-budget.ts';
73
+ export type {
74
+ XIdentityBudgetErrorKind,
75
+ XIdentityBudgetOptions,
76
+ XIdentityBudgetReservation,
77
+ XIdentityBudgetSnapshot,
78
+ } from './xidentity-budget.ts';
79
+ export {
80
+ consentPageHtml,
81
+ CONSENT_SCRIPT_PATH,
82
+ CONSENT_STYLE_PATH,
83
+ errorPageHtml,
84
+ xIdentityConsentState,
85
+ } from './xidentity-consent-ui.ts';
86
+ export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './xidentity-consent-ui.ts';
87
+
88
+ // Registry descriptor: the pack self-describes so tooling can discover it.
89
+ import type { TwinPack } from '@volter/world-core';
90
+ import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from './xidentity-budget.ts';
91
+ /**
92
+ * The x.com / twitter.com paths THIS pack serves — the consent leg — as a RegExp SOURCE for the
93
+ * descriptor's `hosts` path rule. x.com is NOT an API host: it is the whole X web property (the
94
+ * timeline, settings, everything). Claiming the host outright would be the Cal.com mis-route
95
+ * incident with the largest possible blast radius, so this claims EXACTLY the authorize path plus
96
+ * the twin-only prefix. ANCHORED, the googleoauth lesson: an unanchored claim swallows
97
+ * `/i/oauth2/authorize2` and friends. Transcribed unchanged from inject.cjs's former
98
+ * `isXIdentityConsentPath` predicate.
99
+ */
100
+ const XIDENTITY_CONSENT_PATHS = '^/i/oauth2/authorize/?$|^/_twin/';
101
+
102
+ /**
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
105
+ * 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.
108
+ */
109
+ const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)/?$';
110
+
111
+ export const pack: TwinPack = {
112
+ // STILL PROTOCOL 1, and the reason is the gate rather than the pack (ROADMAP.md,
113
+ // shape-parity-refusals: a refusal the harness cannot express). Everything else is ready: the connector
114
+ // is on the v2 kernel and `performXIdentityAction` / `syncXIdentityFromRemote` are written and
115
+ // exported below. What blocks the declaration is that `GET /2/users/me` needs an access token
116
+ // that only a completed OAuth flow mints, so the refresh REFUSES at the twin's own wire — which
117
+ // is correct (never fold an empty account over observed state) and which the parity harness
118
+ // turns into a crash rather than a reported diff.
119
+
120
+ vendor: 'xidentity',
121
+ // The SAME object xidentity-budget.ts declares at module load — one source of truth, so
122
+ // registering the pack and importing the connector can never arm two different ceilings.
123
+ rateBudget: RATE_BUDGET,
124
+ transport: 'rest',
125
+ archetype: 'crud',
126
+ bin: 'world-xidentity',
127
+ resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window'],
128
+ specSource:
129
+ 'api.x.com/2/openapi.json (X API v2 OpenAPI 2.167: /2/users/me, the Problem family, the '
130
+ + '24-scope OAuth2UserToken catalog) + docs.x.com OAuth 2.0 authorization-code/user-access-token '
131
+ + 'guides + the official SDK sources (@xdevplatform/xdk, twitter-api-typescript-sdk), all '
132
+ + 'fetched 2026-08-21',
133
+ description:
134
+ '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 '
136
+ + "X's real field/expansion/problem envelopes. Non-OIDC, exactly as the vendor is.",
137
+ // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
138
+ // 2026-08-31).
139
+ //
140
+ // BOTH official clients — the current XDK and the legacy (archived but still installed) SDK —
141
+ // plus X's own npm scope, where the current official XDK lives and any future first-party
142
+ // sibling will. `twitter-api-v2` is deliberately NOT claimed here: it is a third-party library
143
+ // spanning the whole X API and belongs to the POSTING pack (`x`), which models the surface it is
144
+ // installed for.
145
+ //
146
+ // The X_* / TWITTER_* stems stay on THIS pack now that a second X pack exists, deliberately:
147
+ // X_CLIENT_ID / X_CLIENT_SECRET and TWITTER_CLIENT_ID / TWITTER_BEARER_TOKEN name the OAuth APP
148
+ // credentials, which is this pack's surface. NB the single-letter `X` stem fires on ANY
149
+ // X_<credential-suffix> shape the BROAD suffix list admits (X_KEY, X_TOKEN, X_API_URL, …), not
150
+ // only the strict _CLIENT_ID/_CLIENT_SECRET pair — accepted deliberately: a var actually named
151
+ // bare `X_*` with a credential suffix is overwhelmingly the vendor, and a false hit surfaces as
152
+ // a visible coverage row rather than a silent drop.
153
+ adoption: {
154
+ // No Python distribution of its own: X's official SDKs are TypeScript, and the one community
155
+ // Python client of this API - `tweepy` - is claimed by the `x` pack (one name, one pack), whose
156
+ // posting surface is what tweepy is overwhelmingly used for.
157
+ pypi: [],
158
+ sdks: ['@xdevplatform/xdk', 'twitter-api-sdk'],
159
+ scopes: ['@xdevplatform/'],
160
+ envStems: ['X', 'TWITTER'],
161
+ },
162
+ // TWO host families — x.com/api.x.com plus the twitter.com pair X's LEGACY official SDK still
163
+ // targets — every one path-scoped, because x.com is the entire X web property and api.x.com the
164
+ // entire X API v2 host, of which this pack models the identity slice only. With no pathname each
165
+ // host is still claimed so the ambient proxy MITMs it; the post-decrypt resolve then routes the
166
+ // identity paths here and refuses the rest loudly (unclaimedTwinnedHostPathMessage) — the
167
+ // Cal.com mis-route lesson.
168
+ hosts: [
169
+ { host: 'x.com', pathPattern: XIDENTITY_CONSENT_PATHS },
170
+ { host: 'twitter.com', pathPattern: XIDENTITY_CONSENT_PATHS },
171
+ { host: 'api.x.com', pathPattern: XIDENTITY_API_PATHS },
172
+ { host: 'api.twitter.com', pathPattern: XIDENTITY_API_PATHS },
173
+ ],
174
+ // A browser is REDIRECTED to x.com/i/oauth2/authorize — the loader host is the consent host and
175
+ // the prefix is the OAuth path root.
176
+ browserRouting: { apiPathPrefix: '/i/oauth2/', loaderHost: 'https://x.com' },
177
+ };
@@ -0,0 +1,135 @@
1
+ // X identity's CLIENT-SIDE RATE BUDGET — this pack's DECLARATION (the numbers) plus the thin typed
2
+ // bindings `liveXIdentityExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
3
+ // window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
4
+ // lives ONCE in the vendor-agnostic kernel (`@volter/world-core` → `rateBudget.ts`).
5
+ //
6
+ // ── HOW THE CEILING WAS CHOSEN (live-fetched first-party figures) ───────────────────────────────
7
+ // X PUBLISHES per-endpoint rate limits. From https://docs.x.com/x-api/fundamentals/rate-limits
8
+ // (fetched 2026-08-21): `GET /2/users/me` is **75 requests / 15 minutes per user** (no per-app
9
+ // row), other user lookups are 300/15min (app) and 900/15min (user), windows are 15 minutes, and
10
+ // exceeding one answers HTTP 429 with legacy code 88. The OAuth token/revoke endpoints publish NO
11
+ // scalar limit on that page — that absence is stated here rather than dressed up, and those calls
12
+ // are priced ABOVE the documented read so the undocumented surface is the conservative one.
13
+ //
14
+ // The window is therefore the vendor's own 15 minutes and the ceiling is the vendor's own 75,
15
+ // with `GET /2/users/me` at weight 1 — the declaration REPRODUCES the published per-user budget
16
+ // for the one endpoint the connector reads (the github-budget precedent: when the vendor's scheme
17
+ // is already a windowed budget, transcribe it). The kernel demands a `burstCeiling` for any
18
+ // window longer than its 60s burst sub-window; 15 units/60s (15 users/me reads a minute, 3× the
19
+ // window's average pace) bounds the instant WITHOUT out-bursting the kernel fallback's 30, so no
20
+ // VENDOR_BURST_ANCHOR entry is required in `scripts/rate-budget-isolation.test.ts`.
21
+ import {
22
+ declareRateBudget,
23
+ rateBudgetPath,
24
+ rateBudgetWeight,
25
+ RateBudget,
26
+ type RateBudgetDeclaration,
27
+ type RateBudgetOptions,
28
+ type RateBudgetReservation,
29
+ type RateBudgetSnapshot,
30
+ } from '@volter/world-core';
31
+
32
+ const VENDOR = 'xidentity';
33
+
34
+ /** Rolling window, in ms — X's own 15-minute rate-limit window. */
35
+ export const XIDENTITY_BUDGET_WINDOW_MS = 15 * 60_000;
36
+
37
+ /** Weighted units allowed inside one window — X's own per-user allowance for GET /2/users/me. */
38
+ export const XIDENTITY_BUDGET_CEILING = 75;
39
+
40
+ /** Weighted units allowed in any 60s (the kernel's burst sub-window): 3× the window's average
41
+ * pace, and UNDER the kernel fallback's burst of 30. */
42
+ export const XIDENTITY_BUDGET_BURST_CEILING = 15;
43
+
44
+ /** Seconds. A `Retry-After` above this means the client is throttled hard — fail loudly, don't sleep. */
45
+ export const XIDENTITY_BUDGET_MAX_RETRY_AFTER_S = 900;
46
+
47
+ /** Per-call cost, keyed by `"<METHOD> <path>"`. See the header for what is documented vs. judged. */
48
+ export const XIDENTITY_CALL_WEIGHTS = {
49
+ /** `GET /2/users/me` — 75/15min per user is the PUBLISHED limit; weight 1 transcribes it. */
50
+ usersMe: 1,
51
+ /** `POST /2/oauth2/revoke` — logs a human out of an app on a REAL account. Priced as a human
52
+ * blast radius, not a throttle (a judgement call, stated as one). */
53
+ revoke: 15,
54
+ /** Everything else (the token endpoint): no published scalar limit → priced conservatively. */
55
+ other: 3,
56
+ } as const;
57
+
58
+ /** THE PACK'S DECLARATION — pure data, the only X-specific thing in the whole budget. */
59
+ export const XIDENTITY_RATE_BUDGET: RateBudgetDeclaration = {
60
+ windowMs: XIDENTITY_BUDGET_WINDOW_MS,
61
+ ceiling: XIDENTITY_BUDGET_CEILING,
62
+ burstCeiling: XIDENTITY_BUDGET_BURST_CEILING,
63
+ defaultWeight: XIDENTITY_CALL_WEIGHTS.other,
64
+ maxRetryAfterSeconds: XIDENTITY_BUDGET_MAX_RETRY_AFTER_S,
65
+ rules: [
66
+ { match: '^GET /2/users/me$', weight: XIDENTITY_CALL_WEIGHTS.usersMe },
67
+ { match: '^POST /2/oauth2/revoke$', weight: XIDENTITY_CALL_WEIGHTS.revoke },
68
+ ],
69
+ reason:
70
+ 'X publishes per-endpoint rate limits (https://docs.x.com/x-api/fundamentals/rate-limits, '
71
+ + 'fetched 2026-08-21): GET /2/users/me is 75 requests / 15 minutes PER USER, windows are 15 '
72
+ + 'minutes, and exceeding one answers HTTP 429 with legacy code 88 "Rate limit exceeded". This '
73
+ + "declaration transcribes that scheme: the window is the vendor's own 15 minutes, the ceiling "
74
+ + "is the vendor's own 75, and GET /2/users/me costs 1 — the one documented figure for the one "
75
+ + 'endpoint the connector reads. The OAuth token and revoke endpoints publish NO scalar limit '
76
+ + 'on that page; they are priced ABOVE the documented read (defaultWeight 3, revoke 15) so the '
77
+ + 'undocumented surface is the conservative one. POST /2/oauth2/revoke costs 15 because it is '
78
+ + 'the one destructive call here — it logs a human out of an app on a REAL account — which is a '
79
+ + 'human blast radius, not a throttle; that price is a judgement call, stated as one. The '
80
+ + "burstCeiling of 15 units/60s (3x the window's average pace, under the kernel fallback's "
81
+ + 'burst of 30) bounds the instant, and the 429 / Retry-After cooldown is the backstop.',
82
+ };
83
+
84
+ // Declared at module load, so merely importing this module (which `xidentity-connector.ts` does)
85
+ // is enough to arm the real ceiling.
86
+ declareRateBudget(VENDOR, XIDENTITY_RATE_BUDGET);
87
+
88
+ /**
89
+ * Price one call. The key is `"<METHOD> <path>"` with the query string split off. NORMALIZED
90
+ * (upper-cased method, collapsed trailing slash) because the anchored rules are otherwise trivially
91
+ * evaded by input variation: `fetch` upper-cases a known method before sending, so
92
+ * `execute('post', '/2/oauth2/revoke')` really does issue the destructive call and must be priced
93
+ * as one.
94
+ */
95
+ export function xIdentityCallWeight(method: string, path: string): number {
96
+ const { bare, query } = splitQuery(path);
97
+ return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
98
+ }
99
+
100
+ function splitQuery(path: string): { bare: string; query: Record<string, string> } {
101
+ const at = path.indexOf('?');
102
+ const query: Record<string, string> = {};
103
+ if (at !== -1) for (const [k, v] of new URLSearchParams(path.slice(at + 1))) query[k] = v;
104
+ const raw = at === -1 ? path : path.slice(0, at);
105
+ const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
106
+ return { bare, query };
107
+ }
108
+
109
+ /** Where this vendor's ledger lives. Token-keyed and cwd-independent by default; pass `root` to
110
+ * opt into world-scoped accounting. */
111
+ export function xIdentityBudgetPath(opts: { root?: string; token?: string } | string = {}): string {
112
+ const o = typeof opts === 'string' ? { root: opts } : opts;
113
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through must not
114
+ // redirect this pack's ledger.
115
+ return rateBudgetPath({ ...o, vendor: VENDOR });
116
+ }
117
+
118
+ /** Construction options. The vendor is fixed; everything else may only TIGHTEN. */
119
+ export type XIdentityBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
120
+
121
+ /**
122
+ * This vendor's budget — the shared kernel guard bound to this declaration. A real subclass, not
123
+ * an alias, so `budget instanceof XIdentityBudget` means "a budget that accounts against
124
+ * XIDENTITY's ledger under XIDENTITY's ceiling".
125
+ */
126
+ export class XIdentityBudget extends RateBudget {
127
+ constructor(opts: XIdentityBudgetOptions = {}) {
128
+ super({ ...opts, vendor: VENDOR });
129
+ }
130
+ }
131
+
132
+ export { RateBudgetError as XIdentityBudgetError } from '@volter/world-core';
133
+ export type { RateBudgetErrorKind as XIdentityBudgetErrorKind } from '@volter/world-core';
134
+ export type XIdentityBudgetReservation = RateBudgetReservation;
135
+ export type XIdentityBudgetSnapshot = RateBudgetSnapshot;