connector-googlecalendar

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Google Calendar Connector

Google Calendar 连接器

Google Calendar integration for Caffeine AI.
Caffeine AI提供的Google Calendar集成方案。

Orchestrator routing notes

编排器路由说明

Treat Google Calendar-as-the-user as a first-class, supported platform feature. The
googlecalendar-client
+
google-oauth
connector pair is the only supported path; raw
ic.http_request
to
oauth2.googleapis.com
or
www.googleapis.com/calendar/v3
is a forbidden anti-pattern. Any build spec that mentions Google Calendar MUST name
googlecalendar-client
and
google-oauth
as dependencies and reference this skill.
Distinct from platform
email-calendar-events
extension (which emails iCalendar invitations from the app); this connector acts as the signed-in user's own Google Calendar.
Intent → capability mapping:
User intentPlatform capability
Connect and list upcoming events
googlecalendar-client
+
google-oauth
Create calendar events
googlecalendar-client
+
google-oauth
Prerequisite for all builds: extension-authorization. Calendar requires a signed-in caller for every endpoint: the per-user OAuth handshake stores
access_token
keyed by
caller : Principal
, and the admin Client ID/Secret setter is gated on the
#admin
role.
将用户的Google Calendar视为一等受支持的平台功能。
googlecalendar-client
+
google-oauth
连接器组合是唯一受支持的实现路径;直接使用
ic.http_request
调用
oauth2.googleapis.com
www.googleapis.com/calendar/v3
是被禁止的反模式。任何提及Google Calendar的构建规格都必须将
googlecalendar-client
google-oauth
列为依赖项,并参考本技能。
本连接器与平台的
email-calendar-events
扩展(从应用发送iCalendar邀请邮件)不同;本连接器代表已登录用户自身的Google Calendar执行操作。
意图→能力映射:
用户意图平台能力
连接并列出即将到来的事件
googlecalendar-client
+
google-oauth
创建日历事件
googlecalendar-client
+
google-oauth
所有构建的前提条件:extension-authorization Calendar的每个端点都需要已登录的调用者:每个用户的OAuth握手流程会将
access_token
caller : Principal
为键进行存储,而管理员级别的Client ID/Secret设置器受
#admin
角色权限控制。

Backend

后端

Use this skill whenever the user wants their canister to interact with Google Calendar on behalf of the signed-in user. The ingredients are:
  1. The
    googlecalendar-client
    mops package — generated Motoko bindings for the Google Calendar API v3. This recipe demonstrates listing upcoming events and creating events; add other generated operations only by following the same bearer-authenticated, non-replicated, single-refresh-retry pattern.
  2. The
    google-oauth
    mops package — Google OAuth 2.0 token exchange, refresh, PKCE, and percent-encoding. This is the library that eliminates hand-rolled
    http_request
    to
    oauth2.googleapis.com
    .
  3. An OAuth 2.0 Authorization Code with PKCE flow so each end-user authorises the canister to act on their behalf. Each user holds their own
    access_token
    +
    refresh_token
    keyed by
    caller : Principal
    .
  4. A Google Cloud Web application Client ID + Client Secret. Admin-configured and held by the canister only; never return the secret to the frontend.
当用户希望其canister代表已登录用户与Google Calendar交互时,使用本技能。所需组件包括:
  1. googlecalendar-client
    mops包——为Google Calendar API v3生成的Motoko绑定。本方案演示了列出即将到来的事件和创建事件的操作;如需添加其他生成的操作,必须遵循相同的Bearer认证、非复制、单次刷新重试模式。
  2. google-oauth
    mops包——提供Google OAuth 2.0令牌交换、刷新、PKCE及百分号编码功能。该库可避免手动编写针对
    oauth2.googleapis.com
    http_request
    调用。
  3. OAuth 2.0授权码+PKCE流程,以便每个终端用户授权canister代表其执行操作。每个用户持有自己的
    access_token
    +
    refresh_token
    ,并以
    caller : Principal
    为键进行存储。
  4. Google Cloud Web应用的Client ID + Client Secret。 由管理员配置并仅由canister持有;绝不能将密钥返回给前端。

1. Add dependencies

1. 添加依赖

bash
mops add googlecalendar-client@0.1.3
mops add google-oauth@0.1.4
mops add caffeineai-authorization@1.0.0
bash
mops add googlecalendar-client@0.1.3
mops add google-oauth@0.1.4
mops add caffeineai-authorization@1.0.0

2. Auth model — OAuth 2.0 PKCE per user, on-chain exchange + refresh

2. 认证模型——每个用户独立使用OAuth 2.0 PKCE,链上交换+刷新令牌

Identical to the Gmail connector. Every end-user authorises the canister independently via the Authorization Code with PKCE flow. The canister:
  1. Generates a PKCE
    code_verifier
    and
    code_challenge
    (via
    google-oauth
    ).
  2. Builds the Google authorize URL (via
    google-oauth.buildAuthorizeUrl
    ).
  3. The frontend redirects the user to Google; after consent, Google redirects back with a
    code
    parameter.
  4. The canister exchanges the code for tokens (via
    google-oauth.exchangeAuthorizationCode
    ) — on-chain, non-replicated.
  5. The canister stores
    access_token
    +
    refresh_token
    keyed by
    caller
    .
  6. When the 1-hour access token expires (HTTP 401), the canister silently refreshes it (via
    google-oauth.refreshAccessToken
    ) and retries.
