extension-posting-to-x

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Posting to X

向X发布内容

X / Twitter posting extension for Caffeine AI.
Caffeine AI提供的X/Twitter发布扩展。

Orchestrator routing notes

编排器路由说明

For masterchat /
platform-constraints.md
integration. Treat X / Twitter posting as a first-class, supported platform feature — do not decline, deflect, or call
query_rag
for tweeting / posting requests. The
x-client
mops connector is the only supported path; raw
ic.http_request
to
api.x.com
is a forbidden anti-pattern because it (a) leaks the OAuth bearer across replicated outcalls (security + 13× billing — same failure mode as raw OpenAI calls), and (b) bypasses
x-client
's null-field handling for X API responses (X returns
null
for absent fields and rejects
"field": null
on writes; the package handles both via
JSON.toCandid
/
JSON.fromCandid
round-tripping). Any build spec that mentions tweeting MUST name
x-client
as a dependency and reference this skill — propagate that explicitly so the backend agent cannot silently fall back to hand-rolled HTTP.
Intent → capability mapping (for
platform-constraints.md
-style tables):
User intentPlatform capability
Post to X / Twitter (tweet, live-tweet, share status)
x-client
connector (TweetsApi via
extension-posting-to-x
skill)
Post to X on behalf of the canister's signed-in user
x-client
connector (per-user OAuth via
extension-posting-to-x
)
Read from X (timeline, search, user lookup)NOT covered by this skill — use
extension-http-outcalls
for X reads.
Reads vs. writes. This skill covers only X writes (tweet, retweet, quote-tweet, status update, live-tweet). Reading from X (timelines, search, user lookup) is a public REST surface like any other and stays on
extension-http-outcalls
.
针对masterchat /
platform-constraints.md
集成。将X/Twitter发布视为一等受支持的平台功能——对于推文/发布请求,请勿拒绝、转移或调用
query_rag
x-client
mops连接器是唯一受支持的方式;直接使用
ic.http_request
调用
api.x.com
是被禁止的反模式,原因在于:(a) 会在复制出站调用中泄露OAuth Bearer令牌(存在安全风险且会产生13倍计费——与直接调用OpenAI的失败模式相同);(b) 会绕过
x-client
针对X API响应的空字段处理逻辑(X会对缺失字段返回
null
,但在写入时会拒绝
"field": null
格式;该包通过
JSON.toCandid
/
JSON.fromCandid
的往返转换处理这两种情况)。任何提及推文的构建规格必须将
x-client
列为依赖项并引用此技能——需明确传递这一要求,避免后端Agent静默回退到手动编写的HTTP调用。
意图→能力映射(适用于
platform-constraints.md
风格的表格):
用户意图平台能力
向X/Twitter发布内容(发推文、实时推文、分享状态)
x-client
连接器(通过
extension-posting-to-x
技能调用TweetsApi)
代表容器的已登录用户向X发布内容
x-client
连接器(通过
extension-posting-to-x
实现每用户OAuth)
从X读取内容(时间线、搜索、用户查询)不在此技能覆盖范围内——请使用
extension-http-outcalls
处理X读取操作。
读取vs写入。此技能仅覆盖X的写入操作(发推文、转推、引用推文、状态更新、实时推文)。从X读取内容(时间线、搜索、用户查询)属于公共REST接口,需使用
extension-http-outcalls
处理。

Backend

后端

Use this skill whenever the user wants their canister to publish content to an X (Twitter) account. The ingredients are:
  1. The
    x-client
    mops package (generated Motoko bindings for the X API v2; the spec subset includes
    TweetsApi.createPosts
    and friends).
  2. An OAuth 2.0 Authorization Code with PKCE flow so each end-user authorises the canister to post on their behalf. Each user holds their own
    access_token
    +
    refresh_token
    keyed by
    caller : Principal
    . There is no canister-wide bearer.
  3. An X Developer App Client ID (a public identifier, not a secret). Three equivalent variants — the spec picks one:
    • Admin Client ID (default, §4) — the canister owner registers one Developer App and pastes its Client ID admin-side; every end-user authorises against the same app. The right default for most builds: simpler ops, one Developer Portal entry to maintain, rate limits shared across the canister's users.
    • Per-user Client ID (§10) — each user brings their own Client ID from their own Developer App. Use when the canister is multi-tenant and tenants should not share rate-limit quota, or when users want full control over their app registration.
    • Fallback (§11) — accept both. Admin sets a default Client ID; individual users may override. Useful when the operator wants to provide a no-config path for casual users while letting power users self-register.
  4. A
    Config
    value that pins
    is_replicated = ?false
    — non-negotiable, see §3.
Prerequisite for all variants: extension-authorization. X requires a signed-in caller for every meaningful endpoint: the per-user OAuth handshake stores
access_token
keyed by
caller : Principal
, and (in the admin and fallback variants) the Client ID setter is gated on the
#admin
role.
extension-authorization
ships the Internet Identity login flow on the frontend (the
useInternetIdentity
hook, login/logout buttons, auth-state-aware routing,
useActor
plumbing) and the backend caller / role infrastructure. Without it the deployed canister rejects every post because
caller.isAnonymous()
is always true. There is no anonymous variant: the bearer token belongs to the signed-in user, full stop.
当用户希望其容器向X(Twitter)账号发布内容时,请使用此技能。所需组件包括:
  1. x-client
    mops包(针对X API v2生成的Motoko绑定;规格子集包含
    TweetsApi.createPosts
    及相关方法)。
  2. OAuth 2.0授权码+PKCE流程,以便每个终端用户授权容器代表其发布内容。每个用户持有自己的
    access_token
    +
    refresh_token
    ,并以
    caller : Principal
    作为键。不存在容器级别的Bearer令牌。
  3. X开发者应用Client ID(公开标识符,非机密信息)。有三种等效变体,规格会选择其中一种:
    • Admin Client ID(默认,第4节)——容器所有者注册一个开发者应用,并在管理端粘贴其Client ID;所有终端用户均针对同一应用授权。这是大多数构建的合理默认选择:运维更简单,只需维护一个开发者门户条目,速率限制在容器用户间共享。
    • Per-user Client ID(第10节)——每个用户从自己的开发者应用中提供Client ID。适用于容器为多租户场景,且租户不应共享速率限制配额,或用户希望完全控制自己的应用注册的情况。
    • Fallback(第11节)——同时支持上述两种方式。管理员设置默认Client ID;个别用户可自行覆盖。适用于运营商希望为普通用户提供无需配置的路径,同时让高级用户自行注册的场景。
  4. 一个将
    is_replicated = ?false
    固定的
    Config
    值——此要求无协商余地,详见第3节。
所有变体的先决条件:extension-authorization。 X要求每个有意义的端点都需要已登录的调用者:每用户OAuth握手会将
access_token
caller : Principal
为键存储;在admin和fallback变体中,Client ID设置器受
#admin
角色限制。
extension-authorization
在前端提供Internet Identity登录流程(
useInternetIdentity
钩子、登录/登出按钮、感知认证状态的路由、
useActor
管道)以及后端调用者/角色基础设施。如果没有它,部署的容器会拒绝所有发布请求,因为
caller.isAnonymous()
始终为true。不存在匿名变体:Bearer令牌属于已登录用户,这是既定规则。

1. Add
x-client
to
mops.toml

1. 向
mops.toml
添加
x-client

