@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,239 @@
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 { XIdentityBudget, XIdentityBudgetError, xIdentityBudgetPath, xIdentityCallWeight } from "./xidentity-budget.js";
26
+ const SERVICE = 'xidentity';
27
+ /** X's real hosts — the live executor's routing table. ONLY mapped paths may be called live.
28
+ * The token/revoke paths are mapped so a live BYOT rehearsal can refresh or revoke the
29
+ * operator's OWN token through the guarded path — nothing else on api.x.com is reachable. */
30
+ const HOSTS = {
31
+ '/2/users/me': 'https://api.x.com',
32
+ '/2/oauth2/token': 'https://api.x.com',
33
+ '/2/oauth2/revoke': 'https://api.x.com',
34
+ };
35
+ /** The modelled user.fields a pull requests — every field the twin's account rows can hold. */
36
+ export const PULL_USER_FIELDS = [
37
+ 'created_at',
38
+ 'description',
39
+ 'location',
40
+ 'profile_image_url',
41
+ 'protected',
42
+ 'public_metrics',
43
+ 'url',
44
+ 'verified',
45
+ 'verified_type',
46
+ 'confirmed_email',
47
+ ];
48
+ /**
49
+ * A live executor against the real X API, holding the operator's OWN user access token.
50
+ *
51
+ * THIS IS THE ONE PLACE this pack issues a live X request, and therefore the one place the rate
52
+ * budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the request goes
53
+ * out (`checkBudget`, which THROWS instead of returning when the ceiling or a cooldown says stop)
54
+ * and the response is fed back (`recordCall`) so a 429 / `Retry-After` becomes a PERSISTED
55
+ * cooldown that makes every later call fail fast WITHOUT touching X. There is deliberately no
56
+ * option to disable the guard and no value of `budget` that yields an unguarded client
57
+ * (`assertBudgetGuardIntact`).
58
+ */
59
+ export function liveXIdentityExecute(accessToken, opts = {}) {
60
+ const doFetch = opts.fetchImpl ?? fetch;
61
+ const budget = opts.budget !== undefined && opts.budget !== null
62
+ ? assertBudgetGuardIntact(opts.budget, XIdentityBudget, 'liveXIdentityExecute')
63
+ : new XIdentityBudget({ ...(opts.budgetOptions ?? {}), token: accessToken });
64
+ const explicitLedger = opts.budgetOptions?.path !== undefined || opts.budgetOptions?.root !== undefined;
65
+ if (opts.budget && !explicitLedger && budget.path !== xIdentityBudgetPath({ token: accessToken })) {
66
+ throw new Error('liveXIdentityExecute: injected budget is not keyed to the credential this client will send');
67
+ }
68
+ return async (method, path, init) => {
69
+ const bare = path.split('?')[0] ?? path;
70
+ const host = HOSTS[bare];
71
+ if (!host)
72
+ throw new Error(`liveXIdentityExecute: refusing to call an unmapped X path: ${bare}`);
73
+ const weight = xIdentityCallWeight(method, path);
74
+ if (Object.keys(init?.headers ?? {}).some((name) => name.toLowerCase() === 'authorization')) {
75
+ throw new Error('liveXIdentityExecute: refusing an injected Authorization header; the guarded credential is fixed at construction');
76
+ }
77
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
78
+ const reservation = budget.checkBudget(weight);
79
+ const res = await doFetch(`${host}${path}`, {
80
+ method,
81
+ headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${accessToken}` },
82
+ ...(init?.body !== undefined ? { body: init.body } : {}),
83
+ });
84
+ const resHeaders = {};
85
+ res.headers.forEach((v, k) => {
86
+ resHeaders[k.toLowerCase()] = v;
87
+ });
88
+ // Settles the reservation and, on a back-off signal, arms the cooldown. The cooldown is
89
+ // persisted before body parsing or any throw, so even an HTML/plain-text 429 survives it.
90
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
91
+ // call that louder refusal wins; an answer X ACCEPTED is kept, so a write that landed is
92
+ // never recorded as failed and performed again on retry.
93
+ try {
94
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
95
+ }
96
+ catch (error) {
97
+ if (!(error instanceof XIdentityBudgetError) || !res.ok)
98
+ throw error;
99
+ }
100
+ const raw = await res.text();
101
+ let parsed;
102
+ try {
103
+ parsed = raw === '' ? {} : JSON.parse(raw);
104
+ }
105
+ catch {
106
+ throw new Error(`x identity returned non-JSON for ${method} ${bare}: HTTP ${res.status}`);
107
+ }
108
+ // A REFUSED pull is NOT an empty account. X can answer failures with a problem envelope
109
+ // (`title`/`type`), a legacy `errors` array, OR a 200 whose body carries partial `errors` —
110
+ // a status check alone cannot tell refusal from emptiness, so every refusal shape throws.
111
+ if (res.status >= 400)
112
+ throw new Error(`x identity refused ${method} ${bare}: HTTP ${res.status} ${JSON.stringify(parsed)}`);
113
+ if (parsed && typeof parsed === 'object' && !('data' in parsed) && ('errors' in parsed || 'title' in parsed)) {
114
+ throw new Error(`x identity refused ${method} ${bare}: ${JSON.stringify(parsed)}`);
115
+ }
116
+ return parsed;
117
+ };
118
+ }
119
+ /** Map a real `GET /2/users/me` response → the account (persona) SyncResource. Nothing invented:
120
+ * a field the response omits records NOTHING rather than a placeholder — including username and
121
+ * name (§9 round one: a `?? null` there let a partial-errors reply fold null over a previously
122
+ * pulled persona's real values). */
123
+ export function mapUsersMeAccount(body) {
124
+ const data = (body?.data ?? {});
125
+ return {
126
+ type: 'account',
127
+ id: String(data.id ?? ''),
128
+ fields: {
129
+ ...(typeof data.username === 'string' ? { username: data.username } : {}),
130
+ ...(typeof data.name === 'string' ? { name: data.name } : {}),
131
+ ...(typeof data.created_at === 'string' ? { createdAt: data.created_at } : {}),
132
+ ...(typeof data.description === 'string' ? { description: data.description } : {}),
133
+ ...(typeof data.location === 'string' ? { location: data.location } : {}),
134
+ ...(typeof data.profile_image_url === 'string' ? { profileImageUrl: data.profile_image_url } : {}),
135
+ ...(typeof data.protected === 'boolean' ? { protectedAccount: data.protected } : {}),
136
+ ...(data.public_metrics && typeof data.public_metrics === 'object' ? { publicMetrics: data.public_metrics } : {}),
137
+ ...(typeof data.url === 'string' ? { url: data.url } : {}),
138
+ ...(typeof data.verified === 'boolean' ? { verified: data.verified } : {}),
139
+ ...(typeof data.verified_type === 'string' ? { verifiedType: data.verified_type } : {}),
140
+ ...(typeof data.confirmed_email === 'string' ? { confirmedEmail: data.confirmed_email } : {}),
141
+ pulled: true,
142
+ },
143
+ };
144
+ }
145
+ /**
146
+ * A MOVING pull timestamp, forced strictly increasing within the process — never a pinned
147
+ * constant (ADDING_A_TWIN.md §6: under a fixed poll time a vendor value that REVERTS across polls
148
+ * collides with its own earlier observation and the delta silently vanishes).
149
+ */
150
+ let lastPollMs = 0;
151
+ function pollTimestamp() {
152
+ const now = Date.now();
153
+ lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
154
+ return new Date(lastPollMs).toISOString();
155
+ }
156
+ async function collectXIdentity(execute) {
157
+ const body = await execute('GET', `/2/users/me?user.fields=${PULL_USER_FIELDS.join(',')}`);
158
+ // The refusal check lives HERE, not only in the live executor: an injected executor (or a
159
+ // vendor 200 carrying a problem envelope) must never fold an empty account over observed state.
160
+ // A reply without `data`, or whose id is not an X-shaped NUMERIC string, is a refusal or a
161
+ // malformed body, and either one THROWS. The numeric check also keeps a foreign id out of the
162
+ // `clientId:sub` grant-key space (§9 round two: the scaffolding refuses ":" but the pull path
163
+ // is where foreign ids enter).
164
+ const id = body?.['data']?.id;
165
+ if (!body || typeof body !== 'object' || typeof id !== 'string' || !/^\d+$/.test(id)) {
166
+ throw new Error(`x identity pull refused or malformed: ${JSON.stringify(body).slice(0, 200)}`);
167
+ }
168
+ return [mapUsersMeAccount(body)];
169
+ }
170
+ /** PULL the operator's own identity into the twin's observed log (idempotent). */
171
+ export async function pullXIdentity(execute, root, occurredAt) {
172
+ const resources = await collectXIdentity(execute);
173
+ ((__at) => observeResources(SERVICE, resources, { ...(root !== undefined ? { root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt ?? pollTimestamp());
174
+ return resources.length;
175
+ }
176
+ /**
177
+ * D7 consumer-facing pull entry point: pull everything readable from the real X identity surface
178
+ * and fold it into the twin in ONE observation, returning the standard
179
+ * `{ observed, deltasAppended }`. Idempotent — a re-pull of identical state appends nothing.
180
+ */
181
+ export async function syncXIdentityFromReal(execute, opts = {}) {
182
+ const occurredAt = opts.occurredAt ?? pollTimestamp();
183
+ const resources = await collectXIdentity(execute);
184
+ const result = ((__at) => observeResources(SERVICE, resources, { ...(opts.root !== undefined ? { root: opts.root } : {}), at: __at, batch: `obs:${SERVICE}:${__at}` }))(occurredAt);
185
+ return { observed: resources.length, deltasAppended: result.appended };
186
+ }
187
+ /**
188
+ * PUSH — structurally impossible on this vendor, and reported as such rather than faked. X has no
189
+ * API that creates an OAuth App, seeds a user, or grants a scope: those are developer-portal and
190
+ * x.com/settings actions. This never confirms an action and never pretends to have pushed one; it
191
+ * returns the pending count so a caller can see exactly how much local state has no upstream home.
192
+ */
193
+ export function pushPendingXIdentityActions(root) {
194
+ return { pushed: 0, unpushable: deployableEntries(SERVICE, root).length };
195
+ }
196
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
197
+ /** An `XIdentityExecute` over the kernel's executor. At a REAL boundary the kernel sets the sealed
198
+ * credential over these headers (executor.ts); at the twin's own wire any credential is one. */
199
+ export function xIdentityExecuteOver(execute) {
200
+ return async (method, path, init) => {
201
+ const res = await execute({
202
+ method,
203
+ path,
204
+ headers: { accept: 'application/json', authorization: 'Bearer twin', ...(init?.headers ?? {}) },
205
+ ...(init?.body === undefined ? {} : { body: init.body }),
206
+ });
207
+ try {
208
+ return JSON.parse(res.body || '{}');
209
+ }
210
+ catch {
211
+ return {};
212
+ }
213
+ };
214
+ }
215
+ /** The refresh adapter: `GET /2/users/me` is a real read of the real account, so the operator's own
216
+ * identity comes back from X itself. It is also the ONLY thing this vendor lets a client read
217
+ * about its own identity surface — Apps, grants and scopes are portal state, not API state. */
218
+ export async function syncXIdentityFromRemote(execute, opts = {}) {
219
+ return syncXIdentityFromReal(xIdentityExecuteOver(execute), {
220
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
221
+ occurredAt: opts.occurredAt ?? new Date().toISOString(),
222
+ });
223
+ }
224
+ /**
225
+ * The perform adapter — and its answer is always the same, honestly.
226
+ *
227
+ * X publishes NO API that creates an OAuth App, seeds a user, or grants a scope: those are
228
+ * developer-portal and x.com/settings actions a person takes in a browser. So nothing a world
229
+ * writes here has an upstream home, and saying so is the whole point — the pack's push has always
230
+ * reported the unpushable count rather than confirming an action it never made.
231
+ */
232
+ export async function performXIdentityAction(execute, action, _ctx) {
233
+ void execute;
234
+ const op = action.operation ?? `${action.subject.type}.update`;
235
+ return {
236
+ externalId: action.subject.id,
237
+ 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` },
238
+ };
239
+ }