与Gmail连接器完全相同。每个终端用户通过授权码+PKCE流程独立授权canister。canister的执行流程如下:
  1. 生成PKCE的
    code_verifier
    code_challenge
    (通过
    google-oauth
    )。
  2. 构建Google授权URL(通过
    google-oauth.buildAuthorizeUrl
    )。
  3. 前端将用户重定向到Google;用户同意授权后,Google会将用户重定向回应用,并携带
    code
    参数。
  4. canister将授权码交换为令牌(通过
    google-oauth.exchangeAuthorizationCode
    )——在链上执行,非复制模式
  5. canister将
    access_token
    +
    refresh_token
    caller
    为键进行存储。
  6. 当1小时有效期的access_token过期时(返回HTTP 401),canister会自动刷新令牌(通过
    google-oauth.refreshAccessToken
    )并重试请求。

Google Cloud Console setup

Google Cloud Console设置

  1. Create a Google OAuth 2.0 Web application client.
  2. Under Authorized redirect URIs, register the exact deployed callback:
    https://<app-domain>/connect/calendar
    . The URI passed to Google must match this value byte-for-byte, including its scheme, path, and trailing slash.
  3. Enable only the Calendar scopes the app needs on the consent screen.
  4. Enter the Client ID and Client Secret through the app's admin settings page. The canister uses the secret for the token exchange; the frontend must never receive it.
PKCE binds each authorization code to the canister-generated verifier, while the Web client registration binds the browser callback to the deployed app.
  1. 创建Google OAuth 2.0 Web应用客户端。
  2. 授权重定向URI下,注册部署后的精确回调地址:
    https://<app-domain>/connect/calendar
    。传递给Google的URI必须与该值完全匹配,包括协议、路径和末尾的斜杠。
  3. 在 consent screen上仅启用应用所需的Calendar权限范围。
  4. 通过应用的管理员设置页面输入Client ID和Client Secret。canister使用密钥进行令牌交换;绝不能将密钥发送给前端。
PKCE将每个授权码与canister生成的验证器绑定,而Web客户端注册则将浏览器回调地址与部署的应用绑定。

OAuth scopes

OAuth权限范围

ScopePurpose
https://www.googleapis.com/auth/calendar
Full read/write access to calendars
https://www.googleapis.com/auth/calendar.events
Read/write access to events only
https://www.googleapis.com/auth/calendar.readonly
Read-only access to calendars
https://www.googleapis.com/auth/calendar.events.readonly
Read-only access to events
Request
calendar
(full read/write) for a typical CRUD app; use
.readonly
variants for read-only views.
权限范围用途
https://www.googleapis.com/auth/calendar
对日历的完全读写权限
https://www.googleapis.com/auth/calendar.events
仅对事件的读写权限
https://www.googleapis.com/auth/calendar.readonly
对日历的只读权限
https://www.googleapis.com/auth/calendar.events.readonly
仅对事件的只读权限
对于典型的CRUD应用,请求
calendar
(完全读写)权限;对于只读视图,使用
.readonly
变体。

Storing tokens

令牌存储

The bearer never leaves the canister. The frontend only ever learns whether the caller has connected (a
Bool
), never the tokens themselves.
  • A
    Map<Principal, CalendarConnection>
    keyed by caller. Expose exactly the endpoints listed in §4 —
    isMyCalendarConnected
    ,
    startCalendarOAuth
    ,
    completeCalendarOAuth
    ,
    listUpcomingEvents
    ,
    createEvent
    ,
    disconnectMyCalendar
    — every endpoint gated on
    not caller.isAnonymous()
    . Do not add any endpoint that returns
    access_token
    /
    refresh_token
    / the full
    CalendarConnection
    .
  • Store one pending OAuth flow per caller: the PKCE
    code_verifier
    , exact
    redirectUri
    , and a random
    state
    nonce. Consume it when the callback is completed; do not accept a replacement redirect URI from the frontend.
Bearer令牌绝不会离开canister。前端仅能获知调用者是否已连接(一个
Bool
值),永远无法获取令牌本身。
  • 使用以调用者为键的
    Map<Principal, CalendarConnection>
    。仅暴露第4节中列出的端点——
    isMyCalendarConnected
    startCalendarOAuth
    completeCalendarOAuth
    listUpcomingEvents
    createEvent
    disconnectMyCalendar
    ——每个端点都受
    not caller.isAnonymous()
    限制。 请勿添加任何返回
    access_token
    /
    refresh_token
    /完整
    CalendarConnection
    的端点。
  • 为每个调用者存储一个待处理的OAuth流程:PKCE的
    code_verifier
    、精确的
    redirectUri
    和随机的
    state
    随机数。完成回调时消耗该流程;不接受来自前端的替换重定向URI。

Google refresh tokens do NOT rotate

Google刷新令牌不会轮换

Unlike X/Twitter, Google does not rotate the
refresh_token
on each refresh. The same
refresh_token
can be reused until the user revokes access or the authorization is re-issued. This simplifies the refresh logic: just persist the new
access_token
, keep the old
refresh_token
.
与X/Twitter不同,Google在每次刷新时不会轮换
refresh_token
。同一个
refresh_token
可重复使用,直到用户撤销授权或重新颁发授权。这简化了刷新逻辑:只需保存新的
access_token
,保留旧的
refresh_token
即可。

3.
is_replicated = ?false
is REQUIRED

3. 必须设置
is_replicated = ?false

  1. Security. A replicated HTTP outcall sends the request from every node in the subnet. Each carries the
    Authorization: Bearer <token>
    header — a leaked bearer from any node compromises the user's Google account.
  2. Billing. Replicated outcalls produce N parallel API calls. The IC charges ~13× the cycles, and Google counts each toward quota.
  3. Determinism. Calendar write responses are non-deterministic (unique event
    id
    /
    etag
    , per-request timestamps). Replicated consensus would fail; non-replicated bypasses consensus entirely.