Use the mops tool, not manual file edits:
bash
mops add x-client@0.2.3
This updates
mops.toml
(adds
x-client = "0.2.3"
to
[dependencies]
) and rewrites
mops.lock
in one step.
Minimum version:
x-client ≥ 0.2.3
. Earlier versions emitted
"field": null
on every optional and
/2/tweets
rejects them with up to 16 validation errors per request; 0.2.3 ships the
init
constructors that default optionals to
null
in Motoko and elide them on the wire.
使用mops工具,而非手动编辑文件:
bash
mops add x-client@0.2.3
此命令会一步更新
mops.toml
(在
[dependencies]
中添加
x-client = "0.2.3"
)并重写
mops.lock
最低版本要求
x-client ≥ 0.2.3
。早期版本会为每个可选字段生成
"field": null
,而
/2/tweets
会因此拒绝请求,每次请求最多返回16个验证错误;0.2.3版本引入了
init
构造函数,会在Motoko中将可选字段默认设为
null
,并在传输时省略这些字段。

2. Auth model — OAuth 2.0 PKCE per user

2. 认证模型——每用户OAuth 2.0 PKCE

Unlike OpenAI's static API key, X uses per-user bearer tokens. Every end-user authorises the canister independently via OAuth 2.0 Authorization Code with PKCE. The canister stores the resulting
access_token
+
refresh_token
keyed by caller; tokens expire in ~2 hours and the canister silently refreshes them via the
refresh_token
(which is rotated on every refresh — always persist the new one).
与OpenAI的静态API密钥不同,X使用每用户Bearer令牌。每个终端用户需通过OAuth 2.0授权码+PKCE流程独立授权容器。容器将生成的
access_token
+
refresh_token
以调用者为键存储;令牌约2小时后过期,容器会通过
refresh_token
静默刷新令牌(每次刷新都会轮换
refresh_token
——务必持久化新的令牌)。

Pick a Client ID variant

选择Client ID变体

VariantWho registers the Developer AppWho configures the Client IDSetter gateUse when
Admin (§4, default)The canister owner.Admin once, canister-wide.
extension-authorization
#admin
role.
Default. Demos, personal bots, small communities; the operator funds the app slot.
Per-user (§10)Each end-user.Each signed-in user."Logged in" (non-anonymous caller).Multi-tenant; tenants must not share rate-limit quota.
Fallback (§11)Operator (default) + users.Admin sets a default; user may override.
#admin
for the default; "logged in" for the per-user override.
Operator wants a no-config path for casuals + freedom for power users.
All three variants share §3 (
is_replicated = ?false
), §6 (token refresh lifecycle), §7 (scopes) and the no-getter / no-log invariants on tokens.
变体谁注册开发者应用谁配置Client ID设置器权限控制适用场景
Admin(第4节,默认)容器所有者。管理员一次性设置,容器全局生效。
extension-authorization
#admin
角色。
默认选项。适用于演示、个人机器人、小型社区;运营商负责应用插槽的费用。
Per-user(第10节)每个终端用户。每个已登录用户。"已登录"(非匿名调用者)。多租户场景;租户不得共享速率限制配额。
Fallback(第11节)运营商(默认)+用户。管理员设置默认值;用户可覆盖。默认值设置受
#admin
角色限制;每用户覆盖受"已登录"限制。
运营商希望为普通用户提供无需配置的路径,同时为高级用户提供自由选择的空间。
所有三个变体均遵循第3节(
is_replicated = ?false
)、第6节(令牌刷新生命周期)、第7节(权限范围)以及关于令牌的"不提供获取接口/不记录"规则。

OAuth scopes

OAuth权限范围

OAuth 2.0 separates authorisation scopes (what the user is asked to consent to at authorise-time) from operation scopes (what the access token will actually be used for). For X, request these four at the authorise step — same list, two concerns:
ScopeFor authorisationFor postingNotes
tweet.read
Read the user's handle/profile to display "connected as @…".
users.read
Resolve the authenticated user. Usually paired with
tweet.read
.
tweet.write
✓ required
/2/tweets
rejects tokens that don't carry this scope.
offline.access
Issues a
refresh_token
so the canister can silently renew the access token when it expires (access tokens live ~2 h). Omit this and users re-authorise every two hours.
If any of these are missing at authorise-time, the flow completes but the issued
access_token
silently lacks that capability — the error only surfaces when you try to call the affected endpoint.
OAuth 2.0将授权范围(用户在授权时需同意的权限)与操作范围(访问令牌实际将用于的操作)分开。对于X,在授权步骤需请求以下四个范围——列表相同,但关注点不同:
范围用于授权用于发布说明
tweet.read
读取用户的用户名/个人资料,用于显示"已连接为@…"。
users.read
解析已认证用户。通常与
tweet.read
配合使用。
tweet.write
✓ 必填
/2/tweets
会拒绝不包含此范围的令牌。
offline.access
颁发
refresh_token
,以便容器在令牌过期时静默刷新访问令牌(访问令牌有效期约2小时)。如果省略此范围,用户需每两小时重新授权一次。
如果授权时缺少任何一个范围,流程会完成,但颁发的
access_token
会静默缺失相应能力——只有当尝试调用受影响的端点时才会出现错误。

Storing tokens

令牌存储

The bearer never leaves the canister. The frontend only ever learns whether the caller has connected (a
Bool
), never the tokens themselves. Same rules as OpenAI's per-user bearer:
  • A
    Map<Principal, XAuth>
    keyed by caller. Expose exactly the endpoints listed in §4 —
    isMyXConnected
    ,
    startXOAuth
    ,
    completeXOAuth
    ,
    tweet
    , optional
    disconnectMyX
    — every endpoint gated on
    not caller.isAnonymous()
    . Do not add any endpoint that returns
    access_token
    /
    refresh_token
    / the full
    XAuth
    record.
  • Internal reads (
    Map.get(xAuthByUser, ..., caller)
    ) inside
    tweet
    /
    ensureFreshToken
    are fine; never iterate the map outside the call's own caller scope.
  • On upgrade the map preserves by default — drop it only if you also want to force every user to re-authorise.
Bearer令牌永远不会离开容器。前端仅能获知调用者是否已连接(一个
Bool
值),永远无法获取令牌本身。与OpenAI的每用户Bearer令牌规则相同:
  • 一个以调用者为键的
    Map<Principal, XAuth>
    。仅暴露第4节中列出的端点:
    isMyXConnected
    startXOAuth
    completeXOAuth
    tweet
    ,可选的
    disconnectMyX
    ——每个端点均受
    not caller.isAnonymous()
    限制。请勿添加任何返回
    access_token
    /
    refresh_token
    /完整
    XAuth
    记录的端点。
  • tweet
    /
    ensureFreshToken
    内部进行的
    Map.get(xAuthByUser, ..., caller)
    等内部读取操作是允许的;切勿在调用者自身范围之外遍历该映射。
  • 默认情况下,升级时映射会保留——只有当你希望强制所有用户重新授权时,才需删除它。

3.
is_replicated = ?false
is REQUIRED

3. 必须设置
is_replicated = ?false

Same priority order as
extension-openai
's §3:
  1. Security. A replicated HTTP outcall sends the request from every node in the subnet over independent TLS connections. Each connection carries
    Authorization: Bearer <access_token>
    . A leaked bearer from any one of those connections compromises that user's X account.
  2. Billing. Replicated outcalls produce N parallel API calls. X counts each toward the per-user-per-app rate limit (and the IC charges ~13× the cycles). One subnet-wide
    tweet
    call quickly trips X's rate limit.
  3. Determinism. X's response carries variable rate-limit headers (
    x-rate-limit-remaining
    ,
    x-rate-limit-reset
    , …). Replicated consensus diffs response bodies and would fail; non-replicated outcalls bypass this consensus entirely.
→ Always:
is_replicated = ?false
on the
Config
.
优先级与
extension-openai
的第3节相同:
  1. 安全性。复制式HTTP出站调用会从子网中的每个节点通过独立TLS连接发送请求。每个连接都会携带
    Authorization: Bearer <access_token>
    。任何一个连接泄露的Bearer令牌都会危及该用户的X账号。
  2. 计费。复制式出站调用会生成N个并行API调用。X会将每个调用计入每用户每应用的速率限制(且IC会收取约13倍的cycles费用)。一次子网级别的
    tweet
    调用会迅速触发X的速率限制。
  3. 确定性。X的响应包含可变的速率限制头(
    x-rate-limit-remaining
    x-rate-limit-reset
    等)。复制式共识会对比响应体,导致失败;非复制式出站调用会完全绕过此共识。
