@volter/twin-xidentity 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
21
21
  import {
22
22
  ConsentPage,
23
23
  ErrorPage,
24
+ PinPage,
24
25
  type ConsentAccount,
25
26
  type ConsentScopeRow,
26
27
  type ConsentView,
@@ -88,6 +89,67 @@ export function xIdentityConsentState(opts: {
88
89
  };
89
90
  }
90
91
 
92
+ /**
93
+ * What an OAuth 1.0a token lets an app do, as X's authorize screen lists it. OAuth 1.0a has no
94
+ * scopes: the App's permission level (Read / Read and write) decides the grant (docs.x.com
95
+ * developer-apps, "App permissions (OAuth 1.0a)"), and the screen lists what that level allows.
96
+ * The lines follow X's OAuth 1.0a authorize page as widely screenshotted; the live wording was not
97
+ * captured (`xidentity.oauth1.consent_wording`, todo). Keyed rows, so a row is never invented
98
+ * outside the two documented levels.
99
+ */
100
+ export const OAUTH1_CONSENT_ROWS: Record<'read' | 'write', ConsentScopeRow[]> = {
101
+ read: [
102
+ { scope: 'oauth1:timeline', label: 'Posts from your timeline (including protected Posts) as well as your Lists and collections.', group: 'view', known: true },
103
+ { scope: 'oauth1:profile', label: 'Your X profile information and account settings.', group: 'view', known: true },
104
+ { scope: 'oauth1:graph', label: 'Accounts you follow, mute, and block.', group: 'view', known: true },
105
+ ],
106
+ write: [
107
+ { scope: 'oauth1:follow', label: 'Follow and unfollow accounts for you.', group: 'do', known: true },
108
+ { scope: 'oauth1:settings', label: 'Update your profile and account settings for you.', group: 'do', known: true },
109
+ { scope: 'oauth1:post', label: 'Post and delete Posts for you, and engage with Posts posted by others (Like, un-Like, or reply to a Post, Repost, etc.) for you.', group: 'do', known: true },
110
+ { scope: 'oauth1:lists', label: 'Create, manage, and delete Lists and collections for you.', group: 'do', known: true },
111
+ { scope: 'oauth1:moderate', label: 'Mute, block, and report accounts for you.', group: 'do', known: true },
112
+ ],
113
+ };
114
+
115
+ /**
116
+ * THE OAUTH 1.0a STATE BUILDER — the authorize screen for a pending request token, folded out of
117
+ * the projection: the App's name (`appName`, resolved by the caller from the registered App or the
118
+ * World's own), the account the request was bound to when the screen was first served, the rows
119
+ * the granted permission level allows, and the callback's host. `null` for an unknown or settled
120
+ * request token or a missing account; it invents nothing.
121
+ */
122
+ export function xIdentityOAuth1ConsentState(opts: {
123
+ root?: string;
124
+ requestToken: string;
125
+ appName: string;
126
+ origin: string;
127
+ }): ConsentView | null {
128
+ const row = readOne(opts.root, 'oauth1_request_token', opts.requestToken);
129
+ if (!row || row.state !== 'pending') return null;
130
+ const account = readOne(opts.root, 'account', String(row.accountId ?? ''));
131
+ if (!account) return null;
132
+ const level = row.accessLevel === 'read' ? 'read' : 'write';
133
+ const scopes = level === 'read' ? OAUTH1_CONSENT_ROWS.read : [...OAUTH1_CONSENT_ROWS.read, ...OAUTH1_CONSENT_ROWS.write];
134
+ let redirectHost = '';
135
+ if (row.callback !== 'oob') {
136
+ try {
137
+ redirectHost = new URL(String(row.callback)).host;
138
+ } catch {
139
+ redirectHost = String(row.callback);
140
+ }
141
+ }
142
+ return {
143
+ requestId: opts.requestToken,
144
+ origin: opts.origin,
145
+ app: { clientId: String(row.consumerKey), name: opts.appName },
146
+ account: toConsentAccount(account),
147
+ scopes,
148
+ redirectHost,
149
+ decisionPath: '/oauth/authorize',
150
+ };
151
+ }
152
+
91
153
  /** `base` is where the twin is reached (`twinPublicBase`: origin plus any served-World mount path),
92
154
  * so the page's assets resolve inside the World's mount; empty for an in-process render. */
93
155
  function page(title: string, bodyMarkup: string, base: string): string {
@@ -107,6 +169,11 @@ export function consentPageHtml(view: ConsentView, base = ''): string {
107
169
  return page(`Authorize ${view.app.name} to access your account? / X`, markup, base);
108
170
  }
109
171
 
172
+ /** Render the out-of-band PIN page an `oob` request token ends on. */
173
+ export function pinPageHtml(appName: string, pin: string, base = ''): string {
174
+ return page(`${appName} / X`, renderToStaticMarkup(createElement(PinPage, { appName, pin })), base);
175
+ }
176
+
110
177
  /** Render the authorize-endpoint error page (an un-redirectable failure). */
111
178
  export function errorPageHtml(props: ErrorPageProps, base = ''): string {
112
179
  return page(`Error ${props.status}: ${props.code} / X`, renderToStaticMarkup(createElement(ErrorPage, props)), base);