关于 extension-posting-to-x
面向 Caffeine AI 的技能,用于在 Motoko canister 中添加 X/Twitter 发帖功能。它说明如何使用 x-client、按用户进行 OAuth 2.0 PKCE 授权及安全处理令牌;不涵盖读取 X 内容。
主要功能
- 01通过 x-client 发布推文
- 02按用户进行 OAuth 2.0 PKCE
- 03支持管理员或用户提供 Client ID
- 04刷新过期的访问令牌
- 05要求使用非复制 HTTP 出站调用
适用场景
- 01让已登录用户从应用发布 X 内容
- 02构建由 canister 支持的实时发帖功能
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-posting-to-x安装后在 Claude Code 中输入/extension-posting-to-x确认已加载。
详细介绍
发布到 X
适用于 Caffeine AI 的 X / Twitter 发帖扩展。
编排器路由说明
用于与 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 往返转换处理这两种情况)。任何提及发推的构建规范都 MUST 将 x-client 列为依赖项,并引用此技能——请明确传达这一点,确保后端 Agent 不会悄悄退回到手写 HTTP。
意图 → 功能映射(适用于 platform-constraints.md 风格的表格):
| 用户意图 | 平台功能 |
|---|---|
| 发布到 X / Twitter(发推、实时发推、分享状态) | x-client 连接器(通过 extension-posting-to-x 技能使用 TweetsApi) |
| 代表 canister 的已登录用户发布到 X | x-client 连接器(通过 extension-posting-to-x 使用每位用户的 OAuth) |
| 从 X 读取内容(时间线、搜索、用户查询) | 此技能未涵盖——读取 X 内容请使用 extension-http-outcalls。 |
读取与写入。 此技能仅涵盖 X 的写入操作(发推、转发、引用推文、状态更新、实时发推)。从 X 读取内容(时间线、搜索、用户查询)属于与其他公共 REST 接口相同的范畴,仍应使用 extension-http-outcalls。
后端
每当用户希望其 canister 向 X(Twitter)账号发布内容时,都使用此技能。所需内容如下:
x-clientmops 包(X API v2 的 Motoko 绑定,由规范生成;规范子集包含TweetsApi.createPosts等)。- OAuth 2.0 Authorization Code with PKCE 流程,让每位终端用户授权 canister 代表其发帖。每位用户都拥有以
caller : Principal为键的access_token+refresh_token。不存在整个 canister 共用的 bearer。 - X Developer App Client ID(公开标识符,不是密钥)。有三种等效方案——本规范选择其中一种:
- 管理员 Client ID(默认,§4)——canister 所有者注册一个 Developer App,并在管理端填入其 Client ID;每位终端用户都通过同一个应用授权。这是大多数构建的合适默认方案:运维更简单,只需维护一个 Developer Portal 条目,所有 canister 用户共用速率限制。
- 每用户一个 Client ID(§10)——每位用户提供其自己的 Developer App 的 Client ID。适用于多租户 canister,且租户不应共用速率限制配额的情况,或用户希望完全掌控自己的应用注册时。
- 回退方案(§11)——两者兼容。管理员设置默认 Client ID;个别用户可以覆盖。适用于运营者希望为普通用户提供免配置路径,同时允许高级用户自行注册的情况。
- 将
is_replicated = ?false固定设置的Config值——这是不可妥协的要求,参见 §3。
所有方案的前置条件:extension-authorization。
X 的每个有效端点都要求调用者已登录:每用户 OAuth 握手会将 access_token 以 caller : Principal 为键存储;(在管理员和回退方案中)Client ID 设置器受 #admin 角色限制。extension-authorization 提供前端的 Internet Identity 登录流程(useInternetIdentity hook、登录/退出按钮、感知身份验证状态的路由、useActor 接入)以及后端的调用者/角色基础设施。若没有它,已部署的 canister 会拒绝所有发帖请求,因为 caller.isAnonymous() 始终为 true。不存在匿名方案:bearer token 归已登录用户所有,仅此而已。
1. 将 x-client 添加到 mops.toml
使用 mops 工具,而非手动编辑文件:
mops add [email protected]
这会一步更新 mops.toml(在 [dependencies] 中添加 x-client = "0.4.1"),并重写 mops.lock。
最低版本:x-client ≥ 0.4.0,此版本中 Config.cycles 为可选项(§7)。低于 0.3.0 的版本会为每个可选项都输出 "field": null,而 /2/tweets 会因此拒绝请求,每个请求最多返回 16 个验证错误;0.3.0 则提供 init 构造函数,在 Motoko 中将可选项默认设为 null,并在传输时省略这些字段。
2. 身份验证模型——每用户 OAuth 2.0 PKCE
与 OpenAI 的静态 API 密钥不同,X 使用每用户 bearer token。
每位终端用户都通过 OAuth 2.0 Authorization Code with PKCE 独立授权 canister。canister 会将生成的 access_token + refresh_token 以调用者为键存储;令牌约 2 小时后过期,canister 会通过 refresh_token 静默刷新(每次刷新都会轮换该令牌——始终保存新的令牌)。
选择一种 Client ID 方案
| 方案 | 谁注册 Developer App | 谁配置 Client ID | 设置权限门槛 | 适用场景 |
|---|---|---|---|---|
| 管理员(§4,默认) | Canister 所有者。 | 管理员一次性配置,对整个 canister 生效。 | extension-authorization #admin 角色。 | 默认方案。演示、个人机器人、小型社区;运营者为应用名额付费。 |
| 每用户(§10) | 每位终端用户。 | 每位已登录用户。 | “已登录”(非匿名调用者)。 | 多租户;各租户不能共享速率限制配额。 |
| 回退(§11) | 运营者(默认)+ 用户。 | 管理员设置默认值;用户可覆盖。 | 默认值需 #admin 权限;每用户覆盖需“已登录”权限。 | 运营者希望普通用户无需配置即可使用,同时为高级用户保留自由度。 |
这三种方案都遵循 §3(is_replicated = ?false)、§6(令牌
刷新生命周期)、§7(权限范围),并满足令牌不可通过 getter 获取、
不可记录日志的不变量。
OAuth 权限范围
OAuth 2.0 将授权权限范围(用户在授权时被请求同意的权限) 与操作权限范围(访问令牌实际用于执行操作的权限)区分开来。对于 X,在授权步骤请求以下四项即可——同一份列表,满足两种需求:
| 权限范围 | 用于授权 | 用于发布 | 备注 |
|---|---|---|---|
tweet.read | ✓ | — | 读取用户的用户名和个人资料,以显示“已连接为 @…” |
users.read | ✓ | — | 获取已通过身份验证的用户。通常与 tweet.read 配合使用。 |
tweet.write | — | ✓ 必需 | /2/tweets 会拒绝未包含此权限范围的令牌。 |
offline.access | ✓ | — | 签发 refresh_token,以便 canister 在访问令牌过期时静默续期(访问令牌有效期约为 2 小时)。省略此项后,用户每两小时就需要重新授权。 |
如果授权时缺少其中任何一项,流程仍会完成,但签发的
access_token 会在没有提示的情况下缺少相应能力——只有在尝试调用受影响的端点时才会显示错误。
存储令牌
持有者令牌永远不会离开 canister。前端只能知道调用者是否已连接(一个 Bool),永远无法获知令牌本身。规则与 OpenAI 的逐用户持有者令牌相同:
- 使用以调用者为键的
Map<Principal, XAuth>。仅公开 §4 中列出的端点:isMyXConnected、startXOAuth、completeXOAuth、tweet,以及可选的disconnectMyX——每个端点都必须通过not caller.isAnonymous()进行门控。不要添加任何返回access_token/refresh_token/ 完整XAuth记录的端点。 - 在
tweet/ensureFreshToken中进行内部读取(Map.get(xAuthByUser, ..., caller))没有问题;绝不要在调用者自身作用域之外遍历该 Map。 - 升级时,默认会保留该 Map——只有在你也想强制每位用户重新授权时,才将其丢弃。
3. 必须设置 is_replicated = ?false
优先级顺序与 extension-openai 的 §3 相同:
- 安全性。 复制式 HTTP 外调用会让子网中的每个节点通过独立的 TLS 连接发送请求。每个连接都包含
Authorization: Bearer <access_token>。任何一个连接泄露持有者令牌,都会危及该用户的 X 账号。 - 计费。 复制式外调用会产生 N 个并行 API 调用。X 会将每个调用都计入每用户、每应用的速率限制(而 IC 会收取约 13 倍的 cycles)。整个子网的一次
tweet调用很快就会触发 X 的速率限制。 - 确定性。 X 的响应包含可变的速率限制标头(
x-rate-limit-remaining、x-rate-limit-reset、……)。复制式共识会比较响应正文并因此失败;非复制式外调用则完全绕过此共识。
→ 始终在 Config 中设置:is_replicated = ?false。
4. 规范布局
这是默认结构:管理员 Client ID + 逐用户 OAuth。canister 所有者注册一个 X Developer App,并将其 Client ID 粘贴到 canister 级配置中;每位最终用户都使用该 Client ID 执行 OAuth 2.0 PKCE 握手,并获得自己的 access_token + refresh_token。
示例包含五个文件:
src/backend/main.mo— actor:仅包含状态 +include。src/backend/migrations/00000000_000000.mo— 迁移链的头部。src/backend/mixins/x-config.mo— 管理员 Client ID(isXClientIdConfigured、setXClientId)。src/backend/mixins/x-posting.mo— 逐用户 OAuth + 发帖(isMyXConnected、startXOAuth、completeXOAuth、tweet)。src/backend/lib/x.mo—x-client衔接代码(Config构建器 +createPosts往返调用 + 令牌刷新桩)。
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.AccessControlState;
include MixinAuthorization(accessControlState, null);
// 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 };
include MixinXConfig(accessControlState, xClientId);
// Per-user OAuth tokens. Never iterated except by the calling principal.
let xAuthByUser : Map.Map<Principal, LibX.XAuth>;
include MixinXPosting(xClientId, xAuthByUser);
};
迁移链的头部:
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type XAuth = {
access_token : Text;
refresh_token : Text;
expires_at : Nat64;
scope : [Text];
};
type NewActor = {
accessControlState : AccessControl.AccessControlState;
xClientId : { var value : ?Text };
xAuthByUser : Map.Map<Principal, XAuth>;
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
xClientId = { var value : ?Text = null };
xAuthByUser = Map.empty<Principal, XAuth>();
};
};
};
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;
};
};
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);
};
};
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 {}` takes the required-field slice (empty for
// this model) and defaults every optional to `null` (≥ 0.3.0 only);
// rebind `text_` for the value you want to post.
let req = { TweetCreateRequest.init {} with text_ = ?body };
let resp = await* TweetsApi.createPosts(config, req);
// `data` is optional on the response model; X only omits it on an error
// path that `createPosts` would already have raised, so treat it as a bug.
let ?data = resp.data else Runtime.trap("createPosts returned no data");
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 `skills/connector-x/SKILL.md` (§ OAuth 2.0 setup) 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)");
};
};
特定变体的不变量(管理员 Client ID)
- 管理员设置 Client ID,而不是访问令牌。 Client ID 是公开标识符;逐用户的
access_token才是机密。两者的存储结构截然不同({ var value : ?Text }与Map<Principal, XAuth>),门控方式也完全不同(#admin与“已登录”)。 - 不提供
getXClientId端点。isXClientIdConfigured : Bool是对外读取xClientId.value的唯一方式。前端无需显示 Client ID;只需要知道是否要呈现“连接 X”按钮。 xAuthByUser只能按调用者访问。 与extension-openai的逐用户变体一样,必须遵守无 getter、无日志、不得在调用者作用域之外遍历的不变量。具体来说:绝不要生成getMyXAuth、getX、myAccessToken,或任何返回类型为?XAuth/?Text/Text的共享函数或查询函数。仅一条console.log打印出 X 持有者令牌,就足以危及该用户的账号。- 缺少前置条件时应妥善触发 trap。 三种不同情况,使用三条不同消息:
"X is not configured"(缺少 Client ID → 需要管理员处理)、"Connect your X account first"(用户尚未授权 → 前端应启动startXOAuth)、"Sign in to ..."(匿名调用者 → 需要登录)。
5. 两种调用形式 — 函数形式与套件形式
与 extension-openai 相同。每个 Apis 模块都提供这两种形式:
- 函数形式(§4 中使用):
TweetsApi.createPosts(config, req) : async* T。注意async*— 调用处使用await*。这是sharedactor 方法的常见情况。 - 套件形式:
let api = TweetsApi(config); api.createPosts(req) : async T。注意是async,而不是async*。适用于单个shared方法需要多次调用 X,且你想只绑定一次配置的情况。
两种形式可以互换;选择读起来更清晰的一种即可。不要在同一个 shared 主体中混用。
6. 可用的 API 范围
[email protected] 提供了 X API v2 的精选子集。与此技能最相关的模块是 TweetsApi:
| 模块 | 主要入口点 | 功能 |
|---|---|---|
TweetsApi | createPosts | 发布推文(/2/tweets)— 此技能 95% 的使用场景。 |
TweetsApi | deleteTweetById | 删除推文(/2/tweets/{id})。 |
UsersApi | findMyUser | 获取已认证用户的账号名/个人资料。 |
对于 X 的读取操作(时间线、搜索、查询),精选的 API 范围要小得多 — x-client 专注于写入。与其他公共 REST API 一样,通过 extension-http-outcalls 从 X 获取数据。
如果构建规范需要 [email protected] 未涵盖的 X 写入操作(例如媒体上传、回复中的回复语义、转发端点),请在 caffeinelabs/skills-internal 上提出问题 — 不要用手动编写的 ic.http_request 来应付。
7. Cycles 和响应大小
将 cycles 留空(null):每次 x-client 调用都会根据实际发送的请求大小附加 IC 对该请求收取的费用,因此长篇推文(高级订阅用户可发布,最多 25 000 个字符)无需手动预算;在外部调用免费的子网上,费用为零。?n 会精确附加 n 个 cycles,并且如果 canister 持有的 cycles 少于此数量,就会在该子网上失败。
8. 可能遇到的问题
is_replicated = ?false— 参见 §3。不可省略。x-client < 0.3.0— 旧版本会为每个不存在的可选字段输出"field": null,而/2/tweets会拒绝这些字段,每个请求最多产生 16 个验证错误。0.3.0 提供了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 中的混入代码会处理这一点——if (fresh.access_token != auth.access_token)分支会持久化新的令牌对。 - 令牌有效期约为 2 小时。如果授权范围中省略
offline.access,就不会获得refresh_token,用户每次都必须重新授权。 - 回调 URI 不匹配。 每个字符(末尾斜杠、查询字符串、端口)都必须与 Developer Portal 中注册的 URI 完全一致。否则,X 会返回通用的
redirect_uri_mismatch错误。 - 不要自行处理 JSON。
x-client已通过JSON.toCandid/JSON.fromCandid和serde-core的 null 省略机制处理请求和响应 JSON。 - 绝不要提供类似
getApiKey的端点。 这与extension-openai的按用户变体遵循同一规则:任何返回?XAuth、?Text(访问令牌)或 Bearer 令牌任意前缀的共享函数 / 查询函数,都会导致泄露。 - 速率限制。
/2/tweets对每个用户和应用都有调用上限。复制式外呼会按子网大小倍增每分钟请求数(RPM),这又是一个需要设置is_replicated = ?false的理由。遇到 HTTP 429 时应退避。 - 前端绝不持有令牌。 React 应用调用后端的
tweet(body),所有操作都由后端处理。OAuth 流程本身通过x.com进行重定向并返回:前端通过startXOAuth(redirectUri)发起流程,再通过completeXOAuth(code, redirectUri)完成;令牌绝不会到达浏览器。
9. 变体:按用户设置 Client ID
当每个终端用户都必须使用自己的 X Developer App 时,请使用此变体(用于隔离多租户速率限制、按用户控制 Developer Portal)。具体来说,Client ID 存储会从单个 { var value : ?Text }(由管理员设置)改为 Map<Principal, Text>(按用户存储);§4 中的 OAuth + 发帖混入代码可原样复用,只需调整 Client ID 的查找方式。
Actor 保持相同结构——移除管理员 Client ID 混入代码,添加按用户设置 Client ID 的混入代码:
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.AccessControlState;
include MixinAuthorization(accessControlState, null);
// Per-user X Developer App Client IDs.
let xClientIdByUser : Map.Map<Principal, Text>;
include MixinXClientIdPerUser(xClientIdByUser);
// Per-user OAuth tokens — same shape as §4.
let xAuthByUser : Map.Map<Principal, LibX.XAuth>;
include MixinXPostingPerUserClientId(xClientIdByUser, xAuthByUser);
};
此变体的迁移链头会替换 §4 中的内容(在实际应用中,路径仍为 src/backend/migrations/00000000_000000.mo——此变体中 xClientIdByUser 会取代 xClientId):
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type XAuth = {
access_token : Text;
refresh_token : Text;
expires_at : Nat64;
scope : [Text];
};
type NewActor = {
accessControlState : AccessControl.AccessControlState;
xClientIdByUser : Map.Map<Principal, Text>;
xAuthByUser : Map.Map<Principal, XAuth>;
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
xClientIdByUser = Map.empty<Principal, Text>();
xAuthByUser = Map.empty<Principal, XAuth>();
};
};
};
这两个混入文件是对 §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相同。
同样遵循禁止 getter 的规则:即使 Client ID 在技术上是公开的,也不提供 getMyXClientId 端点。保持与访问令牌规则一致的边界,有助于训练 Agent 不要在代码库中搜索“key” / “id”并添加 getter。
10. 变体:fallback(管理员默认值 + 用户个人覆盖)
当运营方希望为普通用户提供免配置的使用路径,同时允许高级用户自行注册时,请使用此方案。管理员设置一个 Canister 级别的默认 Client ID;个别用户可以使用自己的 Client ID 覆盖默认值。
OAuth 启动时的查找顺序:
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
};
在同一个 actor 中部署 §4 和 §10 的两个 mixin:管理员通过 setXClientId 设置默认值,用户通过 setMyXClientId 设置个人覆盖值。
startXOAuth 调用 clientIdFor(caller),而不是读取单个槽位。其他所有内容(xAuthByUser、OAuth 握手、发布端点)均保持不变。
前端
使用此技能的每个构建都必须提供以下内容:
-
登录流程——所有变体都必需。 X 无法在调用方为匿名身份时工作;每用户 OAuth 握手会将令牌按
caller : Principal键控保存,管理员和用户个人 Client ID 设置器也都会要求调用方已登录。登录流程本身来自extension-authorization:useInternetIdentity、登录/退出按钮,以及将已认证身份注入每次后端调用的useActor接线。如果构建尚未包含登录界面,请将其作为同一任务图的一部分规划。 -
Client ID 配置界面。 根据变体而定:
- 管理员变体(§4 默认):受管理员权限控制的
/settings/x页面,其中包含一个绑定到setXClientId(id)的密码输入框。 - 用户个人变体(§9):所有已登录用户均可访问的个人
/settings/x页面,绑定到setMyXClientId(id)。 - Fallback 变体(§10):两个页面都需要——一个受管理员权限控制的页面,用于设置默认值;另一个供用户设置个人覆盖值。
- 管理员变体(§4 默认):受管理员权限控制的
-
“连接 X”页面——始终需要。 一个不受管理员权限控制、供用户个人使用的页面,用于执行 OAuth 2.0 PKCE 握手:通过
startXOAuth(redirectUri)启动,重定向浏览器至 X 进行授权,再返回同一页面并附带?code=...,然后调用completeXOAuth(code, redirectUri)将授权码兑换为令牌。根据isMyXConnected(),最终状态显示为“X 已连接,账号为 @handle”或“连接 X”。
选择与后端变体相匹配的 UI 形式。默认选择变体 A(管理员 Client ID + 用户个人 OAuth),除非规范明确选择用户个人变体(§9)或 fallback 变体(§10)。
变体 A:管理员 Client ID + 每用户 OAuth(对应 §4 — 默认)
两个页面:
-
管理员设置页面 —
/settings/x(仅管理员可访问):- 密码输入框绑定到
setXClientId(id)。按 Enter 提交; 成功后清空输入框。 - 状态指示器由
isXClientIdConfigured()(返回Bool)驱动。显示“已配置”/“未配置”——绝不显示 Client ID 本身,也绝不公开返回该值的 getter。 - 使用
extension-authorization的isCallerAdmin查询,对非管理员隐藏此页面——非管理员不应在 导航中看到链接,更不应访问页面。通过路由器的守卫模式绑定 仅管理员可访问的路由。
- 密码输入框绑定到
-
连接 X 页面 —
/connect/x(任何已登录用户均可访问):- “连接 X”按钮绑定到 `startXOAuth(window.location.origin
- '/connect/x')`。按钮会将浏览器重定向到 canister 返回的 URL。
- 返回时,从 URL 中解析
?code=...&state=..., 调用completeXOAuth(code, redirectUri)(使用传给startXOAuth的同一个redirectUri),然后重定向到用户 之前所在的位置(或主页)。 - 状态由
isMyXConnected()(返回Bool)驱动。显示 “已连接为 @…”(句柄不会从 bearer 中获取——应通过会调用UsersApi.findMyUser的getMyXHandle端点单独获取,绝不要在 JS 中 解码 bearer)。 - 可选的“断开 X 连接”按钮绑定到
disconnectMyX()。
- “连接 X”按钮绑定到 `startXOAuth(window.location.origin
-
发推文界面的空状态提示 — 当
isMyXConnected()为false时,显示行内“连接 X 以 发布”链接,指向/connect/x。没有此提示,用户会看到“请先 连接你的 X 账号”,却不知道下一步该做什么。
建议的路由布局:
/ → 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)
变体 B:每用户 Client ID(对应 §9)
两个页面,任何已登录用户均可访问:
-
我的 X 设置页面 —
/settings/x:- 密码输入框绑定到
setMyXClientId(id)。同样遵循不显示 Client ID 的约束。 - 状态由
isMyXClientIdConfigured()驱动。 - 除“已登录”外,无需路由守卫。
- 密码输入框绑定到
-
连接 X 页面 — 与变体 A 的
/connect/x相同,但startXOAuth会在底层使用用户自己的 Client ID。 用户必须先配置自己的 Client ID,然后才能连接。
建议的路由布局:
/ → Main UI
/settings/x → Personal Client ID (any signed-in user)
/connect/x → Per-user OAuth handshake
变体 C:回退方案(对应 §10)
三个页面:
/admin/settings/x(仅管理员可访问)——用于设置 canister 范围的默认值setXClientId。/settings/x(任何已登录用户均可访问)——用于设置 每用户覆盖值setMyXClientId。/connect/x(任何已登录用户均可访问)——与 变体 A/B 使用相同的 OAuth 握手,并按 §10 所述的顺序查找。
只有在调用者存在可用的 Client ID(管理员默认值或每用户覆盖值) 时,“连接 X”按钮才会启用。
所有变体通用
- 所有与 X 相关的路由都必须登录。 通过
extension-authorization的身份验证守卫(useInternetIdentity+ 在!isAuthenticated时重定向)处理/settings/...和/connect/x路由;匿名调用者必须先看到“请登录”的提示,再触发任何后端调用,否则每个端点都会因“Sign in to ...”而报错。 - 前端绝不持久化令牌。 不使用
localStorage、IndexedDB或 Cookie——一切都由 canister 处理。浏览器只会看到Bool状态标记(isMyXConnected、isXClientIdConfigured)和 OAuth 重定向 URL。 - OAuth
state参数由 canister 负责。 在服务端的startXOAuth中生成该参数,并与code_verifier一同持久化;在交换授权码之前,于completeXOAuth中验证该参数。不要让前端创建或回传state——这会破坏 CSRF 防护。 - 发布推文的 UI 本身很简单: 一个文本区域、一个提交按钮,以及一个近期推文列表,绑定到 canister 提供的
tweet/ 历史记录端点即可。不需要客户端 X SDK、令牌处理或 JSON 序列化逻辑——canister 就是 X 客户端。
相关内容
mops add [email protected]— 连接器源代码。skills/connector-x/SKILL.md— 权威的连接器技能文档:分步讲解 Developer Portal(开发者门户)的使用流程(§ OAuth 2.0 设置),以及操作注意事项(非复制、可选字段、子对象规则、令牌刷新、速率限制)。- X Developer Portal — 在此创建 Client ID。
- OAuth 2.0 Authorization Code with PKCE(X 文档) — 授权和令牌端点的权威说明。
/2/tweetsAPI 参考 —createPosts实际调用的接口。- RFC 7636 — Proof Key for Code Exchange — PKCE 规范。
- extension-authorization — 此技能所有变体的必备前置条件。提供 Internet Identity 登录流程、
useInternetIdentity/useActor前端接线,以及变体 §4 和 §11 的#admin角色门控。 - extension-http-outcalls — 用于通用 HTTP 外部调用的配套技能,包括 X 的读取操作(时间线、搜索、查询),这些不在本技能涵盖范围内。