@volter/twin-xidentity 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -68,7 +68,10 @@ code TTL). Each such `done` names its evidence boundary in the manifest and its
68
68
  - **The identity read** — `GET /2/users/me` with the OpenAPI's scope demands (`tweet.read` +
69
69
  `users.read`), the 24-value `user.fields` enum with the wire's invalid-parameter envelope, the
70
70
  documented `x-rate-limit-*` headers, 75/15min per-user accounting, and the documented
71
- 429 + legacy code 88 refusal.
71
+ 429 + legacy code 88 refusal. `user.fields=profile_image_url` is the seeded photo, or X's default
72
+ avatar URL (`abs.twimg.com/…/default_profile_normal.png`) for a persona seeded without one — the
73
+ same answer the `x` pack gives for the same person; a pulled persona whose reply withheld the field
74
+ is not given one.
72
75
  - **SDK fidelity** — the unmodified current official SDK (`@xdevplatform/xdk`) drives
73
76
  `users.getMe()` against the twin through its own public `baseUrl` config
74
77
  (`xidentity-sdk.integration.test.ts`); the OAuth legs its hardcoded hosts cannot re-aim are
@@ -52,7 +52,7 @@ import { XIdentityBudgetError, XIDENTITY_BUDGET_BURST_CEILING } from "./xidentit
52
52
  import { liveXIdentityExecute, mapUsersMeAccount, pullXIdentity, pushPendingXIdentityActions, syncXIdentityFromReal, } from "./xidentity-connector.js";
53
53
  import { pkceS256 } from "./xidentity-pkce.js";
54
54
  import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, DEFAULT_PUBLIC_CLIENT_ID, defaultRedirectUris } from "./xidentity-store.js";
55
- import { handleXIdentityTwinRequest } from "./xidentity-twin.js";
55
+ import { handleXIdentityTwinRequest, X_DEFAULT_PROFILE_IMAGE_URL } from "./xidentity-twin.js";
56
56
  const AT = '2026-02-01T00:00:00.000Z';
57
57
  const atPlus = (seconds) => new Date(Date.parse(AT) + seconds * 1000).toISOString();