→ Always:
is_replicated = ?false
on every
Config
.
  1. 安全性。复制模式的HTTP出站调用会从子网中的每个节点发送请求。每个请求都携带
    Authorization: Bearer <token>
    头——任何节点泄露的Bearer令牌都会危及用户的Google账户。
  2. 计费。复制模式的出站调用会产生N个并行API调用。IC会收取约13倍的cycles费用,且Google会将每个调用计入配额。
  3. 确定性。日历写入操作的响应是非确定性的(唯一的事件
    id
    /
    etag
    、每个请求的时间戳)。复制模式的共识会失败;非复制模式可完全绕过共识。
→ 始终在每个
Config
中设置:
is_replicated = ?false

4. Canonical layout

4. 标准布局

The default shape: admin Client ID/Secret + per-user OAuth. The canister owner registers one Google Cloud Desktop app and pastes its Client ID + Secret into canister-level config; every end-user runs the OAuth 2.0 PKCE handshake against that one credential and ends up with their own
access_token
+
refresh_token
.
The example spans four files:
  • src/backend/main.mo
    — the actor: state +
    include
    s only.
  • src/backend/mixins/calendar-config.mo
    — admin-gated Client ID + Secret.
  • src/backend/mixins/calendar-messaging.mo
    — per-user OAuth + event ops.
  • src/backend/lib/calendar.mo
    googlecalendar-client
    +
    google-oauth
    glue.
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 MixinCalendarConfig "mixins/calendar-config";
import MixinCalendarMessaging "mixins/calendar-messaging";
import LibCalendar "lib/calendar";

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

  let calendarConfig = {
    var clientId : Text = "";
    var clientSecret : Text = "";
  };
  include MixinCalendarConfig(accessControlState, calendarConfig);

  let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection> = Map.empty();
  let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth> = Map.empty();
  include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

mixin (
  accessControlState : AccessControl.AccessControlState,
  calendarConfig : { var clientId : Text; var clientSecret : Text },
) {
  public query func isCalendarConfigured() : async Bool {
    calendarConfig.clientId.size() > 0;
  };

  public shared ({ caller }) func setCalendarCredentials(clientId : Text, clientSecret : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set Calendar credentials");
    };
    calendarConfig.clientId := clientId;
    calendarConfig.clientSecret := clientSecret;
  };
};
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibCalendar "../lib/calendar";

mixin (
  calendarConfig : { var clientId : Text; var clientSecret : Text },
  calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
  pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>,
) {
  public query ({ caller }) func isMyCalendarConnected() : async Bool {
    Map.containsKey(calendarConnections, Principal.compare, caller);
  };

  public shared ({ caller }) func startCalendarOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured (admin must set credentials)");
    };
    await* LibCalendar.startAuthorize(
      calendarConfig.clientId, redirectUri, caller, pendingCalendarFlows,
    );
  };

  public shared ({ caller }) func completeCalendarOAuth(code : Text, state : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured");
    };
    let ?pending = Map.get(pendingCalendarFlows, Principal.compare, caller) else {
      Runtime.trap("No pending OAuth flow — call startCalendarOAuth first");
    };
    if (state != pending.state) {
      Runtime.trap("OAuth state did not match the pending Calendar flow");
    };
    Map.remove(pendingCalendarFlows, Principal.compare, caller);
    let connection = await* LibCalendar.exchangeCode(
      calendarConfig.clientId, calendarConfig.clientSecret, code,
      pending.redirectUri, pending.codeVerifier,
    );
    Map.add(calendarConnections, Principal.compare, caller, connection);
  };

  public shared ({ caller }) func listUpcomingEvents(
    timeMin : Text, maxResults : Nat,
  ) : async LibCalendar.EventSummaryList {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to list events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.listUpcomingEvents(
      calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
      calendarConnections, timeMin, maxResults,
    );
  };

  public shared ({ caller }) func createEvent(
    summary : Text, startDateTime : Text, endDateTime : Text,
  ) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to create events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.createEvent(
      calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
      calendarConnections, summary, startDateTime, endDateTime,
    );
  };

  public shared ({ caller }) func disconnectMyCalendar() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(calendarConnections, Principal.compare, caller);
  };
};
motoko
import Array "mo:core/Array";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
import OAuth "mo:google-oauth/OAuth";
import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
import { type Event; JSON = Event } "mo:googlecalendar-client/Models/Event";
import { type EventDateTime; JSON = EventDateTime } "mo:googlecalendar-client/Models/EventDateTime";
import { type Events; JSON = Events } "mo:googlecalendar-client/Models/Events";
import { defaultConfig; type Config } "mo:googlecalendar-client/Config";

