@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.
@@ -1,4 +1,4 @@
1
- import { type ConsentView, type ErrorPageProps } from '../client/xidentity-consent.js';
1
+ import { type ConsentScopeRow, type ConsentView, type ErrorPageProps } from '../client/xidentity-consent.js';
2
2
  export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from '../client/xidentity-consent.js';
3
3
  export { XIDENTITY_CONSENT_CLIENT_CSS as CONSENT_CLIENT_CSS, XIDENTITY_CONSENT_CLIENT_JS as CONSENT_CLIENT_JS } from './xidentity-consent-client.gen.js';
4
4
  /** Twin-only asset paths, clearly namespaced so they can never be mistaken for vendor surface. */
@@ -15,7 +15,31 @@ export declare function xIdentityConsentState(opts: {
15
15
  requestId: string;
16
16
  origin: string;
17
17
  }): ConsentView | null;
18
+ /**
19
+ * What an OAuth 1.0a token lets an app do, as X's authorize screen lists it. OAuth 1.0a has no
20
+ * scopes: the App's permission level (Read / Read and write) decides the grant (docs.x.com
21
+ * developer-apps, "App permissions (OAuth 1.0a)"), and the screen lists what that level allows.
22
+ * The lines follow X's OAuth 1.0a authorize page as widely screenshotted; the live wording was not
23
+ * captured (`xidentity.oauth1.consent_wording`, todo). Keyed rows, so a row is never invented
24
+ * outside the two documented levels.
25
+ */
26
+ export declare const OAUTH1_CONSENT_ROWS: Record<'read' | 'write', ConsentScopeRow[]>;
27
+ /**
28
+ * THE OAUTH 1.0a STATE BUILDER — the authorize screen for a pending request token, folded out of
29
+ * the projection: the App's name (`appName`, resolved by the caller from the registered App or the
30
+ * World's own), the account the request was bound to when the screen was first served, the rows
31
+ * the granted permission level allows, and the callback's host. `null` for an unknown or settled
32
+ * request token or a missing account; it invents nothing.
33
+ */
34
+ export declare function xIdentityOAuth1ConsentState(opts: {
35
+ root?: string;
36
+ requestToken: string;
37
+ appName: string;
38
+ origin: string;
39
+ }): ConsentView | null;
18
40
  /** Render the authorize page from a view model. The tab title mirrors X's "Authorize <app>". */
19
41
  export declare function consentPageHtml(view: ConsentView, base?: string): string;
42
+ /** Render the out-of-band PIN page an `oob` request token ends on. */
43
+ export declare function pinPageHtml(appName: string, pin: string, base?: string): string;
20
44
  /** Render the authorize-endpoint error page (an un-redirectable failure). */
21
45
  export declare function errorPageHtml(props: ErrorPageProps, base?: string): string;
@@ -18,7 +18,7 @@
18
18
  // progressive enhancement only, and the serve path never builds (runtime contract R12b, R9).
19
19
  import { createElement } from 'react';
20
20
  import { renderToStaticMarkup } from 'react-dom/server';
21
- import { ConsentPage, ErrorPage, } from "../client/xidentity-consent.js";
21
+ import { ConsentPage, ErrorPage, PinPage, } from "../client/xidentity-consent.js";
22
22
  import { describeScopes, parseScopeParam, sortScopesForConsent } from "./xidentity-scopes.js";
23
23
  import { readOne } from "./xidentity-store.js";
24
24
  export { XIDENTITY_CONSENT_CLIENT_CSS as CONSENT_CLIENT_CSS, XIDENTITY_CONSENT_CLIENT_JS as CONSENT_CLIENT_JS } from "./xidentity-consent-client.gen.js";
@@ -72,6 +72,63 @@ export function xIdentityConsentState(opts) {
72
72
  redirectHost,
73
73
  };
74
74
  }