58
58
  /** The origin this harness's world serves the twin at. The seeded demo clients' callbacks are
@@ -502,17 +502,41 @@ export const XIDENTITY_CAPABILITIES = [
502
502
  && d['created_at'] === ADA.createdAt
503
503
  && d['description'] === ADA.description
504
504
  && d['location'] === ADA.location
505
- && d['profile_image_url'] === ADA.profileImageUrl
505
+ && d['profile_image_url'] === X_DEFAULT_PROFILE_IMAGE_URL // Ada is seeded without a photo
506
506
  && d['protected'] === false
507
507
  && d['url'] === ADA.url
508
508
  && d['verified'] === true
509
509
  && d['verified_type'] === 'blue';
510
510
  })),
511
- // The x pack answers user.fields=profile_image_url with X's default avatar for an account seeded
512
- // without a photo (every X account has one); this pack omits it, because a PARTIALLY-pulled persona
513
- // cannot tell "no photo" from "not pulled" (renderUser). Serving the default for a seeded persona
514
- // needs that distinction recorded on the row — until then the two packs answer differently (§9).
515
- todo('xidentity.users_me.default_profile_image', 'users_me', "user.fields=profile_image_url for a persona seeded without a photo: X's default avatar URL (abs.twimg.com default_profile_normal.png), as the x pack serves it, rather than omission", 'api', 'common'),
511
+ // Every X account has a profile image: X's default avatar until the person sets a photo (the x pack's
512
+ // `X_DEFAULT_PROFILE_IMAGE_URL`; X's own payload example serves it for a photo-less user). Postiz's X
513
+ // connect reads it from v2.me and recorded an empty picture for @acmeshoes while the x pack, asked
514
+ // for the same person, answered the default. A PULLED persona the reply told nothing about stays
515
+ // without one (the pull asked; a withheld field is not "no photo"). That both packs answer one person
516
+ // alike is xidentity-x-avatar.integration.test.ts: both packs on one root, which a shipped verify
517
+ // does not reach (A3).
518
+ done('xidentity.users_me.default_profile_image', 'users_me', "user.fields=profile_image_url for a persona seeded without a photo is X's default avatar URL (abs.twimg.com default_profile_normal.png), the same answer the x pack gives for the same person; a seeded photo is served as set, the field stays out unless asked for, and a pulled persona whose reply withheld it is not given one (the x pack's answer for the same person: xidentity-x-avatar.integration.test.ts)", 'api', 'common', () => withRoot(async (h) => {
519
+ const PLAIN = { id: '1790000000000000001', username: 'acmeshoes', name: 'Acme Shoes' };
520
+ const PHOTO = { id: '1790000000000000002', username: 'pictured', name: 'Pictured', profile_image_url: 'https://pbs.twimg.com/profile_images/1/pictured_normal.jpg' };
521
+ await h({ m: 'GET', p: '/i/oauth2/authorize' }); // seed the world (clients, session)
522
+ for (const person of [PLAIN, PHOTO])
523
+ await h({ m: 'POST', p: '/_twin/accounts', b: JSON.stringify(person) });
524
+ await pullXIdentity(async () => ({ data: { id: '4444', username: 'withheld' } }), h.root, '2026-02-01T00:00:01.000Z');
525
+ const meOf = async (accountId, query) => {
526
+ await h({ m: 'POST', p: '/_twin/session', b: JSON.stringify({ account_id: accountId }) });
527
+ const { tokens } = await fullFlow(h);
528
+ const r = await h({ m: 'GET', p: `/2/users/me${query}`, h: { authorization: `Bearer ${tokens['access_token']}` } });
529
+ return r.status === 200 ? body(r).data : undefined;
530
+ };
531
+ const plain = await meOf(PLAIN.id, '?user.fields=profile_image_url');
532
+ const plainBare = await meOf(PLAIN.id, '');
533
+ const photo = await meOf(PHOTO.id, '?user.fields=profile_image_url,name');
534
+ const withheld = await meOf('4444', '?user.fields=profile_image_url');
535
+ return plain?.['profile_image_url'] === X_DEFAULT_PROFILE_IMAGE_URL
536
+ && plainBare !== undefined && !('profile_image_url' in plainBare)
537
+ && photo?.['profile_image_url'] === PHOTO.profile_image_url
538
+ && withheld?.['id'] === '4444' && !('profile_image_url' in withheld);
539
+ })),
516
540
  done('xidentity.users_me.public_metrics', 'users_me', 'user.fields=public_metrics returns the metrics object with the vendor\'s six counters', 'api', 'common', () => withRoot(async (h) => {
517
541
  const { tokens } = await fullFlow(h);
518
542
  const r = await h({ m: 'GET', p: '/2/users/me?user.fields=public_metrics', h: { authorization: `Bearer ${tokens['access_token']}` } });
@@ -41,6 +41,15 @@ export type XIdentityResponse = XResponse;
41
41
  export declare const USER_FIELDS: readonly ["confirmed_email", "connection_status", "created_at", "description", "entities", "id", "is_identity_verified", "location", "name", "parody", "profile_banner_url", "profile_image_url", "protected", "public_metrics", "receives_your_dm", "subscribes_to_you", "subscription", "subscription_type", "url", "username", "verified", "verified_followers_count", "verified_type", "withheld"];
42
42
  /** The `expansions` enum — UserExpansionsParameter (2.167), verbatim. */
43
43
  export declare const USER_EXPANSIONS: readonly ["affiliation", "most_recent_post_id", "pinned_post_id"];
44
+ /** The picture X shows for an account that never uploaded one. Every X account has a profile image;
45
+ * until the person sets a photo it is this URL — the `profile_image_url_https` X serves for such a
46
+ * user (docs.x.com/x-api/account-activity/introduction, its payload example's "Harrison Test",
47
+ * fetched 2026-09-28), and the URL answers X's grey silhouette (observed 2026-09-28:
48
+ * `curl -sS -o /dev/null -w '%{http_code} %{content_type}'` over it gives `200 image/png`, 48×48).
49
+ * TRANSCRIBED from the x pack's `X_DEFAULT_PROFILE_IMAGE_URL` (packages/twin/x/src/x-twin.ts), not
50
+ * imported (A3); xidentity-x-avatar.integration.test.ts asks both packs for the same person
51
+ * on one root and fails if they part. */
52
+ export declare const X_DEFAULT_PROFILE_IMAGE_URL = "https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png";
44
53
  /** The scopes GET /2/users/me demands — the OpenAPI operation's OAuth2UserToken entry lists BOTH. */
45
54
  export declare const USERS_ME_REQUIRED_SCOPES: readonly ["tweet.read", "users.read"];
