@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
@@ -0,0 +1,370 @@
1
+ // X identity conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
2
+ //
3
+ // ── THIS CHECK DRIVES THE ROUTER AND ASSERTS WHAT CAME BACK ─────────────────────────────────────
4
+ // Held to ADDING_A_TWIN.md §6's bar, and to the two escalating false-greens the tinybird reviews
5
+ // found there:
6
+ // • two constants asserting about each other is not a check — every expectation below is a
7
+ // LITERAL, never a value read back out of the handler's own module;
8
+ // • "not the router's own miss" has teeth at the dispatch and nowhere deeper — each probe
9
+ // declares the STATUS SET and a PREDICATE over the body a live handler produces, the endpoint
10
+ // census is a two-way bijection with the snapshot, and a hand-enumerated ROUTER_SURFACE
11
+ // catches served-but-unclaimed surface (the direction probes⇄snapshot is blind to).
12
+ //
13
+ // And the whole point of this pack — the ROUND TRIP — is checked as a round trip: a real
14
+ // authorization request, a real consent, a real 302 carrying a real code+state, that code redeemed
15
+ // (PKCE verified) for a real token, and that token answering GET /2/users/me with the consenting
16
+ // persona. A router that dispatches every route while folding nothing cannot pass that.
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { pkceS256 } from './xidentity-pkce.ts';
21
+ import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, DEFAULT_PUBLIC_CLIENT_ID, defaultRedirectUris } from './xidentity-store.ts';
22
+ import { handleXIdentityTwinRequest, xIdentityTwinSnapshot, type XResponse } from './xidentity-twin.ts';
23
+
24
+ export type XIdentityConformanceReport = {
25
+ ok: boolean;
26
+ endpointsChecked: number;
27
+ endpointsProbed: number;
28
+ resourceTypesChecked: number;
29
+ violations: string[];
30
+ };
31
+
32
+ type Probe = {
33
+ method: string;
34
+ path: string;
35
+ body?: string;
36
+ headers?: Record<string, string>;
37
+ /** The status(es) a WORKING handler answers with. */
38
+ status: number[];
39
+ /** What a working handler's body must look like. */
40
+ expect?: (body: unknown) => boolean;
41
+ };
42
+
43
+ const isObject = (b: unknown): b is Record<string, unknown> => !!b && typeof b === 'object';
44
+ const htmlContaining = (...needles: string[]) => (b: unknown) => typeof b === 'string' && needles.every((n) => b.includes(n));
45
+
46
+ /** The origin this harness's world serves the twin at. The seeded demo clients' callbacks are
47
+ * DERIVED from it (runtime contract R7: the port belongs to the caller's world, never to the
48
+ * twin's source), so the probes below name no port of their own. */
49
+ const DEMO_ORIGIN = 'http://localhost:3000';
50
+ const REDIRECT_URIS = defaultRedirectUris(DEMO_ORIGIN);
51
+ const REDIRECT_URI = REDIRECT_URIS[0]!;
52
+ const SCOPE = 'tweet.read users.read offline.access';
53
+ const AT = '2026-02-01T00:00:00.000Z';
54
+ const VERIFIER = 'probe-verifier-0123456789-0123456789-0123456789';
55
+ const ADA = DEFAULT_ACCOUNTS[0]!;
56
+
57
+ /** Placeholders substituted with values the live flow actually minted. */
58
+ const CODE = 'PROBE_CODE';
59
+ const ACCESS = 'PROBE_ACCESS_TOKEN';
60
+ const REFRESH = 'PROBE_REFRESH_TOKEN';
61
+
62
+ const authQuery = `response_type=code&client_id=${encodeURIComponent(DEFAULT_CLIENT_ID)}`
63
+ + `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&scope=${encodeURIComponent(SCOPE)}`
64
+ + `&state=probe-state&code_challenge=${pkceS256(VERIFIER)}&code_challenge_method=S256`;
65
+
66
+ const form = (params: Record<string, string>) => new URLSearchParams(params).toString();
67
+ const basic = `Basic ${Buffer.from(`${DEFAULT_CLIENT_ID}:${DEFAULT_CLIENT_SECRET}`).toString('base64')}`;
68
+
69
+ /** A representative request per declared endpoint, with the outcome a LIVE handler produces. */
70
+ const PROBES: Record<string, Probe> = {
71
+ 'GET /i/oauth2/authorize': {
72
+ method: 'GET',
73
+ path: `/i/oauth2/authorize?${authQuery}`,
74
+ status: [200],
75
+ // The authorize surface is HTML, and its content is the claim: the app name from the client
76
+ // registry, the signed-in persona, both buttons, and a scope's consent wording — X's own,
77
+ // from the OpenAPI scope catalog — must all be ON the page.
78
+ expect: htmlContaining('Authorize app', 'Cancel', 'Twin Demo App', `@${ADA.username}`, 'View all posts you can see'),
79
+ },
80
+ 'POST /2/oauth2/token': {
81
+ method: 'POST',
82
+ path: '/2/oauth2/token',
83
+ body: form({ grant_type: 'authorization_code', code: CODE, client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: VERIFIER }),
84
+ headers: { authorization: basic },
85
+ status: [200],
86
+ // Literals, deliberately (the token shape both official SDKs type): lowercase bearer, the
87
+ // documented 2-hour expiry, an X-shaped base64url token, and a refresh_token because the
88
+ // grant carries offline.access.
89
+ expect: (b) =>
90
+ isObject(b)
91
+ && b.token_type === 'bearer'
92
+ && b.expires_in === 7200
93
+ && typeof b.access_token === 'string'
94
+ && Buffer.from(String(b.access_token), 'base64url').toString('utf8').includes(':at:')
95
+ && b.scope === SCOPE
96
+ && typeof b.refresh_token === 'string'
97
+ && Buffer.from(String(b.refresh_token), 'base64url').toString('utf8').includes(':rt:'),
98
+ },
99
+ 'POST /2/oauth2/revoke': {
100
+ method: 'POST',
101
+ path: '/2/oauth2/revoke',
102
+ body: form({ token: ACCESS, client_id: DEFAULT_CLIENT_ID, token_type_hint: 'access_token' }),
103
+ headers: { authorization: basic },
104
+ status: [200],
105
+ expect: (b) => isObject(b) && b.revoked === true && Object.keys(b).length === 1,
106
+ },
107
+ 'GET /2/users/me': {
108
+ method: 'GET',
109
+ path: '/2/users/me',
110
+ headers: { authorization: `Bearer ${ACCESS}` },
111
+ status: [200],
112
+ expect: (b) =>
113
+ isObject(b)
114
+ && isObject(b.data)
115
+ && b.data['id'] === ADA.id
116
+ && b.data['username'] === ADA.username
117
+ && b.data['name'] === ADA.name,
118
+ },
119
+ };
120
+
121
+ /**
122
+ * Every VENDOR method/path pair a reader of `routeXIdentityTwinRequest` can see the router branch
123
+ * on, PLUS near-miss pairs (wrong method on a claimed path, the OpenAPI's alternate authorize
124
+ * host path, a sibling /2 users route) that must answer the vendor-shaped not-found envelope.
125
+ * Written by hand from the router, so the census can catch VENDOR surface that is served without
126
+ * being claimed. The `/_twin/*` control routes `twinControl` dispatches are DELIBERATELY outside
127
+ * this census: they are twin-only scaffolding (ADDING_A_TWIN.md §6), answer non-404 by design,
128
+ * and are never claimable — so this check is blind to them BY CONSTRUCTION, and a §9 reader must
129
+ * compare `twinControl`'s branches against the scaffolding list in the README instead.
130
+ */
131
+ const ROUTER_SURFACE: Array<[string, string]> = [
132
+ ['GET', `/i/oauth2/authorize?${authQuery}`],
133
+ ['POST', '/i/oauth2/authorize'],
134
+ ['POST', '/2/oauth2/token'],
135
+ ['GET', '/2/oauth2/token'],
136
+ ['POST', '/2/oauth2/revoke'],
137
+ ['GET', '/2/oauth2/revoke'],
138
+ ['GET', '/2/users/me'],
139
+ ['POST', '/2/users/me'],
140
+ ['GET', '/2/oauth2/authorize'],
141
+ ['GET', '/2/users/by'],
142
+ ['GET', '/oauth2/token'],
143
+ ];
144
+
145
+ const isNotFoundEnvelope = (res: XResponse): boolean =>
146
+ res.status === 404
147
+ && isObject(res.body)
148
+ && res.body['title'] === 'Not Found Error'
149
+ && res.body['type'] === 'about:blank';
150
+
151
+ export async function checkXIdentityConformance(opts: { root?: string } = {}): Promise<XIdentityConformanceReport> {
152
+ void opts;
153
+ const snapshot = xIdentityTwinSnapshot();
154
+ const violations: string[] = [];
155
+ // Always a THROWAWAY root, even when a caller passes one: the check mints and then REVOKES
156
+ // credentials, and doing that in an operator's world would log them out of their own twin.
157
+ const root = mkdtempSync(join(tmpdir(), 'xidentity-conformance-'));
158
+ const call = (method: string, path: string, body?: string, headers?: Record<string, string>): Promise<XResponse> =>
159
+ handleXIdentityTwinRequest({ method, path, ...(body !== undefined ? { body } : {}), ...(headers ? { headers } : {}), root, origin: DEMO_ORIGIN, occurredAt: AT });
160
+
161
+ /** Drive a full consent and return the minted code (empty string on any failure). */
162
+ const mintCode = async (scope = SCOPE): Promise<string> => {
163
+ const q = authQuery.replace(`scope=${encodeURIComponent(SCOPE)}`, `scope=${encodeURIComponent(scope)}`);
164
+ const a = await call('GET', `/i/oauth2/authorize?${q}`);
165
+ const rid = /name="auth_request" value="([^"]+)"/.exec(String(a.body))?.[1] ?? '';
166
+ const d = await call('POST', '/_twin/consent', form({ auth_request: rid, decision: 'allow' }));
167
+ return new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('code') ?? '';
168
+ };
169
+ const redeem = (code: string) =>
170
+ call('POST', '/2/oauth2/token', form({ grant_type: 'authorization_code', code, client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT_URI, code_verifier: VERIFIER }), { authorization: basic });
171
+
172
+ let probed = 0;
173
+ try {
174
+ // ── the ROUND TRIP, driven for real, before any probing ──
175
+ const authRes = await call('GET', `/i/oauth2/authorize?${authQuery}`);
176
+ const requestId = /name="auth_request" value="([^"]+)"/.exec(String(authRes.body))?.[1] ?? '';
177
+ if (!requestId) violations.push('the authorize endpoint did not render a consent form carrying an auth_request handle');
178
+ const decision = await call('POST', '/_twin/consent', form({ auth_request: requestId, decision: 'allow' }));
179
+ const location = decision.headers?.location ?? '';
180
+ if (decision.status !== 302 || !location.startsWith(REDIRECT_URI)) {
181
+ violations.push(`consent did not 302 back to the registered redirect_uri: ${decision.status} ${location}`);
182
+ }
183
+ const back = new URL(location || 'http://invalid.test/');
184
+ const code = back.searchParams.get('code') ?? '';
185
+ if (back.searchParams.get('state') !== 'probe-state') {
186
+ violations.push(`the redirect did not echo the caller's state verbatim: ${back.searchParams.get('state')}`);
187
+ }
188
+ if (!Buffer.from(code, 'base64url').toString('utf8').includes(':ac:')) {
189
+ violations.push(`the redirect did not carry an X-shaped authorization code: ${code}`);
190
+ }
191
+ const extras = [...back.searchParams.keys()].filter((k) => k !== 'state' && k !== 'code');
192
+ if (extras.length > 0) {
193
+ // The documented callback carries EXACTLY state and code; an invented parameter is surface
194
+ // an integration could come to depend on and then break against the real vendor.
195
+ violations.push(`the success redirect carries parameters X's documented callback does not: ${extras.join(', ')}`);
196
+ }
197
+
198
+ const tokenRes = await redeem(code);
199
+ const tokens = tokenRes.body as Record<string, any>;
200
+ if (tokenRes.status !== 200 || typeof tokens?.access_token !== 'string') {
201
+ violations.push(`the minted code was not redeemable at the token endpoint: ${tokenRes.status} ${JSON.stringify(tokenRes.body).slice(0, 160)}`);
202
+ }
203
+ // …and the token is USABLE: it answers /2/users/me with the consenting persona and the
204
+ // documented rate headers.
205
+ const me = await call('GET', '/2/users/me', undefined, { authorization: `Bearer ${tokens?.access_token}` });
206
+ const meBody = me.body as Record<string, any>;
207
+ if (me.status !== 200 || meBody?.data?.id !== ADA.id || meBody?.data?.username !== ADA.username) {
208
+ violations.push(`the minted token did not answer /2/users/me with the consenting persona: ${me.status} ${JSON.stringify(me.body).slice(0, 160)}`);
209
+ }
210
+ if (me.headers?.['x-rate-limit-limit'] !== '75' || typeof me.headers?.['x-rate-limit-remaining'] !== 'string' || typeof me.headers?.['x-rate-limit-reset'] !== 'string') {
211
+ violations.push(`/2/users/me did not carry the documented x-rate-limit-* headers: ${JSON.stringify(me.headers)}`);
212
+ }
213
+ // A redeemed code must be DEAD. This is the security property, so it is checked here and not
214
+ // only in the manifest.
215
+ const replay = await redeem(code);
216
+ if (replay.status !== 400 || (replay.body as Record<string, unknown>)?.['error'] !== 'invalid_request') {
217
+ violations.push(`a replayed authorization code was not refused: ${replay.status} ${JSON.stringify(replay.body).slice(0, 120)}`);
218
+ }
219
+ // The refresh token ROTATES: the replacement works, the spent one is refused.
220
+ const refresh1 = await call('POST', '/2/oauth2/token', form({ grant_type: 'refresh_token', refresh_token: String(tokens?.refresh_token), client_id: DEFAULT_CLIENT_ID }), { authorization: basic });
221
+ const refreshed = refresh1.body as Record<string, any>;
222
+ if (refresh1.status !== 200 || typeof refreshed?.refresh_token !== 'string' || refreshed.refresh_token === tokens?.refresh_token) {
223
+ violations.push(`the refresh grant did not rotate the refresh token: ${refresh1.status} ${JSON.stringify(refresh1.body).slice(0, 120)}`);
224
+ }
225
+ const spent = await call('POST', '/2/oauth2/token', form({ grant_type: 'refresh_token', refresh_token: String(tokens?.refresh_token), client_id: DEFAULT_CLIENT_ID }), { authorization: basic });
226
+ if (spent.status !== 400) violations.push(`a spent refresh token was accepted a second time: ${spent.status}`);
227
+
228
+ // ── the endpoint census, THREE ways ──
229
+ const claimed = new Set(snapshot.implementedEndpoints);
230
+ for (const key of Object.keys(PROBES)) {
231
+ if (!claimed.has(key)) violations.push(`probe '${key}' does not correspond to any claimed endpoint — the probe table has drifted`);
232
+ }
233
+ for (const [method, probePath] of ROUTER_SURFACE) {
234
+ const res = await call(method, probePath);
235
+ const key = `${method} ${probePath.split('?')[0]}`;
236
+ if (!isNotFoundEnvelope(res) && !claimed.has(key)) {
237
+ violations.push(`the router answers '${key}' (${res.status}) but the snapshot does not claim it — served surface outside the census is deletable without this check noticing`);
238
+ }
239
+ }
240
+
241
+ // Fixtures per destructive probe, so probe ORDER cannot make this check lie: the revoke probe
242
+ // kills a WHOLE (client, user) grant family, which would take the users/me probe's token with
243
+ // it if the two fixtures shared a user. The revoke fixture is therefore minted under the
244
+ // SECOND persona's session (switched and restored via the twin-only session control).
245
+ const probeTokens = async () => (await redeem(await mintCode())).body as Record<string, any>;
246
+ await call('POST', '/_twin/session', JSON.stringify({ account_id: DEFAULT_ACCOUNTS[1]!.id }));
247
+ const revokeFixture = await probeTokens();
248
+ await call('POST', '/_twin/session', JSON.stringify({ account_id: ADA.id }));
249
+ const meFixture = await probeTokens();
250
+ const tokenProbeCode = await mintCode();
251
+
252
+ const substitute = (s: string) =>
253
+ s.replace(CODE, tokenProbeCode)
254
+ .replace(ACCESS, String(meFixture?.access_token ?? ''))
255
+ .replace(REFRESH, String(meFixture?.refresh_token ?? ''));
256
+
257
+ for (const endpoint of snapshot.implementedEndpoints) {
258
+ const probe = PROBES[endpoint];
259
+ if (!probe) {
260
+ violations.push(`endpoint '${endpoint}' is claimed but has no conformance probe — the claim is unverified`);
261
+ continue;
262
+ }
263
+ const [claimedMethod, claimedPath] = endpoint.split(' ');
264
+ if (probe.method !== claimedMethod) {
265
+ violations.push(`probe '${endpoint}' drives ${probe.method}, not ${claimedMethod}`);
266
+ continue;
267
+ }
268
+ if ((probe.path.split('?')[0] ?? '') !== claimedPath) {
269
+ violations.push(`probe '${endpoint}' drives ${probe.path}, which is not the claimed path ${claimedPath}`);
270
+ continue;
271
+ }
272
+ const path = substitute(probe.path);
273
+ const body = endpoint === 'POST /2/oauth2/revoke'
274
+ ? form({ token: String(revokeFixture?.access_token ?? ''), client_id: DEFAULT_CLIENT_ID, token_type_hint: 'access_token' })
275
+ : probe.body === undefined ? undefined : substitute(probe.body);
276
+ const headers = probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
277
+ const res = await call(probe.method, path, body, headers);
278
+ probed += 1;
279
+ if (!probe.status.includes(res.status)) {
280
+ violations.push(`endpoint '${endpoint}' answered ${res.status} (expected ${probe.status.join('/')}): ${JSON.stringify(res.body).slice(0, 160)}`);
281
+ continue;
282
+ }
283
+ if (probe.expect && !probe.expect(res.body)) {
284
+ violations.push(`endpoint '${endpoint}' answered ${res.status} but the body is not the shape this route returns: ${JSON.stringify(res.body).slice(0, 200)}`);
285
+ }
286
+ }
287
+
288
+ // Every grant type the snapshot claims must actually dispatch — an unsupported one answers
289
+ // `unsupported_grant_type`, so a claimed-but-missing grant is caught by name.
290
+ for (const grantType of snapshot.grantTypes) {
291
+ const res = await call('POST', '/2/oauth2/token', form({ grant_type: grantType, client_id: DEFAULT_PUBLIC_CLIENT_ID }));
292
+ const err = (res.body as Record<string, unknown> | null)?.['error'];
293
+ if (err === 'unsupported_grant_type') violations.push(`grant type '${grantType}' is claimed but the token endpoint does not dispatch it`);
294
+ }
295
+
296
+ // Every resource type the twin projects must be reachable through the protocol — a type with
297
+ // no observable effect is state a consumer can never see. Each witness reads a VALUE only a
298
+ // live handler that genuinely projects that type can produce (never a bare status, and never
299
+ // the snapshot asserting about itself).
300
+ const witness: Record<string, () => Promise<boolean>> = {
301
+ // the app's registered NAME is only on the screen if the oauth_client row projected
302
+ oauth_client: async () => String((await call('GET', `/i/oauth2/authorize?${authQuery}`)).body).includes('Twin Demo App'),
303
+ // the persona's username comes back only if the account row projected
304
+ account: async () => {
305
+ const t = await probeTokens();
306
+ const r = await call('GET', '/2/users/me', undefined, { authorization: `Bearer ${t?.access_token}` });
307
+ return r.status === 200 && (r.body as Record<string, any>)?.data?.username === ADA.username;
308
+ },
309
+ // switching the session changes WHO the authorize screen consents as
310
+ session: async () => {
311
+ const grace = DEFAULT_ACCOUNTS[1]!;
312
+ await call('POST', '/_twin/session', JSON.stringify({ account_id: grace.id }));
313
+ const shows = String((await call('GET', `/i/oauth2/authorize?${authQuery}`)).body).includes(`@${grace.username}`);
314
+ await call('POST', '/_twin/session', JSON.stringify({ account_id: ADA.id }));
315
+ return shows;
316
+ },
317
+ // an auth_request that did not project could not have carried a settleable handle
318
+ auth_request: async () => /name="auth_request" value="ar_[0-9a-f]{32}"/.test(String((await call('GET', `/i/oauth2/authorize?${authQuery}`)).body)),
319
+ // a code that did not project could not be REDEEMED — the witness is the SUCCESS, because
320
+ // the refusal is exactly what a twin projecting nothing would also say
321
+ authorization_code: async () => {
322
+ const r = await redeem(await mintCode());
323
+ return r.status === 200 && typeof (r.body as Record<string, unknown>)?.['access_token'] === 'string';
324
+ },
325
+ access_token: async () => {
326
+ const t = await probeTokens();
327
+ return (await call('GET', '/2/users/me', undefined, { authorization: `Bearer ${t?.access_token}` })).status === 200;
328
+ },
329
+ refresh_token: async () => {
330
+ const t = await probeTokens();
331
+ return (await call('POST', '/2/oauth2/token', form({ grant_type: 'refresh_token', refresh_token: String(t?.refresh_token), client_id: DEFAULT_CLIENT_ID }), { authorization: basic })).status === 200;
332
+ },
333
+ // the grant row is what revocation resolves: revoking the ACCESS half must kill the
334
+ // REFRESH half too, and only a projected grant makes that pair-wide sweep possible
335
+ grant: async () => {
336
+ const t = await probeTokens();
337
+ await call('POST', '/2/oauth2/revoke', form({ token: String(t?.access_token), client_id: DEFAULT_CLIENT_ID }), { authorization: basic });
338
+ const r = await call('POST', '/2/oauth2/token', form({ grant_type: 'refresh_token', refresh_token: String(t?.refresh_token), client_id: DEFAULT_CLIENT_ID }), { authorization: basic });
339
+ return r.status === 400;
340
+ },
341
+ // an armed rate window turns the NEXT read into the documented 429 + code 88
342
+ rate_window: async () => {
343
+ const t = await probeTokens();
344
+ await call('POST', '/_twin/rate_limit', JSON.stringify({ account_id: ADA.id, used: 75 }));
345
+ const r = await call('GET', '/2/users/me', undefined, { authorization: `Bearer ${t?.access_token}` });
346
+ await call('POST', '/_twin/rate_limit', JSON.stringify({ account_id: ADA.id, used: 0 }));
347
+ const errs = (r.body as Record<string, any>)?.errors;
348
+ return r.status === 429 && Array.isArray(errs) && errs[0]?.code === 88;
349
+ },
350
+ };
351
+ for (const type of snapshot.resourceTypes) {
352
+ const probe = witness[type];
353
+ if (!probe) {
354
+ violations.push(`resource type '${type}' has no reachability witness — the claim is unverified`);
355
+ continue;
356
+ }
357
+ if (!(await probe())) violations.push(`resource type '${type}' is not reachable through any served endpoint`);
358
+ }
359
+ } finally {
360
+ rmSync(root, { recursive: true, force: true });
361
+ }
362
+
363
+ return {
364
+ ok: violations.length === 0,
365
+ endpointsChecked: snapshot.implementedEndpoints.length,
366
+ endpointsProbed: probed,
367
+ resourceTypesChecked: snapshot.resourceTypes.length,
368
+ violations,
369
+ };
370
+ }
@@ -0,0 +1,269 @@
1
+ // X identity CONNECTOR — the live-vendor pull path.
2
+ //
3
+ // PULL (real → twin): the twin's persona registry is only as useful as the identities in it, and
4
+ // this surface exposes exactly ONE authenticated read: `GET /2/users/me`. A pull observes the real
5
+ // account behind the operator's own user access token (every modelled user.field requested) and
6
+ // folds it into the observed log.
7
+ //
8
+ // WHAT A PULL CANNOT OBSERVE, recorded as reasoned gaps rather than faked:
9
+ // • the OAuth CLIENT registry — X Apps are created in the developer portal; no API reads them
10
+ // (`xidentity.connector.pull_clients`, todo);
11
+ // • the GRANT (which scopes this token holds) — X has no tokeninfo/introspection endpoint; a
12
+ // users/me success evidences tweet.read+users.read but cannot enumerate the rest
13
+ // (`xidentity.connector.pull_grants`, todo).
14
+ //
15
+ // PUSH IS AN HONEST GAP, not an omission: X exposes NO write API for this identity surface —
16
+ // clients live in the developer portal, and a user's app authorizations are managed at
17
+ // x.com/settings. `pushPendingXIdentityActions` exists only to report that; filed as
18
+ // `xidentity.connector.push` (todo). ADDING_A_TWIN.md §10 sanctions exactly this for a vendor
19
+ // with no write path.
20
+ //
21
+ // The vendor I/O is an INJECTED executor (the auth boundary): the kernel and this pack hold NO X
22
+ // credential and import NO network client. Offline/tests pass a fake executor; live runs pass
23
+ // `liveXIdentityExecute(accessToken)`. Same code path either way.
24
+ import { assertBudgetGuardIntact, deployableEntries, observeResources } from '@volter/world-core';
25
+ import type { PerformContext, PushOutcome, RemoteExecute, TwinAction, SyncResource } from '@volter/world-core';
26
+ import { XIdentityBudget, XIdentityBudgetError, xIdentityBudgetPath, xIdentityCallWeight, type XIdentityBudgetOptions } from './xidentity-budget.ts';
27
+
28
+ const SERVICE = 'xidentity';
29
+
30
+ /** X's real hosts — the live executor's routing table. ONLY mapped paths may be called live.
31
+ * The token/revoke paths are mapped so a live BYOT rehearsal can refresh or revoke the
32
+ * operator's OWN token through the guarded path — nothing else on api.x.com is reachable. */
33
+ const HOSTS: Record<string, string> = {
34
+ '/2/users/me': 'https://api.x.com',
35
+ '/2/oauth2/token': 'https://api.x.com',
36
+ '/2/oauth2/revoke': 'https://api.x.com',
37
+ };
38
+
39
+ /** The modelled user.fields a pull requests — every field the twin's account rows can hold. */
40
+ export const PULL_USER_FIELDS = [
41
+ 'created_at',
42
+ 'description',
43
+ 'location',
44
+ 'profile_image_url',
45
+ 'protected',
46
+ 'public_metrics',
47
+ 'url',
48
+ 'verified',
49
+ 'verified_type',
50
+ 'confirmed_email',
51
+ ] as const;
52
+
53
+ /**
54
+ * The injected real-X boundary. `execute` issues ONE request and returns the parsed JSON body.
55
+ * A real client (a raw fetch wrapper) is structurally assignable; tests pass a fake.
56
+ */
57
+ export type XIdentityExecute = (
58
+ method: 'GET' | 'POST',
59
+ path: string,
60
+ init?: { headers?: Record<string, string>; body?: string },
61
+ ) => Promise<Record<string, any>>;
62
+
63
+ export type LiveXIdentityOptions = {
64
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
65
+ fetchImpl?: typeof fetch;
66
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
67
+ budget?: XIdentityBudget;
68
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
69
+ budgetOptions?: XIdentityBudgetOptions;
70
+ };
71
+
72
+ /**
73
+ * A live executor against the real X API, holding the operator's OWN user access token.
74
+ *
75
+ * THIS IS THE ONE PLACE this pack issues a live X request, and therefore the one place the rate
76
+ * budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request goes
77
+ * out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says stop)
78
+ * and the response is fed back (`recordCall`) so a 429 / `Retry-After` becomes a PERSISTED
79
+ * cooldown that makes every later call fail fast WITHOUT touching X. There is deliberately no
80
+ * option to disable the guard and no value of `budget` that yields an unguarded client
81
+ * (`assertBudgetGuardIntact`).
82
+ */
83
+ export function liveXIdentityExecute(accessToken: string, opts: LiveXIdentityOptions = {}): XIdentityExecute {
84
+ const doFetch = opts.fetchImpl ?? fetch;
85
+ const budget = opts.budget !== undefined && opts.budget !== null
86
+ ? assertBudgetGuardIntact(opts.budget, XIdentityBudget, 'liveXIdentityExecute')
87
+ : new XIdentityBudget({ ...(opts.budgetOptions ?? {}), token: accessToken });
88
+ const explicitLedger = opts.budgetOptions?.path !== undefined || opts.budgetOptions?.root !== undefined;
89
+ if (opts.budget && !explicitLedger && budget.path !== xIdentityBudgetPath({ token: accessToken })) {
90
+ throw new Error('liveXIdentityExecute: injected budget is not keyed to the credential this client will send');
91
+ }
92
+ return async (method, path, init) => {
93
+ const bare = path.split('?')[0] ?? path;
94
+ const host = HOSTS[bare];
95
+ if (!host) throw new Error(`liveXIdentityExecute: refusing to call an unmapped X path: ${bare}`);
96
+ const weight = xIdentityCallWeight(method, path);
97
+ if (Object.keys(init?.headers ?? {}).some((name) => name.toLowerCase() === 'authorization')) {
98
+ throw new Error('liveXIdentityExecute: refusing an injected Authorization header; the guarded credential is fixed at construction');
99
+ }
100
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
101
+ const reservation = budget.checkBudget(weight);
102
+ const res = await doFetch(`${host}${path}`, {
103
+ method,
104
+ headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${accessToken}` },
105
+ ...(init?.body !== undefined ? { body: init.body } : {}),
106
+ });
107
+ const resHeaders: Record<string, string> = {};
108
+ res.headers.forEach((v: string, k: string) => {
109
+ resHeaders[k.toLowerCase()] = v;
110
+ });
111
+ // Settles the reservation and, on a back-off signal, arms the cooldown. The cooldown is
112
+ // persisted before body parsing or any throw, so even an HTML/plain-text 429 survives it.
113
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
114
+ // call that louder refusal wins; an answer X ACCEPTED is kept, so a write that landed is
115
+ // never recorded as failed and performed again on retry.
116
+ try {
117
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
118
+ } catch (error) {
119
+ if (!(error instanceof XIdentityBudgetError) || !res.ok) throw error;
120
+ }
121
+ const raw = await res.text();
122
+ let parsed: Record<string, any>;
123
+ try {
124
+ parsed = raw === '' ? {} : JSON.parse(raw) as Record<string, any>;
125
+ } catch {
126
+ throw new Error(`x identity returned non-JSON for ${method} ${bare}: HTTP ${res.status}`);
127
+ }
128
+ // A REFUSED pull is NOT an empty account. X can answer failures with a problem envelope
129
+ // (`title`/`type`), a legacy `errors` array, OR a 200 whose body carries partial `errors` —
130
+ // a status check alone cannot tell refusal from emptiness, so every refusal shape throws.
131
+ if (res.status >= 400) throw new Error(`x identity refused ${method} ${bare}: HTTP ${res.status} ${JSON.stringify(parsed)}`);
132
+ if (parsed && typeof parsed === 'object' && !('data' in parsed) && ('errors' in parsed || 'title' in parsed)) {
133
+ throw new Error(`x identity refused ${method} ${bare}: ${JSON.stringify(parsed)}`);
134
+ }
135
+ return parsed;
136
+ };
137
+ }
138
+
139
+ /** Map a real `GET /2/users/me` response → the account (persona) SyncResource. Nothing invented:
140
+ * a field the response omits records NOTHING rather than a placeholder — including username and
141
+ * name (§9 round one: a `?? null` there let a partial-errors reply fold null over a previously
142
+ * pulled persona's real values). */
143
+ export function mapUsersMeAccount(body: Record<string, any>): SyncResource {
144
+ const data = (body?.data ?? {}) as Record<string, any>;
145
+ return {
146
+ type: 'account',
147
+ id: String(data.id ?? ''),
148
+ fields: {
149
+ ...(typeof data.username === 'string' ? { username: data.username } : {}),
150
+ ...(typeof data.name === 'string' ? { name: data.name } : {}),
151
+ ...(typeof data.created_at === 'string' ? { createdAt: data.created_at } : {}),
152
+ ...(typeof data.description === 'string' ? { description: data.description } : {}),
153
+ ...(typeof data.location === 'string' ? { location: data.location } : {}),
154
+ ...(typeof data.profile_image_url === 'string' ? { profileImageUrl: data.profile_image_url } : {}),
155
+ ...(typeof data.protected === 'boolean' ? { protectedAccount: data.protected } : {}),
156
+ ...(data.public_metrics && typeof data.public_metrics === 'object' ? { publicMetrics: data.public_metrics } : {}),
157
+ ...(typeof data.url === 'string' ? { url: data.url } : {}),
158
+ ...(typeof data.verified === 'boolean' ? { verified: data.verified } : {}),
159
+ ...(typeof data.verified_type === 'string' ? { verifiedType: data.verified_type } : {}),
160
+ ...(typeof data.confirmed_email === 'string' ? { confirmedEmail: data.confirmed_email } : {}),
161
+ pulled: true,
162
+ },
163
+ };
164
+ }
165
+
166
+ /**
167
+ * A MOVING pull timestamp, forced strictly increasing within the process — never a pinned
168
+ * constant (ADDING_A_TWIN.md §6: under a fixed poll time a vendor value that REVERTS across polls
169
+ * collides with its own earlier observation and the delta silently vanishes).
170
+ */
171
+ let lastPollMs = 0;
172
+ function pollTimestamp(): string {
173
+ const now = Date.now();
174
+ lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
175
+ return new Date(lastPollMs).toISOString();
176
+ }
177
+
178
+ async function collectXIdentity(execute: XIdentityExecute): Promise<SyncResource[]> {
179
+ const body = await execute('GET', `/2/users/me?user.fields=${PULL_USER_FIELDS.join(',')}`);
180
+ // The refusal check lives HERE, not only in the live executor: an injected executor (or a
181
+ // vendor 200 carrying a problem envelope) must never fold an empty account over observed state.
182
+ // A reply without `data`, or whose id is not an X-shaped NUMERIC string, is a refusal or a
183
+ // malformed body, and either one THROWS. The numeric check also keeps a foreign id out of the
184
+ // `clientId:sub` grant-key space (§9 round two: the scaffolding refuses ":" but the pull path
185
+ // is where foreign ids enter).
186
+ const id = (body as Record<string, any> | null)?.['data']?.id;
187
+ if (!body || typeof body !== 'object' || typeof id !== 'string' || !/^\d+$/.test(id)) {
188
+ throw new Error(`x identity pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
189
+ }
190
+ return [mapUsersMeAccount(body)];
191
+ }
192
+
193
+ /** PULL the operator's own identity into the twin's observed log (idempotent). */
194
+ export async function pullXIdentity(execute: XIdentityExecute, root?: string, occurredAt?: string): Promise<number> {
195
+ const resources = await collectXIdentity(execute);
196
+ ((__at) => observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt ?? pollTimestamp());
197
+ return resources.length;
198
+ }
199
+
200
+ /**
201
+ * D7 consumer-facing pull entry point: pull everything readable from the real X identity surface
202
+ * and fold it into the twin in ONE observation, returning the standard
203
+ * `{ observed, deltasAppended }`. Idempotent — a re-pull of identical state appends nothing.
204
+ */
205
+ export async function syncXIdentityFromReal(
206
+ execute: XIdentityExecute,
207
+ opts: { root?: string; occurredAt?: string } = {},
208
+ ): Promise<{ observed: number; deltasAppended: number }> {
209
+ const occurredAt = opts.occurredAt ?? pollTimestamp();
210
+ const resources = await collectXIdentity(execute);
211
+ const result = ((__at) => observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt);
212
+ return { observed: resources.length, deltasAppended: result.appended };
213
+ }
214
+
215
+ /**
216
+ * PUSH — structurally impossible on this vendor, and reported as such rather than faked. X has no
217
+ * API that creates an OAuth App, seeds a user, or grants a scope: those are developer-portal and
218
+ * x.com/settings actions. This never confirms an action and never pretends to have pushed one; it
219
+ * returns the pending count so a caller can see exactly how much local state has no upstream home.
220
+ */
221
+ export function pushPendingXIdentityActions(root?: string): { pushed: 0; unpushable: number } {
222
+ return { pushed: 0, unpushable: deployableEntries(SERVICE, root).length };
223
+ }
224
+
225
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
226
+
227
+ /** An `XIdentityExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
228
+ * credential over these headers (executor.ts); at the twin's own wire any credential is one. */
229
+ export function xIdentityExecuteOver(execute: RemoteExecute): XIdentityExecute {
230
+ return async (method, path, init) => {
231
+ const res = await execute({
232
+ method,
233
+ path,
234
+ headers: { accept: 'application/json', authorization: 'Bearer twin', ...(init?.headers ?? {}) },
235
+ ...(init?.body === undefined ? {} : { body: init.body }),
236
+ });
237
+ try { return JSON.parse(res.body || '{}'); } catch { return {}; }
238
+ };
239
+ }
240
+
241
+ /** The refresh adapter: `GET /2/users/me` is a real read of the real account, so the operator's own
242
+ * identity comes back from X itself. It is also the ONLY thing this vendor lets a client read
243
+ * about its own identity surface — Apps, grants and scopes are portal state, not API state. */
244
+ export async function syncXIdentityFromRemote(
245
+ execute: RemoteExecute,
246
+ opts: { root?: string; origin?: string; occurredAt?: string } = {},
247
+ ): Promise<{ observed: number; deltasAppended: number }> {
248
+ return syncXIdentityFromReal(xIdentityExecuteOver(execute), {
249
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
250
+ occurredAt: opts.occurredAt ?? new Date().toISOString(),
251
+ });
252
+ }
253
+
254
+ /**
255
+ * The perform adapter — and its answer is always the same, honestly.
256
+ *
257
+ * X publishes NO API that creates an OAuth App, seeds a user, or grants a scope: those are
258
+ * developer-portal and x.com/settings actions a person takes in a browser. So nothing a world
259
+ * writes here has an upstream home, and saying so is the whole point — the pack's push has always
260
+ * reported the unpushable count rather than confirming an action it never made.
261
+ */
262
+ export async function performXIdentityAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
263
+ void execute;
264
+ const op = action.operation ?? `${action.subject.type}.update`;
265
+ return {
266
+ externalId: action.subject.id,
267
+ data: { performed: false, reason: `${op} has nowhere to go: X creates OAuth Apps, users and scope grants in the developer portal and in account settings, and publishes no API for any of them` },
268
+ };
269
+ }