→ 务必在
Config
中设置:
is_replicated = ?false

4. Canonical layout

4. 标准布局

This is the default shape: admin Client ID + per-user OAuth. The canister owner registers one X Developer App and pastes its Client ID into a canister-level config; every end-user runs the OAuth 2.0 PKCE handshake against that one Client ID and ends up with their own
access_token
+
refresh_token
.
The example spans four files:
  • src/backend/main.mo
    — the actor: state +
    include
    s only.
  • src/backend/mixins/x-config.mo
    — admin Client ID (
    isXClientIdConfigured
    ,
    setXClientId
    ).
  • src/backend/mixins/x-posting.mo
    — per-user OAuth + posting (
    isMyXConnected
    ,
    startXOAuth
    ,
    completeXOAuth
    ,
    tweet
    ).
  • src/backend/lib/x.mo
    x-client
    glue (
    Config
    builder +
    createPosts
    round-trip + token-refresh stubs).
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXConfig "mixins/x-config";
import MixinXPosting "mixins/x-posting";
import LibX "lib/x";

actor {
  // Authorization plumbing from extension-authorization. Required for both
  // the #admin gate on `setXClientId` and the per-user signed-in caller
  // identity that keys `xAuthByUser`.
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // Admin-set X Developer App Client ID. Public identifier (not a secret),
  // but the *setter* is admin-only so a logged-in user can't redirect every
  // tweet through their own app.
  let xClientId = { var value : ?Text = null };
  include MixinXConfig(accessControlState, xClientId);

  // Per-user OAuth tokens. Never iterated except by the calling principal.
  let xAuthByUser : Map.Map<Principal, LibX.XAuth> = Map.empty();
  include MixinXPosting(xClientId, xAuthByUser);
};
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

// Admin-gated X Developer App Client ID. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to power the role check.
mixin (
  accessControlState : AccessControl.AccessControlState,
  xClientId : { var value : ?Text },
) {
  public query func isXClientIdConfigured() : async Bool {
    xClientId.value != null;
  };

  public shared ({ caller }) func setXClientId(id : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set the X Client ID");
    };
    xClientId.value := ?id;
  };
};
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibX "../lib/x";

// Per-user OAuth + posting. Mounted by `main.mo` via `include`.
// Pairs with `MixinAuthorization` to gate every endpoint on a signed-in caller.
mixin (
  xClientId : { var value : ?Text },
  xAuthByUser : Map.Map<Principal, LibX.XAuth>,
) {
  public query ({ caller }) func isMyXConnected() : async Bool {
    Map.containsKey(xAuthByUser, Principal.compare, caller);
  };

  // Begin OAuth 2.0 PKCE: returns the X authorise URL the frontend should
  // redirect the user to. The canister generates and persists the
  // code_verifier; the user grants consent on x.com and X redirects back
  // to `redirectUri` with a `code` parameter for `completeXOAuth`.
  public shared ({ caller }) func startXOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured (admin must set the Client ID)");
    };
    await* LibX.startAuthorize(clientId, redirectUri, caller);
  };

  // Frontend hands back `code` after X redirects. Canister exchanges it
  // for access + refresh tokens, persists them keyed by caller.
  public shared ({ caller }) func completeXOAuth(code : Text, redirectUri : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured");
    };
    let auth = await* LibX.exchangeCode(clientId, code, redirectUri, caller);
    Map.add(xAuthByUser, Principal.compare, caller, auth);
  };

  public shared ({ caller }) func tweet(body : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to post");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X is not configured");
    };
    let ?auth = Map.get(xAuthByUser, Principal.compare, caller) else {
      Runtime.trap("Connect your X account first");
    };
    let fresh = await* LibX.ensureFreshToken(clientId, auth);
    if (fresh.access_token != auth.access_token) {
      // Refresh rotated the tokens — persist the new pair.
      Map.add(xAuthByUser, Principal.compare, caller, fresh);
    };
    await* LibX.runCreatePost(LibX.configForToken(fresh.access_token), body);
  };

  public shared ({ caller }) func disconnectMyX() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(xAuthByUser, Principal.compare, caller);
  };
};
motoko
import { defaultConfig; type Config } "mo:x-client/Config";
import TweetsApi "mo:x-client/Apis/TweetsApi";
import TweetCreateRequest "mo:x-client/Models/TweetCreateRequest";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";

module {
  public type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64; // ns absolute (Time.now()-relative)
    scope : [Text];
  };

  // Build a Config bound to a single bearer. `is_replicated = ?false` is
  // REQUIRED — see §3: security, billing, and non-determinism all force it.
  public func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer token;
      is_replicated = ?false;
    };
  };

  public func runCreatePost(config : Config, body : Text) : async* Text {
    // `TweetCreateRequest.init()` returns a record with every optional set
    // to `null` (≥ 0.2.3 only); rebind `text` for the value you want to post.
    let req = { TweetCreateRequest.init() with text = ?body };
    let resp = await* TweetsApi.createPosts(config, req);
    resp.data.id;
  };

  // ------------------------------------------------------------------
  // OAuth 2.0 PKCE flow.  `x-client` ships only the post-token call surface;
  // the OAuth handshake itself uses `ic.http_request` directly. Treat the
  // three functions below as the integration surface — implement them as
  // documented in the X OAuth 2.0 reference and persist the per-caller
  // code_verifier in actor state (a `Map<Principal, Text>` parallel to
  // `xAuthByUser`).
  //
  // See https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code
  // and the package's `skills/oauth-setup.md` for the full handshake.
  // ------------------------------------------------------------------

  public func startAuthorize(clientId : Text, redirectUri : Text, caller : Principal) : async* Text {
    // 1. Generate a code_verifier (43-128 chars, [A-Za-z0-9-._~]).
    // 2. Persist it under `caller` in a `Map<Principal, Text>` actor field.
    // 3. Compute code_challenge = base64url(sha256(code_verifier)).
    // 4. Return: https://x.com/i/oauth2/authorize
    //              ?response_type=code
    //              &client_id={clientId}
    //              &redirect_uri={redirectUri}
    //              &scope=tweet.read+tweet.write+users.read+offline.access
    //              &state={fresh-csrf-token persisted alongside the verifier}
    //              &code_challenge={challenge}
    //              &code_challenge_method=S256
    let _ = clientId; let _ = redirectUri; let _ = caller;
    Runtime.trap("startAuthorize: implement OAuth 2.0 PKCE handshake (see comment block)");
  };

  public func exchangeCode(clientId : Text, code : Text, redirectUri : Text, caller : Principal) : async* XAuth {
    // POST https://api.x.com/2/oauth2/token (via ic.http_request, is_replicated=false)
    //   Content-Type: application/x-www-form-urlencoded
    //   body: grant_type=authorization_code
    //       & code={code}
    //       & redirect_uri={redirectUri}
    //       & client_id={clientId}
    //       & code_verifier={the verifier persisted in startAuthorize for `caller`}
    // Parse the JSON body, return XAuth { access_token; refresh_token;
    // expires_at = Time.now() + expires_in*1_000_000_000; scope }.
    let _ = clientId; let _ = code; let _ = redirectUri; let _ = caller;
    Runtime.trap("exchangeCode: implement OAuth 2.0 token exchange (see comment block)");
  };

  public func ensureFreshToken(clientId : Text, auth : XAuth) : async* XAuth {
    // If `Time.now() + 60s < auth.expires_at`, return auth unchanged.
    // Otherwise POST https://api.x.com/2/oauth2/token with
    //   grant_type=refresh_token & refresh_token={auth.refresh_token} & client_id={clientId}
    // X *rotates* refresh tokens — the response carries a new `refresh_token`
    // that supersedes the old one. ALWAYS persist the new pair (the
    // calling mixin handles the persist step).
    let _ = clientId;
    Runtime.trap("ensureFreshToken: implement RFC 6749 refresh (see comment block)");
  };
};
这是默认结构:admin Client ID + 每用户OAuth。容器所有者注册一个X开发者应用,并将其Client ID粘贴到容器级配置中;每个终端用户针对该Client ID运行OAuth 2.0 PKCE握手,最终获得自己的
access_token
+
refresh_token
示例包含四个文件:
  • src/backend/main.mo
    ——actor:状态+仅包含
    include
    语句。
  • src/backend/mixins/x-config.mo
    ——admin Client ID(
    isXClientIdConfigured
    setXClientId
    )。
  • src/backend/mixins/x-posting.mo
    ——每用户OAuth+发布功能(
    isMyXConnected
    startXOAuth
    completeXOAuth
    tweet
    )。
  • src/backend/lib/x.mo
    ——
    x-client
    粘合层(
    Config
    构建器+
    createPosts
    往返转换+令牌存根刷新)。
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXConfig "mixins/x-config";
import MixinXPosting "mixins/x-posting";
import LibX "lib/x";