75
+ /**
76
+ * What an OAuth 1.0a token lets an app do, as X's authorize screen lists it. OAuth 1.0a has no
77
+ * scopes: the App's permission level (Read / Read and write) decides the grant (docs.x.com
78
+ * developer-apps, "App permissions (OAuth 1.0a)"), and the screen lists what that level allows.
79
+ * The lines follow X's OAuth 1.0a authorize page as widely screenshotted; the live wording was not
80
+ * captured (`xidentity.oauth1.consent_wording`, todo). Keyed rows, so a row is never invented
81
+ * outside the two documented levels.
82
+ */
83
+ export const OAUTH1_CONSENT_ROWS = {
84
+ read: [
85
+ { scope: 'oauth1:timeline', label: 'Posts from your timeline (including protected Posts) as well as your Lists and collections.', group: 'view', known: true },
86
+ { scope: 'oauth1:profile', label: 'Your X profile information and account settings.', group: 'view', known: true },
87
+ { scope: 'oauth1:graph', label: 'Accounts you follow, mute, and block.', group: 'view', known: true },
88
+ ],
89
+ write: [
90
+ { scope: 'oauth1:follow', label: 'Follow and unfollow accounts for you.', group: 'do', known: true },
91
+ { scope: 'oauth1:settings', label: 'Update your profile and account settings for you.', group: 'do', known: true },
92
+ { 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 },
93
+ { scope: 'oauth1:lists', label: 'Create, manage, and delete Lists and collections for you.', group: 'do', known: true },
94
+ { scope: 'oauth1:moderate', label: 'Mute, block, and report accounts for you.', group: 'do', known: true },
95
+ ],
96
+ };
97
+ /**
98
+ * THE OAUTH 1.0a STATE BUILDER — the authorize screen for a pending request token, folded out of
99
+ * the projection: the App's name (`appName`, resolved by the caller from the registered App or the
100
+ * World's own), the account the request was bound to when the screen was first served, the rows
101
+ * the granted permission level allows, and the callback's host. `null` for an unknown or settled
102
+ * request token or a missing account; it invents nothing.
103
+ */
104
+ export function xIdentityOAuth1ConsentState(opts) {
105
+ const row = readOne(opts.root, 'oauth1_request_token', opts.requestToken);
106
+ if (!row || row.state !== 'pending')
107
+ return null;
108
+ const account = readOne(opts.root, 'account', String(row.accountId ?? ''));
109
+ if (!account)
110
+ return null;
111
+ const level = row.accessLevel === 'read' ? 'read' : 'write';
112
+ const scopes = level === 'read' ? OAUTH1_CONSENT_ROWS.read : [...OAUTH1_CONSENT_ROWS.read, ...OAUTH1_CONSENT_ROWS.write];
113
+ let redirectHost = '';
114
+ if (row.callback !== 'oob') {
115
+ try {
116
+ redirectHost = new URL(String(row.callback)).host;
117
+ }
118
+ catch {
119
+ redirectHost = String(row.callback);
120
+ }
121
+ }
122
+ return {
123
+ requestId: opts.requestToken,
124
+ origin: opts.origin,
125
+ app: { clientId: String(row.consumerKey), name: opts.appName },
126
+ account: toConsentAccount(account),
127
+ scopes,
128
+ redirectHost,
129
+ decisionPath: '/oauth/authorize',
130
+ };
131
+ }
75
132
  /** `base` is where the twin is reached (`twinPublicBase`: origin plus any served-World mount path),
76
133
  * so the page's assets resolve inside the World's mount; empty for an in-process render. */