46
55
  /** Every VENDOR endpoint this twin claims to serve. The conformance check drives one real request
@@ -558,12 +558,31 @@ export const USER_FIELDS = [
558
558
  ];
559
559
  /** The `expansions` enum — UserExpansionsParameter (2.167), verbatim. */
560
560
  export const USER_EXPANSIONS = ['affiliation', 'most_recent_post_id', 'pinned_post_id'];
561
+ /** The picture X shows for an account that never uploaded one. Every X account has a profile image;
562
+ * until the person sets a photo it is this URL — the `profile_image_url_https` X serves for such a
563
+ * user (docs.x.com/x-api/account-activity/introduction, its payload example's "Harrison Test",
564
+ * fetched 2026-09-28), and the URL answers X's grey silhouette (observed 2026-09-28:
565
+ * `curl -sS -o /dev/null -w '%{http_code} %{content_type}'` over it gives `200 image/png`, 48×48).
566
+ * TRANSCRIBED from the x pack's `X_DEFAULT_PROFILE_IMAGE_URL` (packages/twin/x/src/x-twin.ts), not
567
+ * imported (A3); xidentity-x-avatar.integration.test.ts asks both packs for the same person
568
+ * on one root and fails if they part. */
569
+ export const X_DEFAULT_PROFILE_IMAGE_URL = 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png';
570
+ /** A persona's `profile_image_url`: the photo on the row, else X's default for a persona the twin
571
+ * seeded (a seeded persona without a photo has none, and X answers the default for that); a PULLED
572
+ * persona without one is undefined, because the pull asked for the field and a reply that withheld
573
+ * it (partial errors) says nothing about the real account's picture. */
574
+ function profileImageOf(account) {
575
+ if (typeof account.profileImageUrl === 'string' && account.profileImageUrl !== '')
576
+ return account.profileImageUrl;
577
+ return account.pulled === true ? undefined : X_DEFAULT_PROFILE_IMAGE_URL;
578
+ }
561
579
  /** The scopes GET /2/users/me demands — the OpenAPI operation's OAuth2UserToken entry lists BOTH. */
562
580
  export const USERS_ME_REQUIRED_SCOPES = ['tweet.read', 'users.read'];