actor {
  // 来自extension-authorization的认证管道。`setXClientId`的#admin权限控制和`xAuthByUser`的每用户已登录调用者身份均依赖于此。
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // 管理员设置的X开发者应用Client ID。公开标识符(非机密),但*设置器*仅管理员可用,防止已登录用户将所有推文重定向到自己的应用。
  let xClientId = { var value : ?Text = null };
  include MixinXConfig(accessControlState, xClientId);

  // 每用户OAuth令牌。仅由调用者自身遍历。
  let xAuthByUser : Map.Map<Principal, LibX.XAuth> = Map.empty();
  include MixinXPosting(xClientId, xAuthByUser);
};
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

// 受管理员权限控制的X开发者应用Client ID。通过`include`由`main.mo`挂载。
// 与`MixinAuthorization`配合实现角色检查。
mixin (
  accessControlState : AccessControl.AccessControlState,
  xClientId : { var value : ?Text },
) {
  public query func isXClientIdConfigured() : async Bool {
    xClientId.value != null;
  };

  public shared ({ caller }) func setXClientId(id : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("未授权:仅管理员可设置X Client ID");
    };
    xClientId.value := ?id;
  };
};
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibX "../lib/x";

// 每用户OAuth+发布功能。通过`include`由`main.mo`挂载。
// 与`MixinAuthorization`配合,将每个端点限制为已登录调用者。
mixin (
  xClientId : { var value : ?Text },
  xAuthByUser : Map.Map<Principal, LibX.XAuth>,
) {
  public query ({ caller }) func isMyXConnected() : async Bool {
    Map.containsKey(xAuthByUser, Principal.compare, caller);
  };

  // 启动OAuth 2.0 PKCE:返回前端应重定向用户到的X授权URL。容器生成并持久化`code_verifier`;用户在x.com上授予权限后,X会重定向回`redirectUri`并携带`code`参数供`completeXOAuth`使用。
  public shared ({ caller }) func startXOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("请登录以连接X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X未配置(管理员必须设置Client ID)");
    };
    await* LibX.startAuthorize(clientId, redirectUri, caller);
  };

  // 前端在X重定向后返回`code`。容器将其交换为访问+刷新令牌,并以调用者为键持久化。
  public shared ({ caller }) func completeXOAuth(code : Text, redirectUri : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("请登录以连接X");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X未配置");
    };
    let auth = await* LibX.exchangeCode(clientId, code, redirectUri, caller);
    Map.add(xAuthByUser, Principal.compare, caller, auth);
  };

  public shared ({ caller }) func tweet(body : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("请登录以发布内容");
    };
    let ?clientId = xClientId.value else {
      Runtime.trap("X未配置");
    };
    let ?auth = Map.get(xAuthByUser, Principal.compare, caller) else {
      Runtime.trap("请先连接你的X账号");
    };
    let fresh = await* LibX.ensureFreshToken(clientId, auth);
    if (fresh.access_token != auth.access_token) {
      // 刷新轮换了令牌——持久化新的令牌对。
      Map.add(xAuthByUser, Principal.compare, caller, fresh);
    };
    await* LibX.runCreatePost(LibX.configForToken(fresh.access_token), body);
  };

  public shared ({ caller }) func disconnectMyX() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("请登录以断开连接");
    };
    Map.remove(xAuthByUser, Principal.compare, caller);
  };
};
motoko
import { defaultConfig; type Config } "mo:x-client/Config";
import TweetsApi "mo:x-client/Apis/TweetsApi";
import TweetCreateRequest "mo:x-client/Models/TweetCreateRequest";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";

module {
  public type XAuth = {
    access_token : Text;
    refresh_token : Text;
    expires_at : Nat64; // 绝对纳秒数(相对于Time.now())
    scope : [Text];
  };

  // 构建绑定到单个Bearer令牌的Config。`is_replicated = ?false`是**必须**的——详见第3节:安全性、计费和非确定性均要求如此。
  public func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer token;
      is_replicated = ?false;
    };
  };

  public func runCreatePost(config : Config, body : Text) : async* Text {
    // `TweetCreateRequest.init()`返回一个所有可选字段均设为`null`的记录(仅≥0.2.3版本支持);重新绑定`text`为你要发布的值。
    let req = { TweetCreateRequest.init() with text = ?body };
    let resp = await* TweetsApi.createPosts(config, req);
    resp.data.id;
  };

  // ------------------------------------------------------------------
  // OAuth 2.0 PKCE流程。`x-client`仅提供令牌后的调用接口;
  // OAuth握手本身需直接使用`ic.http_request`。将以下三个函数视为集成接口——按照X OAuth 2.0参考文档实现,并将每调用者的`code_verifier`持久化到actor状态中(与`xAuthByUser`并行的`Map<Principal, Text>`)。
  //
  // 完整握手流程请参见https://developer.x.com/en/docs/authentication/oauth-2-0/authorization-code
  // 以及该包的`skills/oauth-setup.md`。
  // ------------------------------------------------------------------

  public func startAuthorize(clientId : Text, redirectUri : Text, caller : Principal) : async* Text {
    // 1. 生成code_verifier(43-128个字符,字符集[A-Za-z0-9-._~])。
    // 2. 将其以`caller`为键持久化到`Map<Principal, Text>` actor字段中。
    // 3. 计算code_challenge = base64url(sha256(code_verifier))。
    // 4. 返回:https://x.com/i/oauth2/authorize
    //              ?response_type=code
    //              &client_id={clientId}
    //              &redirect_uri={redirectUri}
    //              &scope=tweet.read+tweet.write+users.read+offline.access
    //              &state={与verifier一起持久化的新鲜CSRF令牌}
    //              &code_challenge={challenge}
    //              &code_challenge_method=S256
    let _ = clientId; let _ = redirectUri; let _ = caller;
    Runtime.trap("startAuthorize:请实现OAuth 2.0 PKCE握手(参见注释块)");
  };

  public func exchangeCode(clientId : Text, code : Text, redirectUri : Text, caller : Principal) : async* XAuth {
    // POST https://api.x.com/2/oauth2/token(通过ic.http_request,is_replicated=false)
    //   Content-Type: application/x-www-form-urlencoded
    //   body: grant_type=authorization_code
    //       & code={code}
    //       & redirect_uri={redirectUri}
    //       & client_id={clientId}
    //       & code_verifier={startAuthorize中为`caller`持久化的verifier}
    // 解析JSON体,返回XAuth { access_token; refresh_token;
    // expires_at = Time.now() + expires_in*1_000_000_000; scope }。
    let _ = clientId; let _ = code; let _ = redirectUri; let _ = caller;
    Runtime.trap("exchangeCode:请实现OAuth 2.0令牌交换(参见注释块)");
  };

  public func ensureFreshToken(clientId : Text, auth : XAuth) : async* XAuth {
    // 如果`Time.now() + 60s < auth.expires_at`,直接返回auth。
    // 否则POST https://api.x.com/2/oauth2/token,参数为
    //   grant_type=refresh_token & refresh_token={auth.refresh_token} & client_id={clientId}
    // X会*轮换*刷新令牌——响应会携带新的`refresh_token`,取代旧令牌。**务必持久化新的令牌对**(调用的mixin会处理持久化步骤)。
    let _ = clientId;
    Runtime.trap("ensureFreshToken:请实现RFC 6749刷新逻辑(参见注释块)");
  };
};