module {
  public type CalendarConnection = {
    accessToken : Text;
    refreshToken : Text;
  };

  public type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  public type EventSummary = {
    id : Text;
    summary : Text;
    start : Text;
    end : Text;
  };

  public type EventSummaryList = [EventSummary];

  let SCOPES : Text = "https://www.googleapis.com/auth/calendar";

  func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer(token);
      is_replicated = ?false;
      max_response_bytes = ?Nat64.fromNat(2_000_000);
    };
  };

  func refreshIfNeeded(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    errorMsg : Text,
  ) : async* ?Text {
    if (not (errorMsg.contains(#text("401")) or errorMsg.contains(#text("Unauthorized")))) {
      Runtime.trap("Calendar API failed: " # errorMsg);
    };
    let refreshed = await OAuth.refreshAccessToken(clientId, clientSecret, connection.refreshToken);
    let newToken = accessTokenOf(refreshed, "Token refresh");
    Map.add(calendarConnections, Principal.compare, caller, {
      connection with accessToken = newToken;
    });
    ?newToken;
  };

  public func startAuthorize(
    clientId : Text, redirectUri : Text, caller : Principal,
    pendingFlows : Map.Map<Principal, PendingOAuth>,
  ) : async* Text {
    let codeVerifier = await OAuth.generateCodeVerifier();
    let state = await OAuth.generateCodeVerifier();
    Map.add(pendingFlows, Principal.compare, caller, {
      codeVerifier;
      redirectUri;
      state;
    });
    OAuth.buildAuthorizeUrl(clientId, redirectUri, SCOPES, state, OAuth.computeCodeChallenge(codeVerifier));
  };

  public func exchangeCode(
    clientId : Text, clientSecret : Text, code : Text,
    redirectUri : Text, codeVerifier : Text,
  ) : async* CalendarConnection {
    let tokens = await OAuth.exchangeAuthorizationCode(clientId, clientSecret, code, redirectUri, codeVerifier);
    let accessToken = accessTokenOf(tokens, "Token exchange");
    let refreshToken = switch (tokens.refreshToken) {
      case (?t) t;
      case null Runtime.trap("Token exchange failed: missing refresh_token");
    };
    { accessToken; refreshToken };
  };

  func accessTokenOf(tokens : OAuth.TokenResponse, operation : Text) : Text {
    switch (tokens.error) {
      case (?error) {
        let description = switch (tokens.errorDescription) {
          case (?value) ": " # value;
          case null "";
        };
        Runtime.trap(operation # " failed: " # error # description);
      };
      case null {};
    };
    switch (tokens.accessToken) {
      case (?token) token;
      case null Runtime.trap(operation # " failed: missing access_token");
    };
  };

  public func listUpcomingEvents(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    timeMin : Text, maxResults : Nat,
  ) : async* EventSummaryList {
    if (timeMin.size() == 0) {
      Runtime.trap("timeMin must be an RFC 3339 timestamp");
    };
    let events : Events = try {
      await* calendar_events_list(
        configForToken(connection.accessToken), "primary", #json,
        "", "", "", true, "", "",
        false, [], "", 0, maxResults, #starttime,
        "", [], "", [], false, false, true, "",
        "", timeMin, "", "",
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId, clientSecret, connection, caller, calendarConnections, e.message(),
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_list(
        configForToken(newToken), "primary", #json,
        "", "", "", true, "", "",
        false, [], "", 0, maxResults, #starttime,
        "", [], "", [], false, false, true, "",
        "", timeMin, "", "",
      );
    };
    eventSummariesOf(events);
  };

  public func createEvent(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    summary : Text, startDateTime : Text, endDateTime : Text,
  ) : async* Text {
    let start : EventDateTime = { EventDateTime.init {} with dateTime = ?startDateTime };
    let end : EventDateTime = { EventDateTime.init {} with dateTime = ?endDateTime };
    let event : Event = { Event.init {} with
      summary = ?summary;
      start = ?start;
      end = ?end;
    };
    let created : Event = try {
      await* calendar_events_insert(
        configForToken(connection.accessToken), "primary", #json,
        "", "", "", true, "", "",
        0, 0, true, #all, false, event,
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId, clientSecret, connection, caller, calendarConnections, e.message(),
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_insert(
        configForToken(newToken), "primary", #json,
        "", "", "", true, "", "",
        0, 0, true, #all, false, event,
      );
    };
    switch (created.id) {
      case (?id) id;
      case null "";
    };
  };

  func eventSummariesOf(events : Events) : EventSummaryList {
    let items = switch (events.items) {
      case (?items) items;
      case null [];
    };
    Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
      id = switch (e.id) { case (?id) id; case null "" };
      summary = switch (e.summary) { case (?s) s; case null "(no title)" };
      start = switch (e.start) {
        case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
        case null "";
      };
      end = switch (e.end) {
        case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
        case null "";
      };
    });
  };
};
默认架构:管理员级Client ID/Secret + 每个用户独立OAuth。canister所有者注册一个Google Cloud桌面应用,并将其Client ID + Secret粘贴到canister级配置中;每个终端用户针对该凭据运行OAuth 2.0 PKCE握手流程,最终获得自己的
access_token
+
refresh_token
示例包含四个文件:
  • src/backend/main.mo
    ——actor:状态 +
    include
    s语句。
  • src/backend/mixins/calendar-config.mo
    ——受管理员权限控制的Client ID + Secret设置。
  • src/backend/mixins/calendar-messaging.mo
    ——每个用户的OAuth + 事件操作。
  • src/backend/lib/calendar.mo
    ——
    googlecalendar-client
    +
    google-oauth
    的粘合层。
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 MixinCalendarConfig "mixins/calendar-config";
import MixinCalendarMessaging "mixins/calendar-messaging";
import LibCalendar "lib/calendar";

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

  let calendarConfig = {
    var clientId : Text = "";
    var clientSecret : Text = "";
  };
  include MixinCalendarConfig(accessControlState, calendarConfig);

  let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection> = Map.empty();
  let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth> = Map.empty();
  include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};
motoko
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

mixin (
  accessControlState : AccessControl.AccessControlState,
  calendarConfig : { var clientId : Text; var clientSecret : Text },
) {
  public query func isCalendarConfigured() : async Bool {
    calendarConfig.clientId.size() > 0;
  };

  public shared ({ caller }) func setCalendarCredentials(clientId : Text, clientSecret : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set Calendar credentials");
    };
    calendarConfig.clientId := clientId;
    calendarConfig.clientSecret := clientSecret;
  };
};
motoko
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibCalendar "../lib/calendar";

mixin (
  calendarConfig : { var clientId : Text; var clientSecret : Text },
  calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
  pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>,
) {
  public query ({ caller }) func isMyCalendarConnected() : async Bool {
    Map.containsKey(calendarConnections, Principal.compare, caller);
  };

  public shared ({ caller }) func startCalendarOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured (admin must set credentials)");
    };
    await* LibCalendar.startAuthorize(
      calendarConfig.clientId, redirectUri, caller, pendingCalendarFlows,
    );
  };

  public shared ({ caller }) func completeCalendarOAuth(code : Text, state : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured");
    };
    let ?pending = Map.get(pendingCalendarFlows, Principal.compare, caller) else {
      Runtime.trap("No pending OAuth flow — call startCalendarOAuth first");
    };
    if (state != pending.state) {
      Runtime.trap("OAuth state did not match the pending Calendar flow");
    };
    Map.remove(pendingCalendarFlows, Principal.compare, caller);
    let connection = await* LibCalendar.exchangeCode(
      calendarConfig.clientId, calendarConfig.clientSecret, code,
      pending.redirectUri, pending.codeVerifier,
    );
    Map.add(calendarConnections, Principal.compare, caller, connection);
  };

  public shared ({ caller }) func listUpcomingEvents(
    timeMin : Text, maxResults : Nat,
  ) : async LibCalendar.EventSummaryList {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to list events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.listUpcomingEvents(
      calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
      calendarConnections, timeMin, maxResults,
    );
  };

  public shared ({ caller }) func createEvent(
    summary : Text, startDateTime : Text, endDateTime : Text,
  ) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to create events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.createEvent(
      calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
      calendarConnections, summary, startDateTime, endDateTime,
    );
  };

  public shared ({ caller }) func disconnectMyCalendar() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(calendarConnections, Principal.compare, caller);
  };
};
motoko
import Array "mo:core/Array";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
import OAuth "mo:google-oauth/OAuth";
import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
import { type Event; JSON = Event } "mo:googlecalendar-client/Models/Event";
import { type EventDateTime; JSON = EventDateTime } "mo:googlecalendar-client/Models/EventDateTime";
import { type Events; JSON = Events } "mo:googlecalendar-client/Models/Events";
import { defaultConfig; type Config } "mo:googlecalendar-client/Config";