563
581
  /** Map an account row to the vendor's User object for a requested field set. Default fields are
564
582
  * id, name, username (the OpenAPI/docs default); everything else appears only when asked for and
565
583
  * only when the twin models it — a modelled-but-absent optional field is OMITTED, matching the
566
- * spec (no User property is `required`). Unmodelled enum values (`connection_status`,
584
+ * spec (no User property is `required`) — except `profile_image_url`, which every X account has
585
+ * (X's default avatar until a photo is set; `profileImageOf`). Unmodelled enum values (`connection_status`,
567
586
  * `receives_your_dm`, …) are accepted and omitted; each is a named manifest todo, not silent.
568
587
  * `confirmed_email` additionally demands the `users.email` scope (X's April-2025 announcement —
569
588
  * see xidentity-scopes.ts); OMISSION when the scope is missing is EXTRAPOLATION, pinned by
@@ -584,8 +603,11 @@ function renderUser(account, fields, heldScopes) {
584
603
  out['description'] = account.description;
585
604
  if (want.has('location') && typeof account.location === 'string')
586
605
  out['location'] = account.location;
587
- if (want.has('profile_image_url') && typeof account.profileImageUrl === 'string')
588
- out['profile_image_url'] = account.profileImageUrl;
606
+ if (want.has('profile_image_url')) {
607
+ const picture = profileImageOf(account);
608
+ if (picture !== undefined)
609
+ out['profile_image_url'] = picture;
610
+ }
589
611
  if (want.has('protected'))
590
612
  out['protected'] = account.protectedAccount === true;
591
613
  if (want.has('public_metrics') && account.publicMetrics && typeof account.publicMetrics === 'object')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-xidentity",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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,10 +56,10 @@
56
56
  "react-dom": "^19.2.7"
57
57
  },
58
58
  "peerDependencies": {
59
- "@volter/world-core": "2.0.2"
59
+ "@volter/world-core": "2.0.4"
60
60
  },
61
61
  "devDependencies": {
62
- "@volter/world-core": "2.0.2",
62
+ "@volter/world-core": "2.0.4",
63
63
  "@volter/world-tooling": "0.1.0",
64
64
  "@xdevplatform/xdk": "^0.6.6",
65
65
  "@types/bun": "^1.2.20",
@@ -59,7 +59,7 @@ import {
59
59
  } from './xidentity-connector.ts';
60
60
  import { pkceS256 } from './xidentity-pkce.ts';
61
61
  import { DEFAULT_ACCOUNTS, DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET, DEFAULT_PUBLIC_CLIENT_ID, defaultRedirectUris } from './xidentity-store.ts';
62
- import { handleXIdentityTwinRequest, USER_FIELDS, type XResponse } from './xidentity-twin.ts';
62
+ import { handleXIdentityTwinRequest, USER_FIELDS, X_DEFAULT_PROFILE_IMAGE_URL, type XResponse } from './xidentity-twin.ts';
63
63
 
64
64
  type Step = { m: string; p: string; b?: string; h?: Record<string, string>; at?: string };
65
65
  type Body = Record<string, any>;
@@ -584,17 +584,41 @@ export const XIDENTITY_CAPABILITIES: CapabilitySpec[] = [
584
584
  && d['created_at'] === ADA.createdAt
585
585
  && d['description'] === ADA.description
586
586
  && d['location'] === ADA.location
587
- && d['profile_image_url'] === ADA.profileImageUrl
587
+ && d['profile_image_url'] === X_DEFAULT_PROFILE_IMAGE_URL // Ada is seeded without a photo
588
588
  && d['protected'] === false
589
589
  && d['url'] === ADA.url
590
590
  && d['verified'] === true
591
591
  && d['verified_type'] === 'blue';
592
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'),
593
+ // Every X account has a profile image: X's default avatar until the person sets a photo (the x pack's
594
+ // `X_DEFAULT_PROFILE_IMAGE_URL`; X's own payload example serves it for a photo-less user). Postiz's X
595
+ // connect reads it from v2.me and recorded an empty picture for @acmeshoes while the x pack, asked
596
+ // for the same person, answered the default. A PULLED persona the reply told nothing about stays
597
+ // without one (the pull asked; a withheld field is not "no photo"). That both packs answer one person
598
+ // alike is xidentity-x-avatar.integration.test.ts: both packs on one root, which a shipped verify
599
+ // does not reach (A3).
600
+ done('xidentity.users_me.default_profile_image', 'users_me', "user.fields=profile_image_url for a persona seeded without a photo is X's default avatar URL (abs.twimg.com default_profile_normal.png), the same answer the x pack gives for the same person; a seeded photo is served as set, the field stays out unless asked for, and a pulled persona whose reply withheld it is not given one (the x pack's answer for the same person: xidentity-x-avatar.integration.test.ts)", 'api', 'common', () =>
601
+ withRoot(async (h) => {
602
+ const PLAIN = { id: '1790000000000000001', username: 'acmeshoes', name: 'Acme Shoes' };
603
+ const PHOTO = { id: '1790000000000000002', username: 'pictured', name: 'Pictured', profile_image_url: 'https://pbs.twimg.com/profile_images/1/pictured_normal.jpg' };
604
+ await h({ m: 'GET', p: '/i/oauth2/authorize' }); // seed the world (clients, session)
605
+ for (const person of [PLAIN, PHOTO]) await h({ m: 'POST', p: '/_twin/accounts', b: JSON.stringify(person) });
606
+ await pullXIdentity(async () => ({ data: { id: '4444', username: 'withheld' } }), h.root, '2026-02-01T00:00:01.000Z');
607
+ const meOf = async (accountId: string, query: string): Promise<Body | undefined> => {
608
+ await h({ m: 'POST', p: '/_twin/session', b: JSON.stringify({ account_id: accountId }) });
609
+ const { tokens } = await fullFlow(h);
610
+ const r = await h({ m: 'GET', p: `/2/users/me${query}`, h: { authorization: `Bearer ${tokens['access_token']}` } });
611
+ return r.status === 200 ? body(r).data : undefined;
612
+ };
613
+ const plain = await meOf(PLAIN.id, '?user.fields=profile_image_url');
614
+ const plainBare = await meOf(PLAIN.id, '');
615
+ const photo = await meOf(PHOTO.id, '?user.fields=profile_image_url,name');
616
+ const withheld = await meOf('4444', '?user.fields=profile_image_url');
617
+ return plain?.['profile_image_url'] === X_DEFAULT_PROFILE_IMAGE_URL
618
+ && plainBare !== undefined && !('profile_image_url' in plainBare)
619
+ && photo?.['profile_image_url'] === PHOTO.profile_image_url
620
+ && withheld?.['id'] === '4444' && !('profile_image_url' in withheld);
621
+ })),
598
622
  done('xidentity.users_me.public_metrics', 'users_me', 'user.fields=public_metrics returns the metrics object with the vendor\'s six counters', 'api', 'common', () =>
599
623
  withRoot(async (h) => {
600
624
  const { tokens } = await fullFlow(h);
@@ -639,13 +639,33 @@ export const USER_FIELDS = [
639
639
  /** The `expansions` enum — UserExpansionsParameter (2.167), verbatim. */