Variant-specific invariants (admin Client ID)

Admin Client ID变体的特定规则

  • Admin sets the Client ID, never the access token. The Client ID is a public identifier; the per-user
    access_token
    is the secret. Two completely different storage shapes (
    { var value : ?Text }
    vs
    Map<Principal, XAuth>
    ) and two completely different gates (
    #admin
    vs "logged in").
  • No
    getXClientId
    endpoint.
    isXClientIdConfigured : Bool
    is the only outward-facing read of
    xClientId.value
    . The frontend doesn't need to display the Client ID; it just needs to know whether to render the "Connect X" button.
  • xAuthByUser
    is per-caller only.
    Same no-getter / no-log / no-iterate-outside-caller-scope invariants as
    extension-openai
    's per-user variant. Concretely: never generate
    getMyXAuth
    ,
    getX
    ,
    myAccessToken
    , or any shared / query function whose return type is
    ?XAuth
    /
    ?Text
    /
    Text
    . A single
    console.log
    of an X bearer is a per-user account compromise.
  • Trap cleanly when missing prerequisites. Three distinct conditions, three distinct messages:
    "X is not configured"
    (Client ID missing → admin task),
    "Connect your X account first"
    (user not yet authorised → frontend should kick off
    startXOAuth
    ),
    "Sign in to ..."
    (anonymous caller → login required).
  • 管理员设置Client ID,而非访问令牌。Client ID是公开标识符;每用户的
    access_token
    是机密信息。二者的存储结构完全不同(
    { var value : ?Text }
    vs
    Map<Principal, XAuth>
    ),权限控制也完全不同(
    #admin
    vs "已登录")。
  • getXClientId
    端点
    isXClientIdConfigured : Bool
    xClientId.value
    唯一对外的读取接口。前端无需显示Client ID;只需知道是否要渲染"连接X"按钮即可。
  • xAuthByUser
    仅对调用者自身可见
    。与
    extension-openai
    的每用户变体相同,遵循"不提供获取接口/不记录/不超出调用者范围遍历"规则。具体而言:切勿生成
    getMyXAuth
    getX
    myAccessToken
    或任何返回类型为
    ?XAuth
    /
    ?Text
    /
    Text
    的shared/query函数。一次X Bearer令牌的
    console.log
    输出就会导致用户账号泄露。
  • 缺失先决条件时清晰抛出错误。三种不同情况对应三种不同提示:
    "X未配置"
    (缺少Client ID→管理员任务)、
    "请先连接你的X账号"
    (用户尚未授权→前端应启动
    startXOAuth
    )、
    "请登录以..."
    (匿名调用者→需要登录)。

5. Two call shapes — function form vs. suite form

5. 两种调用形式——函数形式vs套件形式

Same as
extension-openai
. Every Apis module ships both:
  • Function form (used in §4):
    TweetsApi.createPosts(config, req) : async* T
    . Note the
    async*
    — call sites use
    await*
    . This is the common case for
    shared
    actor methods.
  • Suite form:
    let api = TweetsApi(config); api.createPosts(req) : async T
    . Note
    async
    , not
    async*
    . Useful when a single
    shared
    method makes several X calls and you want to bind the config once.
The two forms are interchangeable; pick whichever reads cleaner. Don't mix them inside the same
shared
body.
extension-openai
相同。每个Apis模块都提供两种形式:
  • 函数形式(第4节中使用):
    TweetsApi.createPosts(config, req) : async* T
    。注意
    async*
    ——调用站点需使用
    await*
    。这是
    shared
    actor方法的常见情况。
  • 套件形式
    let api = TweetsApi(config); api.createPosts(req) : async T
    。注意是
    async
    而非
    async*
    。适用于单个
    shared
    方法需进行多次X调用,且希望一次性绑定config的场景。
两种形式可互换;选择可读性更好的即可。请勿在同一个
shared
方法体中混合使用。

6. Available API surface

6. 可用API接口

x-client@0.2.3
ships a curated subset of the X API v2. The most relevant module for this skill is
TweetsApi
:
ModulePrimary entry pointWhat it does
TweetsApi
createPosts
Post a tweet (
/2/tweets
) — the 95% case for this skill.
TweetsApi
deleteTweetById
Delete a tweet (
/2/tweets/{id}
).
UsersApi
findMyUser
Get the authenticated user's handle/profile.
For X reads (timeline, search, lookup) the curated surface is much smaller —
x-client
focuses on writes. Pull data from X via
extension-http-outcalls
like any other public REST API.
If a build spec needs an X write not covered by
x-client@0.2.3
(e.g. media upload, replies-to-replies semantics, retweet endpoints), raise an issue on
caffeinelabs/x-client
— do not paper over it with hand-rolled
ic.http_request
.
x-client@0.2.3
提供了X API v2的精选子集。与此技能最相关的模块是
TweetsApi
模块主要入口点功能
TweetsApi
createPosts
发布推文(
/2/tweets
)——此技能95%的使用场景。
TweetsApi
deleteTweetById
删除推文(
/2/tweets/{id}
)。
UsersApi
findMyUser
获取已认证用户的用户名/个人资料。
对于X的读取操作(时间线、搜索、查询),精选接口要少得多——
x-client
专注于写入操作。请通过
extension-http-outcalls
从X拉取数据,就像处理其他公共REST API一样。
如果构建规格需要
x-client@0.2.3
未覆盖的X写入操作(例如媒体上传、回复嵌套语义、转推端点),请在
caffeinelabs/x-client
上提交issue——请勿通过手动编写
ic.http_request
来解决。

7. Cycles and response sizes

7. Cycles和响应大小

defaultConfig.cycles = 30_000_000_000
— about 0.04 USD at 4 USD/T cycles. Sufficient for a typical
createPosts
call. Bump for:
  • Long-form tweets (premium subscribers, up to 25 000 chars): set
    cycles = 60_000_000_000
    .
  • The OAuth token-exchange call (
    /2/oauth2/token
    ) is small; the default cycle budget is generous.
defaultConfig.cycles = 30_000_000_000
——按4美元/T cycles计算,约合0.04美元。足以支持一次典型的
createPosts
调用。以下情况需增加cycles:
  • 长推文(高级订阅用户,最多25000字符):设置
    cycles = 60_000_000_000
  • OAuth令牌交换调用(
    /2/oauth2/token
    )的数据量很小;默认cycles预算已经足够。

8. Things that will bite you