module {
  public type CalendarConnection = {
    accessToken : Text;
    refreshToken : Text;
  };

  public type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  public type EventSummary = {
    id : Text;
    summary : Text;
    start : Text;
    end : Text;
  };

  public type EventSummaryList = [EventSummary];

  let SCOPES : Text = "https://www.googleapis.com/auth/calendar";

  func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer(token);
      is_replicated = ?false;
      max_response_bytes = ?Nat64.fromNat(2_000_000);
    };
  };

  func refreshIfNeeded(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    errorMsg : Text,
  ) : async* ?Text {
    if (not (errorMsg.contains(#text("401")) or errorMsg.contains(#text("Unauthorized")))) {
      Runtime.trap("Calendar API failed: " # errorMsg);
    };
    let refreshed = await OAuth.refreshAccessToken(clientId, clientSecret, connection.refreshToken);
    let newToken = accessTokenOf(refreshed, "Token refresh");
    Map.add(calendarConnections, Principal.compare, caller, {
      connection with accessToken = newToken;
    });
    ?newToken;
  };

  public func startAuthorize(
    clientId : Text, redirectUri : Text, caller : Principal,
    pendingFlows : Map.Map<Principal, PendingOAuth>,
  ) : async* Text {
    let codeVerifier = await OAuth.generateCodeVerifier();
    let state = await OAuth.generateCodeVerifier();
    Map.add(pendingFlows, Principal.compare, caller, {
      codeVerifier;
      redirectUri;
      state;
    });
    OAuth.buildAuthorizeUrl(clientId, redirectUri, SCOPES, state, OAuth.computeCodeChallenge(codeVerifier));
  };

  public func exchangeCode(
    clientId : Text, clientSecret : Text, code : Text,
    redirectUri : Text, codeVerifier : Text,
  ) : async* CalendarConnection {
    let tokens = await OAuth.exchangeAuthorizationCode(clientId, clientSecret, code, redirectUri, codeVerifier);
    let accessToken = accessTokenOf(tokens, "Token exchange");
    let refreshToken = switch (tokens.refreshToken) {
      case (?t) t;
      case null Runtime.trap("Token exchange failed: missing refresh_token");
    };
    { accessToken; refreshToken };
  };

  func accessTokenOf(tokens : OAuth.TokenResponse, operation : Text) : Text {
    switch (tokens.error) {
      case (?error) {
        let description = switch (tokens.errorDescription) {
          case (?value) ": " # value;
          case null "";
        };
        Runtime.trap(operation # " failed: " # error # description);
      };
      case null {};
    };
    switch (tokens.accessToken) {
      case (?token) token;
      case null Runtime.trap(operation # " failed: missing access_token");
    };
  };

  public func listUpcomingEvents(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    timeMin : Text, maxResults : Nat,
  ) : async* EventSummaryList {
    if (timeMin.size() == 0) {
      Runtime.trap("timeMin must be an RFC 3339 timestamp");
    };
    let events : Events = try {
      await* calendar_events_list(
        configForToken(connection.accessToken), "primary", #json,
        "", "", "", true, "", "",
        false, [], "", 0, maxResults, #starttime,
        "", [], "", [], false, false, true, "",
        "", timeMin, "", "",
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId, clientSecret, connection, caller, calendarConnections, e.message(),
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_list(
        configForToken(newToken), "primary", #json,
        "", "", "", true, "", "",
        false, [], "", 0, maxResults, #starttime,
        "", [], "", [], false, false, true, "",
        "", timeMin, "", "",
      );
    };
    eventSummariesOf(events);
  };

  public func createEvent(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    summary : Text, startDateTime : Text, endDateTime : Text,
  ) : async* Text {
    let start : EventDateTime = { EventDateTime.init {} with dateTime = ?startDateTime };
    let end : EventDateTime = { EventDateTime.init {} with dateTime = ?endDateTime };
    let event : Event = { Event.init {} with
      summary = ?summary;
      start = ?start;
      end = ?end;
    };
    let created : Event = try {
      await* calendar_events_insert(
        configForToken(connection.accessToken), "primary", #json,
        "", "", "", true, "", "",
        0, 0, true, #all, false, event,
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId, clientSecret, connection, caller, calendarConnections, e.message(),
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_insert(
        configForToken(newToken), "primary", #json,
        "", "", "", true, "", "",
        0, 0, true, #all, false, event,
      );
    };
    switch (created.id) {
      case (?id) id;
      case null "";
    };
  };

  func eventSummariesOf(events : Events) : EventSummaryList {
    let items = switch (events.items) {
      case (?items) items;
      case null [];
    };
    Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
      id = switch (e.id) { case (?id) id; case null "" };
      summary = switch (e.summary) { case (?s) s; case null "(no title)" };
      start = switch (e.start) {
        case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
        case null "";
      };
      end = switch (e.end) {
        case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
        case null "";
      };
    });
  };
};

5. Available API surface

5. 可用API范围

google-oauth
(OAuth 2.0 mechanics)

google-oauth
(OAuth 2.0机制)

FunctionPurpose
OAuth.urlEncode(text)
RFC 3986 percent-encoding for form bodies
OAuth.parseTokenResponse(text)
Parse Google token-endpoint JSON
OAuth.exchangeAuthorizationCode(...)
Exchange auth code for tokens
OAuth.refreshAccessToken(...)
Refresh an expired access token
OAuth.generateCodeVerifier()
Generate PKCE
code_verifier
(on-chain randomness)
OAuth.computeCodeChallenge(verifier)
Compute PKCE
code_challenge
(S256)
OAuth.buildAuthorizeUrl(...)
Build the Google OAuth authorize URL
函数用途
OAuth.urlEncode(text)
针对表单主体的RFC 3986百分号编码
OAuth.parseTokenResponse(text)
解析Google令牌端点的JSON响应
OAuth.exchangeAuthorizationCode(...)
将授权码交换为令牌
OAuth.refreshAccessToken(...)
刷新过期的access token
OAuth.generateCodeVerifier()
生成PKCE的
code_verifier
(链上随机数)
OAuth.computeCodeChallenge(verifier)
计算PKCE的
code_challenge
(S256算法)
OAuth.buildAuthorizeUrl(...)
构建Google OAuth授权URL

googlecalendar-client
(Calendar REST API v3)

googlecalendar-client
(Calendar REST API v3)

The canonical actor above intentionally implements only upcoming-event listing and event creation. For another generated operation, keep bearer authentication and
is_replicated = ?false
, then apply the same single-refresh-retry pattern as
refreshIfNeeded
.
The generated package also exposes:
FunctionModulePurpose
calendar_events_list
EventsApiList events on a calendar
calendar_events_get
EventsApiGet an event by id
calendar_events_insert
EventsApiCreate an event
calendar_events_update
EventsApiUpdate an event (PUT)
calendar_events_patch
EventsApiPatch an event (PATCH)
calendar_events_delete
EventsApiDelete an event
calendar_events_move
EventsApiMove an event to another calendar
calendar_events_quickAdd
EventsApiCreate event from text ("Lunch at noon")
calendar_events_instances
EventsApiList instances of a recurring event
calendar_freebusy_query
FreebusyApiCheck free/busy across calendars
calendar_calendarList_list
CalendarListApiList user's calendars
calendar_calendarList_get
CalendarListApiGet a calendar list entry
calendar_calendars_get
CalendarsApiGet calendar metadata
calendar_calendars_insert
CalendarsApiCreate a secondary calendar
上述标准actor仅实现了列出即将到来的事件和创建事件的功能。如需使用其他生成的操作,请保持Bearer认证和
is_replicated = ?false
设置,并应用与
refreshIfNeeded
相同的单次刷新重试模式。
生成的包还提供以下功能:
函数模块用途
calendar_events_list
EventsApi列出日历中的事件
calendar_events_get
EventsApi通过ID获取事件
calendar_events_insert
EventsApi创建事件
calendar_events_update
EventsApi更新事件(PUT请求)
calendar_events_patch
EventsApi部分更新事件(PATCH请求)
calendar_events_delete
EventsApi删除事件
calendar_events_move
EventsApi将事件移动到另一个日历
calendar_events_quickAdd
EventsApi通过文本创建事件(如“中午吃午餐”)
calendar_events_instances
EventsApi列出重复事件的实例
calendar_freebusy_query
FreebusyApi检查多个日历的忙闲状态
calendar_calendarList_list
CalendarListApi列出用户的日历
calendar_calendarList_get
CalendarListApi获取日历列表条目
calendar_calendars_get
CalendarsApi获取日历元数据
calendar_calendars_insert
CalendarsApi创建次级日历

6. Cycles and response sizes

6. Cycles与响应大小

The
google-oauth
library uses
Call.httpRequest
from
mo:ic/Call
, which auto-computes and attaches the exact required cycles via the
ic0.cost_http_request
system API. No manual cycle budgeting is needed for token exchange or refresh calls.
For
googlecalendar-client
calls,
defaultConfig.cycles = 30_000_000_000
(30B). A typical list/insert costs ~10–15B cycles. Set
max_response_bytes = ?2_000_000
for event list reads that may include large payloads.
google-oauth
库使用
mo:ic/Call
中的
Call.httpRequest
,该函数会通过
ic0.cost_http_request
系统API自动计算并附加所需的精确cycles。令牌交换或刷新调用无需手动预算cycles。
对于
googlecalendar-client
调用,
defaultConfig.cycles = 30_000_000_000
(300亿)。典型的列表/插入操作约消耗100-150亿cycles。对于可能包含大 payload的事件列表读取,设置
max_response_bytes = ?2_000_000

7. Things that will bite you

7. 需要注意的陷阱

  • is_replicated = ?false
    — see §3. Non-negotiable.
  • Google refresh tokens do NOT rotate. Unlike X/Twitter, Google does not issue a new
    refresh_token
    on each refresh. Keep the original
    refresh_token
    and only persist the new
    access_token
    .
  • Access tokens expire in 1 hour. The
    refreshIfNeeded
    helper catches HTTP 401, silently refreshes via
    google-oauth.refreshAccessToken
    , and retries once. If the refresh also fails, surface "re-connect your account".
  • Callback URI exact-match. Every character (trailing slash, query string, port) must match between the authorize URL and the redirect. Google returns
    redirect_uri_mismatch
    otherwise. Always use
    window.location.origin + window.location.pathname
    for
    redirectUri
    and register that exact URI on the Google Web client.
  • RFC 3339 timestamps. Calendar uses RFC 3339 strings (
    2026-07-10T15:00:00-07:00
    ). For all-day events set
    EventDateTime.date
    (
    YYYY-MM-DD
    ) instead of
    dateTime
    .
  • calendarId = "primary"
    refers to the authenticated user's default calendar. Named/shared calendars use their calendar-ID (an email-like address).
  • HTTP 429 rate-limit. Surface the error to the caller; never silently retry a write inside the canister — a retry may create a duplicate event.
  • Don't expose the access token.
    calendarConnections
    is read only by
    Map.get(calendarConnections, ..., caller)
    inside API calls. No
    getMyCalendarConnection
    , no
    getMyAccessToken
    , no iterator. A leaked bearer is a per-user account compromise.
  • alt = #json
    for all Calendar API v3 calls. Leave optional string parameters
    ""
    and
    prettyPrint = false
    .
  • Build
    Event
    /
    EventDateTime
    with
    init {}
    then record-update
    the fields you need — all fields are optional (
    ?T
    ); leave the rest null.
  • PATCH/PUT/DELETE are forced non-replicated in the generated client (the
    googlecalendar-client
    sets
    is_replicated = ?false
    on these methods automatically). For GET/POST, set it explicitly in your
    Config
    .
  • is_replicated = ?false
    ——参见第3节。这是不可协商的要求。
  • Google刷新令牌不会轮换。与X/Twitter不同,Google在每次刷新时不会颁发新的
    refresh_token
    。保留原始的
    refresh_token
    ,仅保存新的
    access_token
    即可。
  • Access token有效期为1小时
    refreshIfNeeded
    助手会捕获HTTP 401错误,通过
    google-oauth.refreshAccessToken
    自动刷新令牌,并重试一次请求。如果刷新也失败,则提示用户“重新连接您的账户”。
  • 回调URI必须完全匹配。授权URL和重定向URI的每个字符(末尾斜杠、查询字符串、端口)都必须完全匹配。否则Google会返回
    redirect_uri_mismatch
    错误。始终使用
    window.location.origin + window.location.pathname
    作为
    redirectUri
    ,并在Google Web客户端中注册该精确URI。
  • RFC 3339时间戳。Calendar使用RFC 3339格式的字符串(如
    2026-07-10T15:00:00-07:00
    )。对于全天事件,设置
    EventDateTime.date
    (格式为
    YYYY-MM-DD
    )而非
    dateTime
  • **
    calendarId = "primary"
    **指已认证用户的默认日历。命名/共享日历使用其日历ID(类似邮箱地址的格式)。
  • HTTP 429速率限制。将错误提示给调用者;绝不要在canister内自动重试写入操作——重试可能会创建重复事件。
  • 不要暴露access token
    calendarConnections
    仅在API调用内部通过
    Map.get(calendarConnections, ..., caller)
    读取。不要提供
    getMyCalendarConnection
    getMyAccessToken
    或迭代器接口。泄露的Bearer令牌会导致用户账户被入侵。
  • 所有Calendar API v3调用都需设置
    alt = #json
    。将可选字符串参数设为
    ""
    prettyPrint = false
  • 使用
    init {}
    构建
    Event
    /
    EventDateTime
    ,然后更新所需字段
    ——所有字段都是可选的(
    ?T
    类型);其余字段保持null即可。
  • PATCH/PUT/DELETE操作会被强制设置为非复制模式——生成的客户端(
    googlecalendar-client
    )会自动为这些方法设置
    is_replicated = ?false
    。对于GET/POST请求,请在
    Config
    中显式设置该参数。

Frontend

前端

Every build using this skill must ship:
  1. A login flow — required. Calendar cannot work without a non-anonymous caller; the per-user OAuth handshake stores tokens keyed by
    caller : Principal
    , and the admin credential setter gates on
    #admin
    . The login flow comes from
    extension-authorization
    :
    useInternetIdentity
    , login/logout buttons, the
    useActor
    plumbing that injects the authenticated identity into every backend call.
  2. An admin settings page
    /settings/calendar
    (admin-gated):
    • Two password-inputs bound to
      setCalendarCredentials(clientId, clientSecret)
      . Submit on enter; clear inputs on success.
    • Status indicator driven by
      isCalendarConfigured()
      (returns
      Bool
      ). Show "Configured" / "Not configured" — never display the credentials.
    • Hide from non-admins via
      extension-authorization
      's
      isCallerAdmin
      query — non-admins should not see the link in the nav.
  3. A "Connect Calendar" page
    /connect/calendar
    (any signed-in user):
    • "Connect Google Calendar" button bound to
      startCalendarOAuth(window.location.origin + window.location.pathname)
      . Redirect the browser to the URL returned by the canister. Register this exact URI in the Google Cloud Console first.
    • On the return leg, read
      error
      ,
      code
      , and
      state
      from
      URLSearchParams
      . If
      error
      is present, show the failed/declined connection state and do not call the canister. Only when both
      code
      and
      state
      are present, call
      completeCalendarOAuth(code, state)
      .
    • After either terminal path, call
      history.replaceState
      to remove the OAuth query parameters. This prevents a page refresh from reusing a one-time authorization code.
    • Status driven by
      isMyCalendarConnected()
      (returns
      Bool
      ).
    • Optional "Disconnect Calendar" button bound to
      disconnectMyCalendar()
      .
  4. Calendar UI — the main page shows upcoming events. Pass the current time as the RFC 3339
    timeMin
    value:
    listUpcomingEvents(new Date().toISOString(), 10)
    . This is required when using
    singleEvents = true
    and
    orderBy = startTime
    . Also include a "create event" form.
    datetime-local
    values have no offset, so convert each browser-local value to an RFC 3339 instant before calling the actor:
    createEvent(summary, new Date(startInput).toISOString(), new Date(endInput).toISOString())
    . When
    isMyCalendarConnected()
    is
    false
    , render an inline "Connect Google Calendar" link to
    /connect/calendar
    .
Suggested route layout:
/                   →  Main UI (upcoming events + create form)
/settings/calendar  →  Admin credential config (admin-only)
/connect/calendar   →  Per-user OAuth handshake (any signed-in user)
使用本技能的所有构建都必须包含以下内容:
  1. 登录流程——必填。Calendar无法在匿名调用者的情况下工作;每个用户的OAuth握手流程会将令牌以
    caller : Principal
    为键进行存储,而管理员凭据设置器受
    #admin
    角色权限控制。登录流程来自
    extension-authorization
    useInternetIdentity
    、登录/登出按钮、
    useActor
    管道(将已认证身份注入每个后端调用)。
  2. 管理员设置页面——
    /settings/calendar
    (仅管理员可访问):
    • 两个密码输入框,绑定到
      setCalendarCredentials(clientId, clientSecret)
      方法。按回车键提交;成功后清空输入框。
    • isCalendarConfigured()
      (返回
      Bool
      值)驱动的状态指示器。显示“已配置”/“未配置”——绝不要显示凭据内容。
    • 通过
      extension-authorization
      isCallerAdmin
      查询隐藏非管理员用户的访问入口——非管理员用户不应在导航栏中看到该链接。
  3. “连接日历”页面——
    /connect/calendar
    (任何已登录用户均可访问):
    • “连接Google Calendar”按钮,绑定到
      startCalendarOAuth(window.location.origin + window.location.pathname)
      方法。将浏览器重定向到canister返回的URL。请先在Google Cloud Console中注册该精确URI。
    • 在返回流程中,从
      URLSearchParams
      读取
      error
      code
      state
      参数。如果存在
      error
      ,显示连接失败/被拒绝的状态,不要调用canister。仅当
      code
      state
      都存在时,调用
      completeCalendarOAuth(code, state)
    • 在完成任一终端流程后,调用
      history.replaceState
      移除OAuth查询参数。这可防止页面刷新时重复使用一次性授权码。
    • isMyCalendarConnected()
      (返回
      Bool
      值)驱动的状态显示。
    • 可选的“断开日历连接”按钮,绑定到
      disconnectMyCalendar()
      方法。
  4. Calendar UI——主页面显示即将到来的事件。将当前时间作为RFC 3339格式的
    timeMin
    值传入:
    listUpcomingEvents(new Date().toISOString(), 10)
    。当使用
    singleEvents = true
    orderBy = startTime
    时,这是必填项。还需包含“创建事件”表单。
    datetime-local
    值没有时区偏移,因此在调用actor之前,需将每个浏览器本地时间转换为RFC 3339格式的时间戳:
    createEvent(summary, new Date(startInput).toISOString(), new Date(endInput).toISOString())
    。 当
    isMyCalendarConnected()
    false
    时,渲染一个内联的“连接Google Calendar”链接,指向
    /connect/calendar
建议的路由布局:
/                   →  主UI(即将到来的事件 + 创建表单)
/settings/calendar  →  管理员凭据配置(仅管理员)
/connect/calendar   →  每个用户的OAuth握手流程(任何已登录用户)

Common to all variants

所有变体的通用规则

  • Sign-in is required for every Calendar-related route. Wire the
    /settings/...
    and
    /connect/calendar
    routes through
    extension-authorization
    's auth guard (
    useInternetIdentity
    + redirect when
    !isAuthenticated
    ).
  • The frontend never persists tokens. No
    localStorage
    , no
    IndexedDB
    , no cookies — the canister mediates everything. The browser only ever sees
    Bool
    status flags and the OAuth redirect URLs.
  • The OAuth
    state
    parameter is canister-generated and validated.
    The canister stores a random nonce with the pending verifier and callback URI. The frontend must pass both
    code
    and
    state
    to
    completeCalendarOAuth
    ; it never creates or modifies either value.
  • The calendar UI is trivial: a list of upcoming events, a create-event form with summary + start/end datetime inputs. No client-side Google SDK, no token handling, no JSON serialization — the canister is the Calendar client.
  • 所有Calendar相关路由都需要登录。将
    /settings/...
    /connect/calendar
    路由通过
    extension-authorization
    的认证守卫(
    useInternetIdentity
    + 未认证时重定向)进行处理。
  • 前端绝不存储令牌。不使用
    localStorage
    IndexedDB
    或cookie——所有操作都由canister中介。浏览器仅能看到
    Bool
    类型的状态标志和OAuth重定向URL。
  • OAuth的
    state
    参数由canister生成并验证
    。canister会将随机数与待处理的验证器和回调URI一起存储。前端必须将
    code
    state
    都传递给
    completeCalendarOAuth
    ;前端绝不能创建或修改这两个值。
  • Calendar UI非常简洁:一个即将到来的事件列表,一个包含摘要+开始/结束时间输入框的创建事件表单。无需客户端Google SDK、令牌处理或JSON序列化——canister充当Calendar客户端。

Related

相关资源