640
640
  export const USER_EXPANSIONS = ['affiliation', 'most_recent_post_id', 'pinned_post_id'] as const;
641
641
 
642
+ /** The picture X shows for an account that never uploaded one. Every X account has a profile image;
643
+ * until the person sets a photo it is this URL — the `profile_image_url_https` X serves for such a
644
+ * user (docs.x.com/x-api/account-activity/introduction, its payload example's "Harrison Test",
645
+ * fetched 2026-09-28), and the URL answers X's grey silhouette (observed 2026-09-28:
646
+ * `curl -sS -o /dev/null -w '%{http_code} %{content_type}'` over it gives `200 image/png`, 48×48).
647
+ * TRANSCRIBED from the x pack's `X_DEFAULT_PROFILE_IMAGE_URL` (packages/twin/x/src/x-twin.ts), not
648
+ * imported (A3); xidentity-x-avatar.integration.test.ts asks both packs for the same person
649
+ * on one root and fails if they part. */
650
+ export const X_DEFAULT_PROFILE_IMAGE_URL = 'https://abs.twimg.com/sticky/default_profile_images/default_profile_normal.png';
651
+
652
+ /** A persona's `profile_image_url`: the photo on the row, else X's default for a persona the twin
653
+ * seeded (a seeded persona without a photo has none, and X answers the default for that); a PULLED
654
+ * persona without one is undefined, because the pull asked for the field and a reply that withheld
655
+ * it (partial errors) says nothing about the real account's picture. */
656
+ function profileImageOf(account: Row): string | undefined {
657
+ if (typeof account.profileImageUrl === 'string' && account.profileImageUrl !== '') return account.profileImageUrl;
658
+ return account.pulled === true ? undefined : X_DEFAULT_PROFILE_IMAGE_URL;
659
+ }
660
+
642
661
  /** The scopes GET /2/users/me demands — the OpenAPI operation's OAuth2UserToken entry lists BOTH. */
643
662
  export const USERS_ME_REQUIRED_SCOPES = ['tweet.read', 'users.read'] as const;
644
663
 
645
664
  /** Map an account row to the vendor's User object for a requested field set. Default fields are
646
665
  * id, name, username (the OpenAPI/docs default); everything else appears only when asked for and
647
666
  * only when the twin models it — a modelled-but-absent optional field is OMITTED, matching the
648
- * spec (no User property is `required`). Unmodelled enum values (`connection_status`,
667
+ * spec (no User property is `required`) — except `profile_image_url`, which every X account has
668
+ * (X's default avatar until a photo is set; `profileImageOf`). Unmodelled enum values (`connection_status`,
649
669
  * `receives_your_dm`, …) are accepted and omitted; each is a named manifest todo, not silent.
650
670
  * `confirmed_email` additionally demands the `users.email` scope (X's April-2025 announcement —
651
671
  * see xidentity-scopes.ts); OMISSION when the scope is missing is EXTRAPOLATION, pinned by
@@ -663,7 +683,10 @@ function renderUser(account: Row, fields: readonly string[], heldScopes: readonl
663
683
  if (want.has('created_at') && typeof account.createdAt === 'string') out['created_at'] = account.createdAt;
664
684
  if (want.has('description') && typeof account.description === 'string') out['description'] = account.description;
665
685
  if (want.has('location') && typeof account.location === 'string') out['location'] = account.location;
666
- if (want.has('profile_image_url') && typeof account.profileImageUrl === 'string') out['profile_image_url'] = account.profileImageUrl;
686
+ if (want.has('profile_image_url')) {
687
+ const picture = profileImageOf(account);
688
+ if (picture !== undefined) out['profile_image_url'] = picture;
689
+ }
667
690
  if (want.has('protected')) out['protected'] = account.protectedAccount === true;
668
691
  if (want.has('public_metrics') && account.publicMetrics && typeof account.publicMetrics === 'object') out['public_metrics'] = account.publicMetrics;
669
692
  if (want.has('url') && typeof account.url === 'string') out['url'] = account.url;