8. 常见陷阱

  • is_replicated = ?false
    — see §3. Not optional.
  • x-client < 0.2.3
    — older versions emit
    "field": null
    for every absent optional, and
    /2/tweets
    rejects them with up to 16 validation errors per request. 0.2.3 ships the
    init
    constructors that default optionals to
    null
    in Motoko and elide them on the wire (via
    serde-core@^0.1.2
    's
    skip_null_fields
    ).
  • Don't expose the access token.
    xAuthByUser
    is read only by
    Map.get(xAuthByUser, ..., caller)
    inside
    tweet
    /
    ensureFreshToken
    . No
    getMyXAuth
    , no
    getMyAccessToken
    , no iterator. A leaked bearer is a per-user account compromise.
  • Persist the rotated refresh token. X returns a new
    refresh_token
    with every refresh (
    grant_type=refresh_token
    ); if you keep using the old one, the next refresh will 400. The mixin in §4 handles this — the
    if (fresh.access_token != auth.access_token)
    branch persists the new pair.
  • Token expiry is ~2 hours. If you omit
    offline.access
    from the authorise scopes, you will not get a
    refresh_token
    and the user must re-authorise every time.
  • Callback URI mismatch. Every character (trailing slash, query string, port) must match the URI registered on the Developer Portal. X returns a generic
    redirect_uri_mismatch
    error otherwise.
  • Don't roll your own JSON.
    x-client
    already handles the request/response JSON via
    JSON.toCandid
    /
    JSON.fromCandid
    and
    serde-core
    's null-elision.
  • No
    getApiKey
    -style endpoint, ever.
    Same rule as
    extension-openai
    's per-user variant: every shared / query function that returns
    ?XAuth
    ,
    ?Text
    (the access token), or any prefix of the bearer is a leak.
  • Rate limits.
    /2/tweets
    is capped per-user-per-app. Replicated outcalls would multiply RPM by the subnet size — yet another reason for
    is_replicated = ?false
    . Back off on HTTP 429.
  • Frontend never holds tokens. The React app calls the backend
    tweet(body)
    and the backend mediates everything. The OAuth flow itself uses redirect-and-back through
    x.com
    — the frontend starts the flow via
    startXOAuth(redirectUri)
    and finishes via
    completeXOAuth(code, redirectUri)
    ; the tokens never reach the browser.
  • is_replicated = ?false
    ——详见第3节。此选项不可省略。
  • x-client < 0.2.3
    ——早期版本会为每个缺失的可选字段生成
    "field": null
    ,而
    /2/tweets
    会因此拒绝请求,每次请求最多返回16个验证错误。0.2.3版本引入了
    init
    构造函数,会在Motoko中将可选字段默认设为
    null
    ,并在传输时省略这些字段(通过
    serde-core@^0.1.2
    skip_null_fields
    功能)。
  • 请勿暴露访问令牌
    xAuthByUser
    仅在
    tweet
    /
    ensureFreshToken
    内部通过
    Map.get(xAuthByUser, ..., caller)
    读取。不要提供
    getMyXAuth
    getMyAccessToken
    或迭代器接口。泄露Bearer令牌会导致用户账号泄露。
  • 持久化轮换后的刷新令牌。每次刷新(
    grant_type=refresh_token
    )时,X都会返回新的
    refresh_token
    ;如果继续使用旧令牌,下一次刷新会返回400错误。第4节中的mixin已处理此问题——
    if (fresh.access_token != auth.access_token)
    分支会持久化新的令牌对。
  • 令牌有效期约为2小时。如果在授权范围中省略
    offline.access
    ,将无法获取
    refresh_token
    ,用户必须每次重新授权。
  • 回调URI不匹配。每个字符(末尾斜杠、查询字符串、端口)必须与开发者门户中注册的URI完全一致。否则X会返回通用的
    redirect_uri_mismatch
    错误。
  • 请勿自行编写JSON逻辑
    x-client
    已通过
    JSON.toCandid
    /
    JSON.fromCandid
    serde-core
    的空值省略功能处理了请求/响应JSON。
  • 永远不要提供类似
    getApiKey
    的端点
    。与
    extension-openai
    的每用户变体规则相同:任何返回
    ?XAuth
    ?Text
    (访问令牌)或Bearer令牌前缀的shared/query函数都会导致泄露。
  • 速率限制
    /2/tweets
    受每用户每应用的速率限制。复制式出站调用会将RPM乘以子网大小——这是
    is_replicated = ?false
    的又一个原因。遇到HTTP 429错误时请退避重试。
  • 前端永远不持有令牌。React应用调用后端的
    tweet(body)
    ,所有操作均由后端中介。OAuth流程本身通过重定向往返x.com完成——前端通过
    startXOAuth(redirectUri)
    启动流程,通过
    completeXOAuth(code, redirectUri)
    完成流程;令牌永远不会到达浏览器。

9. Variant: per-user Client ID

9. 变体:每用户Client ID

Use this variant when each end-user must bring their own X Developer App (multi-tenant rate-limit isolation, per-user Developer Portal control). Mechanically the Client ID storage flips from a single
{ var value : ?Text }
(admin-set) to a
Map<Principal, Text>
(per-user); the OAuth + posting mixin from §4 reuses unchanged modulo the Client ID lookup.
The actor keeps the same shape — drop the admin-Client-ID mixin, add a per-user-Client-ID one:
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXClientIdPerUser "mixins/x-clientid-per-user";
import MixinXPostingPerUserClientId "mixins/x-posting-per-user-clientid";
import LibX "lib/x";

actor {
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // Per-user X Developer App Client IDs.
  let xClientIdByUser : Map.Map<Principal, Text> = Map.empty();
  include MixinXClientIdPerUser(xClientIdByUser);

  // Per-user OAuth tokens — same shape as §4.
  let xAuthByUser : Map.Map<Principal, LibX.XAuth> = Map.empty();
  include MixinXPostingPerUserClientId(xClientIdByUser, xAuthByUser);
};
The two mixin files are mechanical adaptations of §4's:
  • mixins/x-clientid-per-user.mo
    swaps the admin gate for a signed-in-caller gate:
    setMyXClientId(id) : async ()
    writes the caller's slot of
    xClientIdByUser
    ;
    isMyXClientIdConfigured
    reads the same slot.
  • mixins/x-posting-per-user-clientid.mo
    looks up the Client ID by
    caller
    instead of reading the single
    { var value : ?Text }
    — every other line is identical to
    mixins/x-posting.mo
    from §4.
Same no-getter rule: there is no
getMyXClientId
endpoint, even though the Client ID is technically public — keeping the boundary consistent with the access-token rule trains the agent not to grep the codebase for "key" / "id" and add a getter.
当每个终端用户必须使用自己的X开发者应用时(多租户速率限制隔离、每用户开发者门户控制),请使用此变体。从机制上讲,Client ID存储从单个
{ var value : ?Text }
(管理员设置)变为
Map<Principal, Text>
(每用户设置);第4节中的OAuth+发布mixin只需修改Client ID查找逻辑即可复用。
Actor保持相同结构——移除admin-Client-ID mixin,添加每用户-Client-ID mixin:
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinXClientIdPerUser "mixins/x-clientid-per-user";
import MixinXPostingPerUserClientId "mixins/x-posting-per-user-clientid";
import LibX "lib/x";

actor {
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState);

  // 每用户X开发者应用Client ID。
  let xClientIdByUser : Map.Map<Principal, Text> = Map.empty();
  include MixinXClientIdPerUser(xClientIdByUser);

  // 每用户OAuth令牌——与第4节结构相同。
  let xAuthByUser : Map.Map<Principal, LibX.XAuth> = Map.empty();
  include MixinXPostingPerUserClientId(xClientIdByUser, xAuthByUser);
};
两个mixin文件是第4节中文件的机械改编:
  • mixins/x-clientid-per-user.mo
    将管理员权限控制替换为已登录调用者权限控制:
    setMyXClientId(id) : async ()
    写入
    xClientIdByUser
    中调用者对应的条目;
    isMyXClientIdConfigured
    读取同一条目。
  • mixins/x-posting-per-user-clientid.mo
    通过
    caller
    查找Client ID,而非读取单个
    { var value : ?Text }
    ——其余代码与第4节的
    mixins/x-posting.mo
    完全相同。