77
134
  function page(title, bodyMarkup, base) {
@@ -88,6 +145,10 @@ export function consentPageHtml(view, base = '') {
88
145
  const markup = renderToStaticMarkup(createElement(ConsentPage, { view }));
89
146
  return page(`Authorize ${view.app.name} to access your account? / X`, markup, base);
90
147
  }
148
+ /** Render the out-of-band PIN page an `oob` request token ends on. */
149
+ export function pinPageHtml(appName, pin, base = '') {
150
+ return page(`${appName} / X`, renderToStaticMarkup(createElement(PinPage, { appName, pin })), base);
151
+ }
91
152
  /** Render the authorize-endpoint error page (an un-redirectable failure). */
92
153
  export function errorPageHtml(props, base = '') {
93
154
  return page(`Error ${props.status}: ${props.code} / X`, renderToStaticMarkup(createElement(ErrorPage, props)), base);
@@ -0,0 +1,93 @@
1
+ import { type TwinResource } from '@volter/world-core';
2
+ import { type XResponse } from './xidentity-problems.js';
3
+ import { type Row } from './xidentity-store.js';
4
+ /** RFC 3986 percent-encoding, the guide's "Percent encoding parameters" (unreserved: A-Z a-z 0-9 - . _ ~). */
5
+ export declare const pct: (s: string) => string;
6
+ /** The `Authorization: OAuth k="v", …` header as decoded pairs; null when it is not an OAuth header. */
7
+ export declare function parseOAuthHeader(value: string | undefined): Array<[string, string]> | null;
8
+ export type SignedRequest = {
9
+ method: string;
10
+ /** The URL the client signed, without its query: scheme://host[:non-default port]/path. */
11
+ baseUrl: string;
12
+ /** Header pairs (realm excluded later), query pairs and — for a form-encoded body — body pairs. */
13
+ header: Array<[string, string]>;
14
+ query: Array<[string, string]>;
15
+ body: Array<[string, string]>;
16
+ };
17
+ /** Every oauth_* protocol parameter the request carries, header first (RFC 5849 §3.5 order). */
18
+ export declare function oauth1Params(req: SignedRequest): Map<string, string>;
19
+ /** HMAC-SHA1 over the base string with `pct(consumer_secret)&pct(token_secret)` — the guide's key. */
20
+ export declare function signatureMatches(req: SignedRequest, consumerSecret: string, tokenSecret: string): boolean;
21
+ /**
22
+ * The URL the client signed. Reached through the injector the request still names X's host (the
23
+ * Node path keeps Host; the fetch path names it in `x-volter-twin-original-host`) and the client
24
+ * signed `https://<that host><path>`. Reached directly, it signed where it sent: `origin` (the
25
+ * twin's public base, a World mount included). A direct in-process call with neither is X's own
26
+ * API origin.
27
+ */
28
+ export declare function signedBaseUrl(headers: Record<string, string> | undefined, origin: string | undefined, path: string): string;
29
+ /** Split a request into the signed pieces. Body pairs count only for a form-encoded body (the guide). */
30
+ export declare function signedRequestOf(req: {
31
+ method: string;
32
+ path: string;
33
+ body?: string;
34
+ headers?: Record<string, string>;
35
+ origin?: string;
36
+ }): SignedRequest;
37
+ /** 215 "Bad Authentication data." — no usable OAuth header at all; X's table pairs it with HTTP 400. */
38
+ export declare const badAuthenticationData: () => XResponse;
39
+ /** 32 "Could not authenticate you." — an unknown consumer key or a signature that does not verify (401). */
40
+ export declare const couldNotAuthenticate: () => XResponse;
41
+ /** 89 "Invalid or expired token." — verbatim from the invalidate_token example (api-reference). */
42
+ export declare const invalidOrExpiredToken: () => XResponse;
43
+ /** 415, the body verbatim from developer-apps' troubleshooting. HTTP 403 is EXTRAPOLATION
44
+ * (the page shows the body only) — `xidentity.oauth1.callback_refusal_status` pins it. */
45
+ export declare const callbackNotApproved: () => XResponse;
46
+ export type OAuth1App = {
47
+ consumerKey: string;
48
+ secret: string;
49
+ name: string;
50
+ callbackUrls: string[];
51
+ accessLevel: 'read' | 'read-write';
52
+ world?: true;
53
+ };
54
+ /** The env pairs a World names its X App with: Postiz's X_API_KEY / X_API_SECRET (x.provider.ts),
55
+ * and the TWITTER_ spelling of the same "API Key and Secret" (docs.x.com's own name for the pair). */
56
+ export declare const WORLD_APP_ENV: ReadonlyArray<readonly [string, string]>;
57
+ /**
58
+ * The World's own App: the one its env describes. An app holding consumer keys is an App its
59
+ * developer made in the X developer console, so the twin takes the World's values as that App's —
60
+ * the slack pack's worldSlackApp precedent. Only a value the World sets counts (worldEnvValue),
61
+ * never one the caller's shell passes. Where the documentation stops and the twin decides: the App
62
+ * is named for the World (VOLTER_WORLD_NAME, else "World app"); a key with no secret is no App;
63
+ * its console lists no callback URLs, since the env names none, so it accepts the callback each
64
+ * request names; its permission is Read and write, which an `x_auth_access_type=read` narrows.
65
+ */
66
+ export declare function worldOAuth1App(): OAuth1App | undefined;
67
+ /** The App a consumer key names: one registered through the twin door, else the World's own. */
68
+ export declare function oauth1AppOf(resources: readonly TwinResource[], consumerKey: string | undefined): OAuth1App | undefined;
69
+ type Req = {
70
+ method: string;
71
+ path: string;
72
+ body?: string;
73
+ headers?: Record<string, string>;
74
+ root?: string;
75
+ occurredAt?: string;
76
+ origin?: string;
77
+ readOnly?: boolean;
78
+ };
79
+ export type OAuth1Principal = {
80
+ token: Row;
81
+ app: OAuth1App;
82
+ };
83
+ /** A request signed with an access token this pack issued and has not revoked; null otherwise. */
84
+ export declare function verifyUserContext(req: Req, resources: readonly TwinResource[]): OAuth1Principal | null;
85
+ /** The OAuth 2.0 scopes an OAuth 1.0a token's permission level stands for on a v2 endpoint that
86
+ * declares both schemes (the OpenAPI's `security` lists OAuth2UserToken scopes OR UserToken). */
87
+ export declare function scopesOfAccessLevel(level: unknown): string[];
88
+ export declare const OAUTH1_ENDPOINTS: readonly ["POST /oauth/request_token", "GET /oauth/authorize", "POST /oauth/authorize", "GET /oauth/authenticate", "POST /oauth/access_token", "POST /1.1/oauth/invalidate_token", "POST /1.1/oauth/invalidate_token.json"];
89
+ /** Paths this slice writes on (read-only twins refuse them). */
90
+ export declare const OAUTH1_WRITE_PATHS: Set<string>;
91
+ /** Route an OAuth 1.0a request; null when the path is not this slice's. */
92
+ export declare function routeOAuth1(req: Req, method: string, path: string): Promise<XResponse | null>;
93
+ export {};