mcpField
E
extension-posting-to-x

extension-posting-to-x

github.com/caffeinelabs营销与增长228.5k 次安装6 天前更新
代码仓库

关于 extension-posting-to-x

面向 Caffeine AI 的技能,用于在 Motoko canister 中添加 X/Twitter 发帖功能。它说明如何使用 x-client、按用户进行 OAuth 2.0 PKCE 授权及安全处理令牌;不涵盖读取 X 内容。

主要功能

  1. 01通过 x-client 发布推文
  2. 02按用户进行 OAuth 2.0 PKCE
  3. 03支持管理员或用户提供 Client ID
  4. 04刷新过期的访问令牌
  5. 05要求使用非复制 HTTP 出站调用

适用场景

  1. 01让已登录用户从应用发布 X 内容
  2. 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 的已登录用户发布到 Xx-client 连接器(通过 extension-posting-to-x 使用每位用户的 OAuth)
从 X 读取内容(时间线、搜索、用户查询)此技能未涵盖——读取 X 内容请使用 extension-http-outcalls。

读取与写入。 此技能仅涵盖 X 的写入操作(发推、转发、引用推文、状态更新、实时发推)。从 X 读取内容(时间线、搜索、用户查询)属于与其他公共 REST 接口相同的范畴,仍应使用 extension-http-outcalls。

后端

每当用户希望其 canister 向 X(Twitter)账号发布内容时,都使用此技能。所需内容如下:

  1. x-client mops 包(X API v2 的 Motoko 绑定,由规范生成;规范子集包含 TweetsApi.createPosts 等)。
  2. OAuth 2.0 Authorization Code with PKCE 流程,让每位终端用户授权 canister 代表其发帖。每位用户都拥有以 caller : Principal 为键的 access_token + refresh_token。不存在整个 canister 共用的 bearer。
  3. 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;个别用户可以覆盖。适用于运营者希望为普通用户提供免配置路径,同时允许高级用户自行注册的情况。
  4. 将 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 相同:

  1. 安全性。 复制式 HTTP 外调用会让子网中的每个节点通过独立的 TLS 连接发送请求。每个连接都包含 Authorization: Bearer <access_token>。任何一个连接泄露持有者令牌,都会危及该用户的 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. 规范布局

这是默认结构:管理员 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*。这是 shared actor 方法的常见情况。
  • 套件形式:let api = TweetsApi(config); api.createPosts(req) : async T。注意是 async,而不是 async*。适用于单个 shared 方法需要多次调用 X,且你想只绑定一次配置的情况。

两种形式可以互换;选择读起来更清晰的一种即可。不要在同一个 shared 主体中混用。

6. 可用的 API 范围

[email protected] 提供了 X API v2 的精选子集。与此技能最相关的模块是 TweetsApi:

模块主要入口点功能
TweetsApicreatePosts发布推文(/2/tweets)— 此技能 95% 的使用场景。
TweetsApideleteTweetById删除推文(/2/tweets/{id})。
UsersApifindMyUser获取已认证用户的账号名/个人资料。

对于 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 握手、发布端点)均保持不变。

前端

使用此技能的每个构建都必须提供以下内容:

  1. 登录流程——所有变体都必需。 X 无法在调用方为匿名身份时工作;每用户 OAuth 握手会将令牌按 caller : Principal 键控保存,管理员和用户个人 Client ID 设置器也都会要求调用方已登录。登录流程本身来自 extension-authorization:useInternetIdentity、登录/退出按钮,以及将已认证身份注入每次后端调用的 useActor 接线。如果构建尚未包含登录界面,请将其作为同一任务图的一部分规划。

  2. Client ID 配置界面。 根据变体而定:

    • 管理员变体(§4 默认):受管理员权限控制的 /settings/x 页面,其中包含一个绑定到 setXClientId(id) 的密码输入框。
    • 用户个人变体(§9):所有已登录用户均可访问的个人 /settings/x 页面,绑定到 setMyXClientId(id)。
    • Fallback 变体(§10):两个页面都需要——一个受管理员权限控制的页面,用于设置默认值;另一个供用户设置个人覆盖值。
  3. “连接 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 — 默认)

两个页面:

  1. 管理员设置页面 — /settings/x(仅管理员可访问):

    • 密码输入框绑定到 setXClientId(id)。按 Enter 提交; 成功后清空输入框。
    • 状态指示器由 isXClientIdConfigured()(返回 Bool)驱动。显示“已配置”/“未配置”——绝不显示 Client ID 本身,也绝不公开返回该值的 getter。
    • 使用 extension-authorization 的 isCallerAdmin 查询,对非管理员隐藏此页面——非管理员不应在 导航中看到链接,更不应访问页面。通过路由器的守卫模式绑定 仅管理员可访问的路由。
  2. 连接 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()。
  3. 发推文界面的空状态提示 — 当 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)

两个页面,任何已登录用户均可访问:

  1. 我的 X 设置页面 — /settings/x:

    • 密码输入框绑定到 setMyXClientId(id)。同样遵循不显示 Client ID 的约束。
    • 状态由 isMyXClientIdConfigured() 驱动。
    • 除“已登录”外,无需路由守卫。
  2. 连接 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 客户端。

相关内容

评论

支持 Markdown