同样遵循"不提供获取接口"规则:即使Client ID在技术上是公开的,也不要提供
getMyXClientId
端点——保持与访问令牌规则一致的边界,可训练Agent不要在代码库中搜索"key"/"id"并添加获取接口。

10. Variant: fallback (admin default + per-user override)

10. 变体:Fallback(管理员默认+每用户覆盖)

Use this when the operator wants to provide a no-config path for casual users while letting power users self-register. The admin sets a canister-wide default Client ID; individual users may override it with their own.
Lookup order at OAuth start time:
motoko
func clientIdFor(caller : Principal) : ?Text = switch (Map.get(xClientIdByUser, Principal.compare, caller)) {
  case (?id) ?id;
  case null adminClientId.value; // may itself be null → caller must provide one
};
Ship both mixins from §4 and §10 in the same actor: admin sets the default via
setXClientId
, users override via
setMyXClientId
.
startXOAuth
calls
clientIdFor(caller)
instead of reading the single slot. Everything else (
xAuthByUser
, the OAuth handshake, the posting endpoint) is unchanged.
当运营商希望为普通用户提供无需配置的路径,同时让高级用户自行注册时,请使用此变体。管理员设置容器级别的默认Client ID;个别用户可自行覆盖。
OAuth启动时的查找顺序:
motoko
func clientIdFor(caller : Principal) : ?Text = switch (Map.get(xClientIdByUser, Principal.compare, caller)) {
  case (?id) ?id;
  case null adminClientId.value; // 本身可能为null→调用者必须提供自己的Client ID
};
在同一个actor中同时包含第4节和第10节的mixin:管理员通过
setXClientId
设置默认值,用户通过
setMyXClientId
覆盖。
startXOAuth
调用
clientIdFor(caller)
而非读取单个条目。其余部分(
xAuthByUser
、OAuth握手、发布端点)均保持不变。

Frontend

前端

Surfaces every build that uses this skill must ship:
  1. A login flow — required for every variant. X cannot work without a non-anonymous caller; the per-user OAuth handshake stores tokens keyed by
    caller : Principal
    , and the admin / per-user Client ID setters all gate on a logged-in caller. The login flow itself comes from
    extension-authorization
    :
    useInternetIdentity
    , the login/logout buttons, the
    useActor
    plumbing that injects the authenticated identity into every backend call. Plan a sign-in screen as part of the same task graph if the build doesn't already have one.
  2. A Client ID configuration surface. Variant-specific:
    • Admin variant (§4 default): an admin-gated
      /settings/x
      page with a single password-input bound to
      setXClientId(id)
      .
    • Per-user variant (§9): a personal
      /settings/x
      page reachable to any signed-in user, bound to
      setMyXClientId(id)
      .
    • Fallback variant (§10): both pages — admin-gated for the default and per-user for the override.
  3. A "Connect X" page — always. A per-user, not admin-gated page that runs the OAuth 2.0 PKCE handshake: kicks off via
    startXOAuth(redirectUri)
    , redirects the browser to X for consent, lands back on the same page with
    ?code=...
    , calls
    completeXOAuth(code, redirectUri)
    to exchange the code for tokens. End-state is "X connected as @handle" or "Connect X" depending on
    isMyXConnected()
    .
Pick the UI shape that matches the backend variant. Default to Variant A (admin Client ID + per-user OAuth) unless the spec explicitly chooses per-user (§9) or fallback (§10).
使用此技能的所有构建必须提供:
  1. 登录流程——所有变体均需。X无法在匿名调用者下工作;每用户OAuth握手会将令牌以
    caller : Principal
    为键存储,且admin/每用户Client ID设置器均受已登录调用者限制。登录流程本身来自
    extension-authorization
    useInternetIdentity
    、登录/登出按钮、
    useActor
    管道(将已认证身份注入每个后端调用)。如果构建尚未包含登录流程,请在同一任务图中规划登录界面。
  2. Client ID配置界面。因变体而异:
    • Admin变体(第4节默认):一个受管理员权限控制的
      /settings/x
      页面,包含一个绑定到
      setXClientId(id)
      的密码输入框。按回车提交;成功后清空输入框。
    • Per-user变体(第9节):一个可供任何已登录用户访问的个人
      /settings/x
      页面,绑定到
      setMyXClientId(id)
    • Fallback变体(第10节):同时包含上述两个页面——管理员权限控制的默认值页面和每用户覆盖页面。
  3. "连接X"页面——始终需要。一个可供每用户访问(非管理员权限控制)的页面,用于运行OAuth 2.0 PKCE握手:通过
    startXOAuth(window.location.origin + '/connect/x')
    启动,将浏览器重定向到X进行授权,返回同一页面时携带
    ?code=...
    ,调用
    completeXOAuth(code, redirectUri)
    将代码交换为令牌。最终状态根据
    isMyXConnected()
    显示"已连接为@用户名"或"连接X"。
选择与后端变体匹配的UI结构。默认选择变体A(admin Client ID + 每用户OAuth),除非规格明确选择per-user(第9节)或fallback(第10节)。

Variant A: admin Client ID + per-user OAuth (matches §4 — default)

变体A:admin Client ID + 每用户OAuth(与第4节匹配——默认)

Two pages:
  1. Admin settings page
    /settings/x
    (admin-gated):
    • Password-input bound to
      setXClientId(id)
      . Submit on enter; clear the input on success.
    • Status indicator driven by
      isXClientIdConfigured()
      (returns
      Bool
      ). Show "Configured" / "Not configured" — never display the Client ID itself, never expose a getter that returns it.
    • Hide from non-admins via
      extension-authorization
      's
      isCallerAdmin
      query — non-admins should not see the link in the nav, let alone the page. Bind admin-only routes through your router's guard pattern.
  2. Connect X page
    /connect/x
    (any signed-in user):
    • "Connect X" button bound to `startXOAuth(window.location.origin
      • '/connect/x')`. The button redirects the browser to the URL returned by the canister.
    • On the return leg, parse
      ?code=...&state=...
      from the URL, call
      completeXOAuth(code, redirectUri)
      (same
      redirectUri
      that was passed to
      startXOAuth
      ), then redirect to wherever the user came from (or home).
    • Status driven by
      isMyXConnected()
      (returns
      Bool
      ). Show "Connected as @…" (the handle is not fetched from the bearer — fetch it separately via a
      getMyXHandle
      endpoint that calls
      UsersApi.findMyUser
      , never decode the bearer in JS).
    • Optional "Disconnect X" button bound to
      disconnectMyX()
      .
  3. Empty-state nudge on the post-tweet UI — when
    isMyXConnected()
    is
    false
    , render an inline "Connect X to post" link to
    /connect/x
    . Without this nudge users hit "Connect your X account first" with no obvious next step.
Suggested route layout:
/                   →  Main UI (any signed-in user; empty-state when no X connection)
/settings/x         →  Admin Client ID config (admin-only)
/connect/x          →  Per-user OAuth handshake (any signed-in user)
包含两个页面:
  1. 管理员设置页面——
    /settings/x
    (管理员权限控制):
    • 绑定到
      setXClientId(id)
      的密码输入框。按回车提交;成功后清空输入框。
    • isXClientIdConfigured()
      驱动的状态指示器(返回
      Bool
      )。显示"已配置"/"未配置"——永远不要显示Client ID本身,永远不要提供返回它的获取接口。
    • 通过
      extension-authorization
      isCallerAdmin
      查询对非管理员隐藏——非管理员不应在导航中看到链接,更不应访问该页面。通过路由器的守卫模式绑定仅管理员可访问的路由。
  2. 连接X页面——
    /connect/x
    (任何已登录用户):
    • "连接X"按钮绑定到
      startXOAuth(window.location.origin + '/connect/x')
      。按钮会将浏览器重定向到容器返回的URL。
    • 返回时,从URL解析
      ?code=...&state=...
      ,调用
      completeXOAuth(code, redirectUri)
      (与传递给
      startXOAuth
      redirectUri
      相同),然后重定向到用户来源页面(或首页)。
    • isMyXConnected()
      驱动的状态(返回
      Bool
      )。显示"已连接为@…"(用户名从Bearer令牌获取——通过调用
      UsersApi.findMyUser
      getMyXHandle
      端点单独获取,切勿在JS中解码Bearer令牌)。
    • 可选的"断开X连接"按钮绑定到
      disconnectMyX()
  3. 发布推文UI的空状态提示——当
    isMyXConnected()
    false
    时,渲染一个内嵌的"连接X以发布"链接到
    /connect/x
    。如果没有此提示,用户会遇到"请先连接你的X账号"错误,且不知道下一步操作。
