extension-posting-to-x
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePosting to X
向X发布内容
X / Twitter posting extension for Caffeine AI.
为Caffeine AI提供的X/Twitter发布扩展。
Orchestrator routing notes
编排器路由说明
For masterchat / integration. Treat X / Twitter
posting as a first-class, supported platform feature — do not decline,
deflect, or call for tweeting / posting requests. The
mops connector is the only supported path; raw
to 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
's null-field handling for X API responses (X returns
for absent fields and rejects on writes; the package
handles both via / round-tripping).
Any build spec that mentions tweeting MUST name as a
dependency and reference this skill — propagate that explicitly so
the backend agent cannot silently fall back to hand-rolled HTTP.
platform-constraints.mdquery_ragx-clientic.http_requestapi.x.comx-clientnull"field": nullJSON.toCandidJSON.fromCandidx-clientIntent → capability mapping (for -style tables):
platform-constraints.md| User intent | Platform capability |
|---|---|
| Post to X / Twitter (tweet, live-tweet, share status) | |
| Post to X on behalf of the canister's signed-in user | |
| Read from X (timeline, search, user lookup) | NOT covered by this skill — use |
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 / 集成。将X/Twitter发布视为一等受支持的平台功能——对于推文/发布请求,请勿拒绝、转移或调用。 mops连接器是唯一受支持的方式;直接使用调用是被禁止的反模式,原因在于:(a) 会在复制出站调用中泄露OAuth Bearer令牌(存在安全风险且会产生13倍计费——与直接调用OpenAI的失败模式相同);(b) 会绕过针对X API响应的空字段处理逻辑(X会对缺失字段返回,但在写入时会拒绝格式;该包通过/的往返转换处理这两种情况)。任何提及推文的构建规格必须将列为依赖项并引用此技能——需明确传递这一要求,避免后端Agent静默回退到手动编写的HTTP调用。
platform-constraints.mdquery_ragx-clientic.http_requestapi.x.comx-clientnull"field": nullJSON.toCandidJSON.fromCandidx-client意图→能力映射(适用于风格的表格):
platform-constraints.md| 用户意图 | 平台能力 |
|---|---|
| 向X/Twitter发布内容(发推文、实时推文、分享状态) | |
| 代表容器的已登录用户向X发布内容 | |
| 从X读取内容(时间线、搜索、用户查询) | 不在此技能覆盖范围内——请使用 |
读取vs写入。此技能仅覆盖X的写入操作(发推文、转推、引用推文、状态更新、实时推文)。从X读取内容(时间线、搜索、用户查询)属于公共REST接口,需使用处理。
extension-http-outcallsBackend
后端
Use this skill whenever the user wants their canister to publish content
to an X (Twitter) account. The ingredients are:
- The mops package (generated Motoko bindings for the X API v2; the spec subset includes
x-clientand friends).TweetsApi.createPosts - 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_tokenkeyed byrefresh_token. There is no canister-wide bearer.caller : Principal - 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.
- A value that pins
Config— non-negotiable, see §3.is_replicated = ?false
Prerequisite for all variants: extension-authorization.
X requires a signed-in caller for every meaningful endpoint: the
per-user OAuth handshake stores keyed by , and (in the admin and fallback variants) the Client ID
setter is gated on the role.
ships the Internet Identity login flow on the frontend (the
hook, login/logout buttons, auth-state-aware
routing, plumbing) and the backend caller / role
infrastructure. Without it the deployed canister rejects every post
because is always true. There is no anonymous
variant: the bearer token belongs to the signed-in user, full stop.
access_tokencaller : Principal#adminextension-authorizationuseInternetIdentityuseActorcaller.isAnonymous()当用户希望其容器向X(Twitter)账号发布内容时,请使用此技能。所需组件包括:
- mops包(针对X API v2生成的Motoko绑定;规格子集包含
x-client及相关方法)。TweetsApi.createPosts - OAuth 2.0授权码+PKCE流程,以便每个终端用户授权容器代表其发布内容。每个用户持有自己的+
access_token,并以refresh_token作为键。不存在容器级别的Bearer令牌。caller : Principal - X开发者应用Client ID(公开标识符,非机密信息)。有三种等效变体,规格会选择其中一种:
- Admin Client ID(默认,第4节)——容器所有者注册一个开发者应用,并在管理端粘贴其Client ID;所有终端用户均针对同一应用授权。这是大多数构建的合理默认选择:运维更简单,只需维护一个开发者门户条目,速率限制在容器用户间共享。
- Per-user Client ID(第10节)——每个用户从自己的开发者应用中提供Client ID。适用于容器为多租户场景,且租户不应共享速率限制配额,或用户希望完全控制自己的应用注册的情况。
- Fallback(第11节)——同时支持上述两种方式。管理员设置默认Client ID;个别用户可自行覆盖。适用于运营商希望为普通用户提供无需配置的路径,同时让高级用户自行注册的场景。
- 一个将固定的
is_replicated = ?false值——此要求无协商余地,详见第3节。Config
所有变体的先决条件:extension-authorization。
X要求每个有意义的端点都需要已登录的调用者:每用户OAuth握手会将以为键存储;在admin和fallback变体中,Client ID设置器受角色限制。在前端提供Internet Identity登录流程(钩子、登录/登出按钮、感知认证状态的路由、管道)以及后端调用者/角色基础设施。如果没有它,部署的容器会拒绝所有发布请求,因为始终为true。不存在匿名变体:Bearer令牌属于已登录用户,这是既定规则。
access_tokencaller : Principal#adminextension-authorizationuseInternetIdentityuseActorcaller.isAnonymous()1. Add x-client
to mops.toml
x-clientmops.toml1. 向mops.toml
添加x-client
mops.tomlx-clientUse the mops tool, not manual file edits:
bash
mops add x-client@0.2.3This updates (adds to )
and rewrites in one step.
mops.tomlx-client = "0.2.3"[dependencies]mops.lockMinimum version: . Earlier versions emitted
on every optional and rejects them with up
to 16 validation errors per request; 0.2.3 ships the
constructors that default optionals to in Motoko and elide
them on the wire.
x-client ≥ 0.2.3"field": null/2/tweetsinitnull使用mops工具,而非手动编辑文件:
bash
mops add x-client@0.2.3此命令会一步更新(在中添加)并重写。
mops.toml[dependencies]x-client = "0.2.3"mops.lock最低版本要求:。早期版本会为每个可选字段生成,而会因此拒绝请求,每次请求最多返回16个验证错误;0.2.3版本引入了构造函数,会在Motoko中将可选字段默认设为,并在传输时省略这些字段。
x-client ≥ 0.2.3"field": null/2/tweetsinitnull2. 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
+ keyed by caller; tokens expire in
~2 hours and the canister silently refreshes them via the
(which is rotated on every refresh — always persist
the new one).
access_tokenrefresh_tokenrefresh_token与OpenAI的静态API密钥不同,X使用每用户Bearer令牌。每个终端用户需通过OAuth 2.0授权码+PKCE流程独立授权容器。容器将生成的+以调用者为键存储;令牌约2小时后过期,容器会通过静默刷新令牌(每次刷新都会轮换——务必持久化新的令牌)。
access_tokenrefresh_tokenrefresh_tokenrefresh_tokenPick a Client ID variant
选择Client ID变体
| Variant | Who registers the Developer App | Who configures the Client ID | Setter gate | Use when |
|---|---|---|---|---|
| Admin (§4, default) | The canister owner. | Admin once, canister-wide. | | 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. | | Operator wants a no-config path for casuals + freedom for power users. |
All three variants share §3 (), §6 (token
refresh lifecycle), §7 (scopes) and the no-getter / no-log invariants
on tokens.
is_replicated = ?false| 变体 | 谁注册开发者应用 | 谁配置Client ID | 设置器权限控制 | 适用场景 |
|---|---|---|---|---|
| Admin(第4节,默认) | 容器所有者。 | 管理员一次性设置,容器全局生效。 | | 默认选项。适用于演示、个人机器人、小型社区;运营商负责应用插槽的费用。 |
| Per-user(第10节) | 每个终端用户。 | 每个已登录用户。 | "已登录"(非匿名调用者)。 | 多租户场景;租户不得共享速率限制配额。 |
| Fallback(第11节) | 运营商(默认)+用户。 | 管理员设置默认值;用户可覆盖。 | 默认值设置受 | 运营商希望为普通用户提供无需配置的路径,同时为高级用户提供自由选择的空间。 |
所有三个变体均遵循第3节()、第6节(令牌刷新生命周期)、第7节(权限范围)以及关于令牌的"不提供获取接口/不记录"规则。
is_replicated = ?falseOAuth 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:
| Scope | For authorisation | For posting | Notes |
|---|---|---|---|
| ✓ | — | Read the user's handle/profile to display "connected as @…". |
| ✓ | — | Resolve the authenticated user. Usually paired with |
| — | ✓ required | |
| ✓ | — | Issues a |
If any of these are missing at authorise-time, the flow completes but
the issued silently lacks that capability — the error
only surfaces when you try to call the affected endpoint.
access_tokenOAuth 2.0将授权范围(用户在授权时需同意的权限)与操作范围(访问令牌实际将用于的操作)分开。对于X,在授权步骤需请求以下四个范围——列表相同,但关注点不同:
| 范围 | 用于授权 | 用于发布 | 说明 |
|---|---|---|---|
| ✓ | — | 读取用户的用户名/个人资料,用于显示"已连接为@…"。 |
| ✓ | — | 解析已认证用户。通常与 |
| — | ✓ 必填 | |
| ✓ | — | 颁发 |
如果授权时缺少任何一个范围,流程会完成,但颁发的会静默缺失相应能力——只有当尝试调用受影响的端点时才会出现错误。
access_tokenStoring tokens
令牌存储
The bearer never leaves the canister. The frontend only ever
learns whether the caller has connected (a ), never the tokens
themselves. Same rules as OpenAI's per-user bearer:
Bool- A keyed by caller. Expose exactly the endpoints listed in §4 —
Map<Principal, XAuth>,isMyXConnected,startXOAuth,completeXOAuth, optionaltweet— every endpoint gated ondisconnectMyX. Do not add any endpoint that returnsnot caller.isAnonymous()/access_token/ the fullrefresh_tokenrecord.XAuth - Internal reads () inside
Map.get(xAuthByUser, ..., caller)/tweetare fine; never iterate the map outside the call's own caller scope.ensureFreshToken - On upgrade the map preserves by default — drop it only if you also want to force every user to re-authorise.
Bearer令牌永远不会离开容器。前端仅能获知调用者是否已连接(一个值),永远无法获取令牌本身。与OpenAI的每用户Bearer令牌规则相同:
Bool- 一个以调用者为键的。仅暴露第4节中列出的端点:
Map<Principal, XAuth>、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
is_replicated = ?false3. 必须设置is_replicated = ?false
is_replicated = ?falseSame priority order as 's §3:
extension-openai- Security. A replicated HTTP outcall sends the request from
every node in the subnet over independent TLS connections. Each
connection carries . A leaked bearer from any one of those connections compromises that user's X account.
Authorization: Bearer <access_token> - 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 call quickly trips X's rate limit.
tweet - Determinism. X's response carries variable rate-limit headers
(,
x-rate-limit-remaining, …). Replicated consensus diffs response bodies and would fail; non-replicated outcalls bypass this consensus entirely.x-rate-limit-reset
→ Always: on the .
is_replicated = ?falseConfig优先级与的第3节相同:
extension-openai- 安全性。复制式HTTP出站调用会从子网中的每个节点通过独立TLS连接发送请求。每个连接都会携带。任何一个连接泄露的Bearer令牌都会危及该用户的X账号。
Authorization: Bearer <access_token> - 计费。复制式出站调用会生成N个并行API调用。X会将每个调用计入每用户每应用的速率限制(且IC会收取约13倍的cycles费用)。一次子网级别的调用会迅速触发X的速率限制。
tweet - 确定性。X的响应包含可变的速率限制头(、
x-rate-limit-remaining等)。复制式共识会对比响应体,导致失败;非复制式出站调用会完全绕过此共识。x-rate-limit-reset
→ 务必在中设置:。
Configis_replicated = ?false4. 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_tokenrefresh_tokenThe example spans four files:
- — the actor: state +
src/backend/main.mos only.include - — admin Client ID (
src/backend/mixins/x-config.mo,isXClientIdConfigured).setXClientId - — per-user OAuth + posting (
src/backend/mixins/x-posting.mo,isMyXConnected,startXOAuth,completeXOAuth).tweet - —
src/backend/lib/x.moglue (x-clientbuilder +Configround-trip + token-refresh stubs).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 {
// 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_tokenrefresh_token示例包含四个文件:
- ——actor:状态+仅包含
src/backend/main.mo语句。include - ——admin Client ID(
src/backend/mixins/x-config.mo、isXClientIdConfigured)。setXClientId - ——每用户OAuth+发布功能(
src/backend/mixins/x-posting.mo、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 is the secret. Two completely different storage shapes (
access_tokenvs{ var value : ?Text }) and two completely different gates (Map<Principal, XAuth>vs "logged in").#admin - No endpoint.
getXClientIdis the only outward-facing read ofisXClientIdConfigured : Bool. The frontend doesn't need to display the Client ID; it just needs to know whether to render the "Connect X" button.xClientId.value - is per-caller only. Same no-getter / no-log / no-iterate-outside-caller-scope invariants as
xAuthByUser's per-user variant. Concretely: never generateextension-openai,getMyXAuth,getX, or any shared / query function whose return type ismyAccessToken/?XAuth/?Text. A singleTextof an X bearer is a per-user account compromise.console.log - Trap cleanly when missing prerequisites. Three distinct
conditions, three distinct messages: (Client ID missing → admin task),
"X is not configured"(user not yet authorised → frontend should kick off"Connect your X account first"),startXOAuth(anonymous caller → login required)."Sign in to ..."
- 管理员设置Client ID,而非访问令牌。Client ID是公开标识符;每用户的是机密信息。二者的存储结构完全不同(
access_tokenvs{ var value : ?Text }),权限控制也完全不同(Map<Principal, XAuth>vs "已登录")。#admin - 无端点。
getXClientId是isXClientIdConfigured : Bool唯一对外的读取接口。前端无需显示Client ID;只需知道是否要渲染"连接X"按钮即可。xClientId.value - 仅对调用者自身可见。与
xAuthByUser的每用户变体相同,遵循"不提供获取接口/不记录/不超出调用者范围遍历"规则。具体而言:切勿生成extension-openai、getMyXAuth、getX或任何返回类型为myAccessToken/?XAuth/?Text的shared/query函数。一次X Bearer令牌的Text输出就会导致用户账号泄露。console.log - 缺失先决条件时清晰抛出错误。三种不同情况对应三种不同提示:(缺少Client ID→管理员任务)、
"X未配置"(用户尚未授权→前端应启动"请先连接你的X账号")、startXOAuth(匿名调用者→需要登录)。"请登录以..."
5. Two call shapes — function form vs. suite form
5. 两种调用形式——函数形式vs套件形式
Same as . Every Apis module ships both:
extension-openai- Function form (used in §4): . Note the
TweetsApi.createPosts(config, req) : async* T— call sites useasync*. This is the common case forawait*actor methods.shared - Suite form: . Note
let api = TweetsApi(config); api.createPosts(req) : async T, notasync. Useful when a singleasync*method makes several X calls and you want to bind the config once.shared
The two forms are interchangeable; pick whichever reads cleaner. Don't
mix them inside the same body.
shared与相同。每个Apis模块都提供两种形式:
extension-openai- 函数形式(第4节中使用):。注意
TweetsApi.createPosts(config, req) : async* T——调用站点需使用async*。这是await*actor方法的常见情况。shared - 套件形式:。注意是
let api = TweetsApi(config); api.createPosts(req) : async T而非async。适用于单个async*方法需进行多次X调用,且希望一次性绑定config的场景。shared
两种形式可互换;选择可读性更好的即可。请勿在同一个方法体中混合使用。
shared6. Available API surface
6. 可用API接口
x-client@0.2.3TweetsApi| Module | Primary entry point | What it does |
|---|---|---|
| | Post a tweet ( |
| | Delete a tweet ( |
| | Get the authenticated user's handle/profile. |
For X reads (timeline, search, lookup) the curated surface is much
smaller — focuses on writes. Pull data from X via
like any other public REST API.
x-clientextension-http-outcallsIf a build spec needs an X write not covered by
(e.g. media upload, replies-to-replies semantics, retweet endpoints),
raise an issue on — do not paper over it
with hand-rolled .
x-client@0.2.3caffeinelabs/x-clientic.http_requestx-client@0.2.3TweetsApi| 模块 | 主要入口点 | 功能 |
|---|---|---|
| | 发布推文( |
| | 删除推文( |
| | 获取已认证用户的用户名/个人资料。 |
对于X的读取操作(时间线、搜索、查询),精选接口要少得多——专注于写入操作。请通过从X拉取数据,就像处理其他公共REST API一样。
x-clientextension-http-outcalls如果构建规格需要未覆盖的X写入操作(例如媒体上传、回复嵌套语义、转推端点),请在上提交issue——请勿通过手动编写来解决。
x-client@0.2.3caffeinelabs/x-clientic.http_request7. Cycles and response sizes
7. Cycles和响应大小
defaultConfig.cycles = 30_000_000_000createPosts- Long-form tweets (premium subscribers, up to 25 000 chars): set
.
cycles = 60_000_000_000 - The OAuth token-exchange call () is small; the default cycle budget is generous.
/2/oauth2/token
defaultConfig.cycles = 30_000_000_000createPosts- 长推文(高级订阅用户,最多25000字符):设置。
cycles = 60_000_000_000 - OAuth令牌交换调用()的数据量很小;默认cycles预算已经足够。
/2/oauth2/token
8. Things that will bite you
8. 常见陷阱
- — see §3. Not optional.
is_replicated = ?false - — older versions emit
x-client < 0.2.3for every absent optional, and"field": nullrejects them with up to 16 validation errors per request. 0.2.3 ships the/2/tweetsconstructors that default optionals toinitin Motoko and elide them on the wire (vianull'sserde-core@^0.1.2).skip_null_fields - Don't expose the access token. is read only by
xAuthByUserinsideMap.get(xAuthByUser, ..., caller)/tweet. NoensureFreshToken, nogetMyXAuth, no iterator. A leaked bearer is a per-user account compromise.getMyAccessToken - Persist the rotated refresh token. X returns a new
with every refresh (
refresh_token); if you keep using the old one, the next refresh will 400. The mixin in §4 handles this — thegrant_type=refresh_tokenbranch persists the new pair.if (fresh.access_token != auth.access_token) - Token expiry is ~2 hours. If you omit from the authorise scopes, you will not get a
offline.accessand the user must re-authorise every time.refresh_token - Callback URI mismatch. Every character (trailing slash, query
string, port) must match the URI registered on the Developer Portal.
X returns a generic error otherwise.
redirect_uri_mismatch - Don't roll your own JSON. already handles the request/response JSON via
x-client/JSON.toCandidandJSON.fromCandid's null-elision.serde-core - No -style endpoint, ever. Same rule as
getApiKey's per-user variant: every shared / query function that returnsextension-openai,?XAuth(the access token), or any prefix of the bearer is a leak.?Text - Rate limits. is capped per-user-per-app. Replicated outcalls would multiply RPM by the subnet size — yet another reason for
/2/tweets. Back off on HTTP 429.is_replicated = ?false - Frontend never holds tokens. The React app calls the backend
and the backend mediates everything. The OAuth flow itself uses redirect-and-back through
tweet(body)— the frontend starts the flow viax.comand finishes viastartXOAuth(redirectUri); the tokens never reach the browser.completeXOAuth(code, redirectUri)
- ——详见第3节。此选项不可省略。
is_replicated = ?false - ——早期版本会为每个缺失的可选字段生成
x-client < 0.2.3,而"field": null会因此拒绝请求,每次请求最多返回16个验证错误。0.2.3版本引入了/2/tweets构造函数,会在Motoko中将可选字段默认设为init,并在传输时省略这些字段(通过null的serde-core@^0.1.2功能)。skip_null_fields - 请勿暴露访问令牌。仅在
xAuthByUser/tweet内部通过ensureFreshToken读取。不要提供Map.get(xAuthByUser, ..., caller)、getMyXAuth或迭代器接口。泄露Bearer令牌会导致用户账号泄露。getMyAccessToken - 持久化轮换后的刷新令牌。每次刷新()时,X都会返回新的
grant_type=refresh_token;如果继续使用旧令牌,下一次刷新会返回400错误。第4节中的mixin已处理此问题——refresh_token分支会持久化新的令牌对。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的空值省略功能处理了请求/响应JSON。serde-core - 永远不要提供类似的端点。与
getApiKey的每用户变体规则相同:任何返回extension-openai、?XAuth(访问令牌)或Bearer令牌前缀的shared/query函数都会导致泄露。?Text - 速率限制。受每用户每应用的速率限制。复制式出站调用会将RPM乘以子网大小——这是
/2/tweets的又一个原因。遇到HTTP 429错误时请退避重试。is_replicated = ?false - 前端永远不持有令牌。React应用调用后端的,所有操作均由后端中介。OAuth流程本身通过重定向往返x.com完成——前端通过
tweet(body)启动流程,通过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
(admin-set) to a
(per-user); the OAuth + posting mixin from §4 reuses unchanged
modulo the Client ID lookup.
{ var value : ?Text }Map<Principal, Text>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:
- swaps the admin gate for a signed-in-caller gate:
mixins/x-clientid-per-user.mowrites the caller's slot ofsetMyXClientId(id) : async ();xClientIdByUserreads the same slot.isMyXClientIdConfigured - looks up the Client ID by
mixins/x-posting-per-user-clientid.moinstead of reading the singlecaller— every other line is identical to{ var value : ?Text }from §4.mixins/x-posting.mo
Same no-getter rule: there is no 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.
getMyXClientId当每个终端用户必须使用自己的X开发者应用时(多租户速率限制隔离、每用户开发者门户控制),请使用此变体。从机制上讲,Client ID存储从单个(管理员设置)变为(每用户设置);第4节中的OAuth+发布mixin只需修改Client ID查找逻辑即可复用。
{ var value : ?Text }Map<Principal, Text>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查找Client ID,而非读取单个caller——其余代码与第4节的{ var value : ?Text }完全相同。mixins/x-posting.mo
同样遵循"不提供获取接口"规则:即使Client ID在技术上是公开的,也不要提供端点——保持与访问令牌规则一致的边界,可训练Agent不要在代码库中搜索"key"/"id"并添加获取接口。
getMyXClientId10. 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 , users override via .
calls instead of reading the
single slot. Everything else (, the OAuth handshake, the
posting endpoint) is unchanged.
setXClientIdsetMyXClientIdstartXOAuthclientIdFor(caller)xAuthByUser当运营商希望为普通用户提供无需配置的路径,同时让高级用户自行注册时,请使用此变体。管理员设置容器级别的默认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:管理员通过设置默认值,用户通过覆盖。调用而非读取单个条目。其余部分(、OAuth握手、发布端点)均保持不变。
setXClientIdsetMyXClientIdstartXOAuthclientIdFor(caller)xAuthByUserFrontend
前端
Surfaces every build that uses this skill must ship:
-
A login flow — required for every variant. X cannot work without a non-anonymous caller; the per-user OAuth handshake stores tokens keyed by, and the admin / per-user Client ID setters all gate on a logged-in caller. The login flow itself comes from
caller : Principal:extension-authorization, the login/logout buttons, theuseInternetIdentityplumbing 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.useActor -
A Client ID configuration surface. Variant-specific:
- Admin variant (§4 default): an admin-gated page with a single password-input bound to
/settings/x.setXClientId(id) - Per-user variant (§9): a personal page reachable to any signed-in user, bound to
/settings/x.setMyXClientId(id) - Fallback variant (§10): both pages — admin-gated for the default and per-user for the override.
- Admin variant (§4 default): an admin-gated
-
A "Connect X" page — always. A per-user, not admin-gated page that runs the OAuth 2.0 PKCE handshake: kicks off via, redirects the browser to X for consent, lands back on the same page with
startXOAuth(redirectUri), calls?code=...to exchange the code for tokens. End-state is "X connected as @handle" or "Connect X" depending oncompleteXOAuth(code, redirectUri).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).
使用此技能的所有构建必须提供:
-
登录流程——所有变体均需。X无法在匿名调用者下工作;每用户OAuth握手会将令牌以为键存储,且admin/每用户Client ID设置器均受已登录调用者限制。登录流程本身来自
caller : Principal:extension-authorization、登录/登出按钮、useInternetIdentity管道(将已认证身份注入每个后端调用)。如果构建尚未包含登录流程,请在同一任务图中规划登录界面。useActor -
Client ID配置界面。因变体而异:
- Admin变体(第4节默认):一个受管理员权限控制的页面,包含一个绑定到
/settings/x的密码输入框。按回车提交;成功后清空输入框。setXClientId(id) - Per-user变体(第9节):一个可供任何已登录用户访问的个人页面,绑定到
/settings/x。setMyXClientId(id) - Fallback变体(第10节):同时包含上述两个页面——管理员权限控制的默认值页面和每用户覆盖页面。
- Admin变体(第4节默认):一个受管理员权限控制的
-
"连接X"页面——始终需要。一个可供每用户访问(非管理员权限控制)的页面,用于运行OAuth 2.0 PKCE握手:通过启动,将浏览器重定向到X进行授权,返回同一页面时携带
startXOAuth(window.location.origin + '/connect/x'),调用?code=...将代码交换为令牌。最终状态根据completeXOAuth(code, redirectUri)显示"已连接为@用户名"或"连接X"。isMyXConnected()
选择与后端变体匹配的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:
-
Admin settings page —(admin-gated):
/settings/x- Password-input bound to . Submit on enter; clear the input on success.
setXClientId(id) - Status indicator driven by (returns
isXClientIdConfigured()). Show "Configured" / "Not configured" — never display the Client ID itself, never expose a getter that returns it.Bool - Hide from non-admins via 's
extension-authorizationquery — non-admins should not see the link in the nav, let alone the page. Bind admin-only routes through your router's guard pattern.isCallerAdmin
- Password-input bound to
-
Connect X page —(any signed-in user):
/connect/x- "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 from the URL, call
?code=...&state=...(samecompleteXOAuth(code, redirectUri)that was passed toredirectUri), then redirect to wherever the user came from (or home).startXOAuth - Status driven by (returns
isMyXConnected()). Show "Connected as @…" (the handle is not fetched from the bearer — fetch it separately via aBoolendpoint that callsgetMyXHandle, never decode the bearer in JS).UsersApi.findMyUser - Optional "Disconnect X" button bound to .
disconnectMyX()
- "Connect X" button bound to `startXOAuth(window.location.origin
-
Empty-state nudge on the post-tweet UI — whenis
isMyXConnected(), render an inline "Connect X to post" link tofalse. Without this nudge users hit "Connect your X account first" with no obvious next step./connect/x
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)包含两个页面:
-
管理员设置页面——(管理员权限控制):
/settings/x- 绑定到的密码输入框。按回车提交;成功后清空输入框。
setXClientId(id) - 由驱动的状态指示器(返回
isXClientIdConfigured())。显示"已配置"/"未配置"——永远不要显示Client ID本身,永远不要提供返回它的获取接口。Bool - 通过的
extension-authorization查询对非管理员隐藏——非管理员不应在导航中看到链接,更不应访问该页面。通过路由器的守卫模式绑定仅管理员可访问的路由。isCallerAdmin
- 绑定到
-
连接X页面——(任何已登录用户):
/connect/x- "连接X"按钮绑定到。按钮会将浏览器重定向到容器返回的URL。
startXOAuth(window.location.origin + '/connect/x') - 返回时,从URL解析,调用
?code=...&state=...(与传递给completeXOAuth(code, redirectUri)的startXOAuth相同),然后重定向到用户来源页面(或首页)。redirectUri - 由驱动的状态(返回
isMyXConnected())。显示"已连接为@…"(用户名不从Bearer令牌获取——通过调用Bool的UsersApi.findMyUser端点单独获取,切勿在JS中解码Bearer令牌)。getMyXHandle - 可选的"断开X连接"按钮绑定到。
disconnectMyX()
- "连接X"按钮绑定到
-
发布推文UI的空状态提示——当为
isMyXConnected()时,渲染一个内嵌的"连接X以发布"链接到false。如果没有此提示,用户会遇到"请先连接你的X账号"错误,且不知道下一步操作。/connect/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:
-
My X settings page —:
/settings/x- Password-input bound to . Same no-display invariant.
setMyXClientId(id) - Status driven by .
isMyXClientIdConfigured() - No router guard beyond "logged in".
- Password-input bound to
-
Connect X page — same as Variant A's, except
/connect/xuses the user's own Client ID under the hood. The user must configure their Client ID before connecting.startXOAuth
Suggested route layout:
/ → Main UI
/settings/x → Personal Client ID (any signed-in user)
/connect/x → Per-user OAuth handshake包含两个页面,均可供任何已登录用户访问:
-
我的X设置页面——:
/settings/x- 绑定到的密码输入框。同样遵循"不显示"规则。
setMyXClientId(id) - 由驱动的状态。
isMyXClientIdConfigured() - 除"已登录"外无其他路由守卫。
- 绑定到
-
连接X页面——与变体A的相同,只是
/connect/x在内部使用用户自己的Client ID。用户必须先配置自己的Client ID,才能连接X。startXOAuth
建议路由布局:
/ → 主UI
/settings/x → 个人Client ID配置(任何已登录用户)
/connect/x → 每用户OAuth握手Variant C: fallback (matches §10)
变体C:Fallback(与第10节匹配)
Three pages:
- (admin-gated) —
/admin/settings/xfor the canister-wide default.setXClientId - (any signed-in user) —
/settings/xfor the per-user override.setMyXClientId - (any signed-in user) — same OAuth handshake as Variants A/B, with the lookup order described in §10.
/connect/x
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 - (任何已登录用户)——与变体A/B相同的OAuth握手,遵循第10节中描述的查找顺序。
/connect/x
"连接X"按钮会保持禁用状态,直到调用者可解析到某个Client ID(管理员默认值或每用户覆盖值)。
Common to all variants
所有变体的通用规则
- Sign-in is required for every X-related route. Wire the
and
/settings/...routes through/connect/x's auth guard (extension-authorization+ a redirect whenuseInternetIdentity); anonymous callers must hit a "please sign in" wall before any backend call fires, otherwise every endpoint traps with "Sign in to ...".!isAuthenticated - The frontend never persists tokens. No , no
localStorage, no cookies — the canister mediates everything. The browser only ever seesIndexedDBstatus flags (Bool,isMyXConnected) and the OAuth redirect URLs.isXClientIdConfigured - The OAuth parameter is the canister's responsibility. Generate it server-side in
state, persist it alongside thestartXOAuth, verify it incode_verifierbefore exchanging the code. Do not let the frontend mint or echocompleteXOAuth— that defeats CSRF protection.state - The post-tweet UI itself is trivial: a textarea, a submit
button, a list of recent tweets bound to whatever / history endpoints the canister exposes. No client-side X SDK, no token handling, no JSON serialisation logic — the canister is the X client.
tweet
- 所有X相关路由均需登录。将和
/settings/...路由通过/connect/x的认证守卫(extension-authorization+useInternetIdentity时重定向)进行处理;匿名调用者在触发任何后端调用前必须进入"请登录"页面,否则每个端点都会抛出"请登录以..."错误。!isAuthenticated - 前端永远不持久化令牌。不使用、
localStorage或cookie——所有操作均由容器中介。浏览器仅能看到IndexedDB状态标志(Bool、isMyXConnected)和OAuth重定向URL。isXClientIdConfigured - OAuth 参数由容器负责。在
state中在服务端生成,与startXOAuth一起持久化,在code_verifier中验证后再交换代码。请勿让前端生成或回显completeXOAuth——这会破坏CSRF防护。state - 发布推文UI本身非常简单:一个文本区域、一个提交按钮、一个绑定到容器暴露的/历史端点的最近推文列表。无需客户端X SDK、令牌处理或JSON序列化逻辑——容器就是X客户端。
tweet
Related
相关资源
- — connector source.
mops add x-client@0.2.3 - — generated bindings repo. Its
caffeinelabs/x-clientcarries the authoritative step-by-step Developer Portal walkthrough; itsskills/oauth-setup.mddocuments operational gotchas (minimum version, scopes, replication, null-field serialisation, sub-object rules).skills/tweeting-fine-points.md - X Developer Portal — where the Client ID is created.
- OAuth 2.0 Authorization Code with PKCE (X docs) — canonical authorise/token endpoint details.
- API reference — what
/2/tweetsactually hits.createPosts - RFC 7636 — Proof Key for Code Exchange — PKCE spec.
- extension-authorization — required prerequisite for every variant of this skill. Provides the Internet Identity login flow, the /
useInternetIdentityfrontend plumbing, and theuseActorrole gate for variants §4 and §11.#admin - 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文档)——规范的授权/令牌端点详情。
- API参考——
/2/tweets实际调用的接口。createPosts - RFC 7636——Proof Key for Code Exchange——PKCE规格。
- extension-authorization——此技能所有变体的必填先决条件。提供Internet Identity登录流程、/
useInternetIdentity前端管道,以及第4节和第11节变体所需的useActor角色权限控制。#admin - extension-http-outcalls——通用HTTP出站调用的兄弟技能,包括X的读取操作(时间线、搜索、查询),此技能不覆盖这些操作。