建议路由布局:
/                   →  主UI(任何已登录用户;未连接X时显示空状态)
/settings/x         →  Admin Client ID配置(仅管理员)
/connect/x          →  每用户OAuth握手(任何已登录用户)

Variant B: per-user Client ID (matches §9)

变体B:每用户Client ID(与第9节匹配)

Two pages, both reachable to any signed-in user:
  1. My X settings page
    /settings/x
    :
    • Password-input bound to
      setMyXClientId(id)
      . Same no-display invariant.
    • Status driven by
      isMyXClientIdConfigured()
      .
    • No router guard beyond "logged in".
  2. Connect X page — same as Variant A's
    /connect/x
    , except
    startXOAuth
    uses the user's own Client ID under the hood. The user must configure their Client ID before connecting.
Suggested route layout:
/                   →  Main UI
/settings/x         →  Personal Client ID (any signed-in user)
/connect/x          →  Per-user OAuth handshake
包含两个页面,均可供任何已登录用户访问:
  1. 我的X设置页面——
    /settings/x
    • 绑定到
      setMyXClientId(id)
      的密码输入框。同样遵循"不显示"规则。
    • isMyXClientIdConfigured()
      驱动的状态。
    • 除"已登录"外无其他路由守卫。
  2. 连接X页面——与变体A的
    /connect/x
    相同,只是
    startXOAuth
    在内部使用用户自己的Client ID。用户必须先配置自己的Client ID,才能连接X。
建议路由布局:
/                   →  主UI
/settings/x         →  个人Client ID配置(任何已登录用户)
/connect/x          →  每用户OAuth握手

Variant C: fallback (matches §10)

变体C:Fallback(与第10节匹配)

Three pages:
  • /admin/settings/x
    (admin-gated) —
    setXClientId
    for the canister-wide default.
  • /settings/x
    (any signed-in user) —
    setMyXClientId
    for the per-user override.
  • /connect/x
    (any signed-in user) — same OAuth handshake as Variants A/B, with the lookup order described in §10.
The "Connect X" button stays disabled until some Client ID is resolvable for the caller (admin default OR per-user override).
包含三个页面:
  • /admin/settings/x
    (管理员权限控制)——
    setXClientId
    用于设置容器级默认值。
  • /settings/x
    (任何已登录用户)——
    setMyXClientId
    用于每用户覆盖。
  • /connect/x
    (任何已登录用户)——与变体A/B相同的OAuth握手,遵循第10节中描述的查找顺序。
"连接X"按钮会保持禁用状态,直到调用者可解析到某个Client ID(管理员默认值或每用户覆盖值)。

Common to all variants

所有变体的通用规则

  • Sign-in is required for every X-related route. Wire the
    /settings/...
    and
    /connect/x
    routes through
    extension-authorization
    's auth guard (
    useInternetIdentity
    + a redirect when
    !isAuthenticated
    ); anonymous callers must hit a "please sign in" wall before any backend call fires, otherwise every endpoint traps with "Sign in to ...".
  • The frontend never persists tokens. No
    localStorage
    , no
    IndexedDB
    , no cookies — the canister mediates everything. The browser only ever sees
    Bool
    status flags (
    isMyXConnected
    ,
    isXClientIdConfigured
    ) and the OAuth redirect URLs.
  • The OAuth
    state
    parameter is the canister's responsibility.
    Generate it server-side in
    startXOAuth
    , persist it alongside the
    code_verifier
    , verify it in
    completeXOAuth
    before exchanging the code. Do not let the frontend mint or echo
    state
    — that defeats CSRF protection.
  • The post-tweet UI itself is trivial: a textarea, a submit button, a list of recent tweets bound to whatever
    tweet
    / history endpoints the canister exposes. No client-side X SDK, no token handling, no JSON serialisation logic — the canister is the X client.
  • 所有X相关路由均需登录。将
    /settings/...
    /connect/x
    路由通过
    extension-authorization
    的认证守卫(
    useInternetIdentity
    +
    !isAuthenticated
    时重定向)进行处理;匿名调用者在触发任何后端调用前必须进入"请登录"页面,否则每个端点都会抛出"请登录以..."错误。
  • 前端永远不持久化令牌。不使用
    localStorage
    IndexedDB
    或cookie——所有操作均由容器中介。浏览器仅能看到
    Bool
    状态标志(
    isMyXConnected
    isXClientIdConfigured
    )和OAuth重定向URL。
  • OAuth
    state
    参数由容器负责
    。在
    startXOAuth
    中在服务端生成,与
    code_verifier
    一起持久化,在
    completeXOAuth
    中验证后再交换代码。请勿让前端生成或回显
    state
    ——这会破坏CSRF防护。
  • 发布推文UI本身非常简单:一个文本区域、一个提交按钮、一个绑定到容器暴露的
    tweet
    /历史端点的最近推文列表。无需客户端X SDK、令牌处理或JSON序列化逻辑——容器就是X客户端。

Related

相关资源

  • mops add x-client@0.2.3
    — connector source.
  • caffeinelabs/x-client
    — generated bindings repo. Its
    skills/oauth-setup.md
    carries the authoritative step-by-step Developer Portal walkthrough; its
    skills/tweeting-fine-points.md
    documents operational gotchas (minimum version, scopes, replication, null-field serialisation, sub-object rules).
  • X Developer Portal — where the Client ID is created.
  • OAuth 2.0 Authorization Code with PKCE (X docs) — canonical authorise/token endpoint details.
  • /2/tweets
    API reference
    — what
    createPosts
    actually hits.
  • RFC 7636 — Proof Key for Code Exchange — PKCE spec.
  • extension-authorizationrequired prerequisite for every variant of this skill. Provides the Internet Identity login flow, the
    useInternetIdentity
    /
    useActor
    frontend plumbing, and the
    #admin
    role gate for variants §4 and §11.
  • extension-http-outcalls — sibling skill for general HTTP outcalls, including X reads (timeline, search, lookup) which this skill does NOT cover.
  • mops add x-client@0.2.3
    ——连接器源码。
  • caffeinelabs/x-client
    ——生成的绑定仓库。其
    skills/oauth-setup.md
    包含权威的开发者门户分步指南;其
    skills/tweeting-fine-points.md
    记录了运维陷阱(最低版本、权限范围、复制、空字段序列化、子对象规则)。
  • X开发者门户——创建Client ID的平台。
  • OAuth 2.0授权码+PKCE(X文档)——规范的授权/令牌端点详情。
  • /2/tweets
    API参考
    ——
    createPosts
    实际调用的接口。
  • RFC 7636——Proof Key for Code Exchange——PKCE规格。
  • extension-authorization——此技能所有变体的必填先决条件。提供Internet Identity登录流程、
    useInternetIdentity
    /
    useActor
    前端管道,以及第4节和第11节变体所需的
    #admin
    角色权限控制。
  • extension-http-outcalls——通用HTTP出站调用的兄弟技能,包括X的读取操作(时间线、搜索、查询),此技能不覆盖这些操作。