forge-openclaw-plugin 0.2.25 → 0.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +59 -3
  2. package/dist/assets/{board-VmF4FAfr.js → board-C6jCchjI.js} +3 -3
  3. package/dist/assets/{board-VmF4FAfr.js.map → board-C6jCchjI.js.map} +1 -1
  4. package/dist/assets/index-DVvS8iiU.css +1 -0
  5. package/dist/assets/index-zYB-9Dfo.js +85 -0
  6. package/dist/assets/index-zYB-9Dfo.js.map +1 -0
  7. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
  8. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
  9. package/dist/assets/{motion-DvkU14p-.js → motion-DFHrH2rd.js} +2 -2
  10. package/dist/assets/{motion-DvkU14p-.js.map → motion-DFHrH2rd.js.map} +1 -1
  11. package/dist/assets/{table-DgiPof9E.js → table-ZL7Di_u3.js} +2 -2
  12. package/dist/assets/{table-DgiPof9E.js.map → table-ZL7Di_u3.js.map} +1 -1
  13. package/dist/assets/{ui-nYfoC0Gq.js → ui-CKNPpz7q.js} +2 -2
  14. package/dist/assets/{ui-nYfoC0Gq.js.map → ui-CKNPpz7q.js.map} +1 -1
  15. package/dist/assets/vendor-DoNZuFhn.js +1247 -0
  16. package/dist/assets/vendor-DoNZuFhn.js.map +1 -0
  17. package/dist/index.html +7 -8
  18. package/dist/openclaw/local-runtime.d.ts +3 -1
  19. package/dist/openclaw/local-runtime.js +67 -15
  20. package/dist/openclaw/plugin-entry-shared.js +24 -2
  21. package/dist/openclaw/plugin-sdk-types.d.ts +17 -0
  22. package/dist/openclaw/routes.d.ts +27 -0
  23. package/dist/openclaw/routes.js +16 -12
  24. package/dist/openclaw/tools.js +0 -3
  25. package/dist/server/server/migrations/001_core.sql +411 -0
  26. package/dist/server/server/migrations/002_psyche.sql +392 -0
  27. package/dist/server/server/migrations/003_habits.sql +30 -0
  28. package/dist/server/server/migrations/004_habit_links.sql +8 -0
  29. package/dist/server/server/migrations/005_habit_psyche_links.sql +24 -0
  30. package/dist/server/server/migrations/006_work_adjustments.sql +14 -0
  31. package/dist/server/server/migrations/007_weekly_review_closures.sql +17 -0
  32. package/dist/server/server/migrations/008_calendar_execution.sql +147 -0
  33. package/dist/server/server/migrations/009_true_calendar_events.sql +195 -0
  34. package/dist/server/server/migrations/010_calendar_selection_state.sql +6 -0
  35. package/dist/server/server/migrations/011_calendar_timezone_backfill.sql +11 -0
  36. package/dist/server/server/migrations/012_work_block_ranges.sql +7 -0
  37. package/dist/server/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
  38. package/dist/server/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
  39. package/dist/server/server/migrations/015_multi_user_and_strategies.sql +244 -0
  40. package/dist/server/server/migrations/016_health_companion.sql +158 -0
  41. package/dist/server/server/migrations/016_strategy_contracts_and_user_graph.sql +22 -0
  42. package/dist/server/server/migrations/017_preferences.sql +131 -0
  43. package/dist/server/server/migrations/018_preference_catalogs.sql +31 -0
  44. package/dist/server/server/migrations/019_wiki_memory.sql +255 -0
  45. package/dist/server/server/migrations/020_wiki_page_hierarchy.sql +11 -0
  46. package/dist/server/server/migrations/021_hide_evidence_from_wiki_index.sql +3 -0
  47. package/dist/server/server/migrations/022_wiki_ingest_background.sql +85 -0
  48. package/dist/server/server/migrations/023_diagnostic_logs.sql +28 -0
  49. package/dist/server/server/migrations/024_questionnaires.sql +96 -0
  50. package/dist/server/server/migrations/025_ai_model_connections.sql +26 -0
  51. package/dist/server/server/migrations/026_custom_theme_settings.sql +2 -0
  52. package/dist/server/server/migrations/027_ai_processors.sql +31 -0
  53. package/dist/server/server/migrations/028_movement_domain.sql +136 -0
  54. package/dist/server/server/migrations/029_watch_micro_capture.sql +23 -0
  55. package/dist/server/server/migrations/030_surface_layouts.sql +5 -0
  56. package/dist/server/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
  57. package/dist/server/server/migrations/032_ai_connectors.sql +44 -0
  58. package/dist/server/server/migrations/033_movement_trip_point_sync.sql +36 -0
  59. package/dist/server/server/migrations/034_movement_segment_sync.sql +49 -0
  60. package/dist/server/server/migrations/035_google_local_auth_settings.sql +2 -0
  61. package/dist/server/server/migrations/036_google_local_auth_client_secret.sql +2 -0
  62. package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  63. package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
  64. package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
  65. package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
  66. package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
  67. package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
  68. package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  69. package/dist/server/{app.js → server/src/app.js} +2112 -414
  70. package/dist/server/server/src/connectors/box-registry.js +223 -0
  71. package/dist/server/server/src/data-management-types.js +107 -0
  72. package/dist/server/{db.js → server/src/db.js} +72 -4
  73. package/dist/server/server/src/debug.js +19 -0
  74. package/dist/server/{demo-data.js → server/src/demo-data.js} +2 -2
  75. package/dist/server/{health.js → server/src/health.js} +702 -18
  76. package/dist/server/{managers → server/src/managers}/platform/llm-manager.js +7 -4
  77. package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
  78. package/dist/server/{managers → server/src/managers}/platform/secrets-manager.js +18 -1
  79. package/dist/server/{managers → server/src/managers}/runtime.js +9 -0
  80. package/dist/server/{movement.js → server/src/movement.js} +1971 -112
  81. package/dist/server/{openapi.js → server/src/openapi.js} +491 -3
  82. package/dist/server/{psyche-types.js → server/src/psyche-types.js} +9 -1
  83. package/dist/server/{repositories → server/src/repositories}/activity-events.js +8 -0
  84. package/dist/server/{repositories → server/src/repositories}/ai-connectors.js +758 -47
  85. package/dist/server/{repositories → server/src/repositories}/calendar.js +1 -1
  86. package/dist/server/{repositories → server/src/repositories}/habits.js +37 -1
  87. package/dist/server/{repositories → server/src/repositories}/model-settings.js +13 -3
  88. package/dist/server/{repositories → server/src/repositories}/notes.js +3 -0
  89. package/dist/server/{repositories → server/src/repositories}/settings.js +431 -21
  90. package/dist/server/{repositories → server/src/repositories}/tasks.js +170 -10
  91. package/dist/server/server/src/runtime-data-root.js +82 -0
  92. package/dist/server/server/src/screen-time.js +802 -0
  93. package/dist/server/{services → server/src/services}/calendar-runtime.js +775 -58
  94. package/dist/server/server/src/services/data-management.js +788 -0
  95. package/dist/server/{services → server/src/services}/entity-crud.js +205 -2
  96. package/dist/server/server/src/services/google-calendar-oauth-config.js +176 -0
  97. package/dist/server/server/src/services/knowledge-graph.js +1455 -0
  98. package/dist/server/server/src/services/life-force-model.js +197 -0
  99. package/dist/server/server/src/services/life-force.js +1270 -0
  100. package/dist/server/server/src/services/psyche-observation-calendar.js +413 -0
  101. package/dist/server/{types.js → server/src/types.js} +420 -29
  102. package/dist/server/server/src/web.js +332 -0
  103. package/dist/server/src/components/customization/utility-widgets.js +439 -0
  104. package/dist/server/src/components/ui/info-tooltip.js +25 -0
  105. package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
  106. package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
  107. package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
  108. package/dist/server/src/components/workbench-boxes/health/health-boxes.js +147 -0
  109. package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
  110. package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +136 -0
  111. package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +47 -0
  112. package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +132 -0
  113. package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
  114. package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
  115. package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +62 -0
  116. package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
  117. package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
  118. package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
  119. package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +6 -0
  120. package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +49 -0
  121. package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
  122. package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
  123. package/dist/server/src/components/workbench-boxes/today/today-boxes.js +78 -0
  124. package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
  125. package/dist/server/src/lib/api-error.js +37 -0
  126. package/dist/server/src/lib/api.js +2118 -0
  127. package/dist/server/src/lib/calendar-name-deduper.js +144 -0
  128. package/dist/server/src/lib/data-management-types.js +1 -0
  129. package/dist/server/src/lib/diagnostics.js +67 -0
  130. package/dist/server/src/lib/entity-visuals.js +279 -0
  131. package/dist/server/src/lib/knowledge-graph-types.js +276 -0
  132. package/dist/server/src/lib/knowledge-graph.js +470 -0
  133. package/dist/server/src/lib/psyche-types.js +1 -0
  134. package/dist/server/src/lib/questionnaire-types.js +1 -0
  135. package/dist/server/src/lib/runtime-paths.js +24 -0
  136. package/dist/server/src/lib/schemas.js +238 -0
  137. package/dist/server/src/lib/snapshot-normalizer.js +416 -0
  138. package/dist/server/src/lib/theme-system.js +319 -0
  139. package/dist/server/src/lib/types.js +1 -0
  140. package/dist/server/src/lib/utils.js +22 -0
  141. package/dist/server/src/lib/workbench/boxes.js +16 -0
  142. package/dist/server/src/lib/workbench/contracts.js +229 -0
  143. package/dist/server/src/lib/workbench/nodes.js +215 -0
  144. package/dist/server/src/lib/workbench/registry.js +120 -0
  145. package/dist/server/src/lib/workbench/runtime.js +397 -0
  146. package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
  147. package/openclaw.plugin.json +1 -1
  148. package/package.json +1 -1
  149. package/server/index.js +68 -0
  150. package/server/migrations/035_google_local_auth_settings.sql +2 -0
  151. package/server/migrations/036_google_local_auth_client_secret.sql +2 -0
  152. package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  153. package/server/migrations/038_data_management_settings.sql +11 -0
  154. package/server/migrations/039_life_force_and_action_points.sql +114 -0
  155. package/server/migrations/040_screen_time_domain.sql +89 -0
  156. package/server/migrations/041_companion_source_states.sql +21 -0
  157. package/server/migrations/042_movement_boxes.sql +47 -0
  158. package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  159. package/skills/forge-openclaw/SKILL.md +27 -11
  160. package/skills/forge-openclaw/entity_conversation_playbooks.md +411 -46
  161. package/skills/forge-openclaw/psyche_entity_playbooks.md +195 -20
  162. package/dist/assets/index-CFCKDIMH.js +0 -67
  163. package/dist/assets/index-CFCKDIMH.js.map +0 -1
  164. package/dist/assets/index-ZPY6U1TU.css +0 -1
  165. package/dist/assets/vendor-D9PTEPSB.js +0 -824
  166. package/dist/assets/vendor-D9PTEPSB.js.map +0 -1
  167. package/dist/assets/viz-Cqb6s--o.js +0 -34
  168. package/dist/assets/viz-Cqb6s--o.js.map +0 -1
  169. package/dist/server/connectors/box-registry.js +0 -257
  170. package/dist/server/services/psyche-observation-calendar.js +0 -46
  171. package/dist/server/web.js +0 -98
  172. /package/dist/server/{discovery-advertiser.js → server/src/discovery-advertiser.js} +0 -0
  173. /package/dist/server/{e2e-server.js → server/src/e2e-server.js} +0 -0
  174. /package/dist/server/{errors.js → server/src/errors.js} +0 -0
  175. /package/dist/server/{index.js → server/src/index.js} +0 -0
  176. /package/dist/server/{managers → server/src/managers}/base.js +0 -0
  177. /package/dist/server/{managers → server/src/managers}/contracts.js +0 -0
  178. /package/dist/server/{managers → server/src/managers}/platform/api-gateway-manager.js +0 -0
  179. /package/dist/server/{managers → server/src/managers}/platform/audit-manager.js +0 -0
  180. /package/dist/server/{managers → server/src/managers}/platform/authentication-manager.js +0 -0
  181. /package/dist/server/{managers → server/src/managers}/platform/authorization-manager.js +0 -0
  182. /package/dist/server/{managers → server/src/managers}/platform/background-job-manager.js +0 -0
  183. /package/dist/server/{managers → server/src/managers}/platform/configuration-manager.js +0 -0
  184. /package/dist/server/{managers → server/src/managers}/platform/database-manager.js +0 -0
  185. /package/dist/server/{managers → server/src/managers}/platform/event-bus-manager.js +0 -0
  186. /package/dist/server/{managers → server/src/managers}/platform/external-service-manager.js +0 -0
  187. /package/dist/server/{managers → server/src/managers}/platform/health-manager.js +0 -0
  188. /package/dist/server/{managers → server/src/managers}/platform/migration-manager.js +0 -0
  189. /package/dist/server/{managers → server/src/managers}/platform/openai-responses-provider.js +0 -0
  190. /package/dist/server/{managers → server/src/managers}/platform/search-index-manager.js +0 -0
  191. /package/dist/server/{managers → server/src/managers}/platform/session-manager.js +0 -0
  192. /package/dist/server/{managers → server/src/managers}/platform/storage-manager.js +0 -0
  193. /package/dist/server/{managers → server/src/managers}/platform/token-manager.js +0 -0
  194. /package/dist/server/{managers → server/src/managers}/platform/transaction-manager.js +0 -0
  195. /package/dist/server/{managers → server/src/managers}/platform/trusted-network.js +0 -0
  196. /package/dist/server/{managers → server/src/managers}/type-guards.js +0 -0
  197. /package/dist/server/{preferences-seeds.js → server/src/preferences-seeds.js} +0 -0
  198. /package/dist/server/{preferences-types.js → server/src/preferences-types.js} +0 -0
  199. /package/dist/server/{questionnaire-flow.js → server/src/questionnaire-flow.js} +0 -0
  200. /package/dist/server/{questionnaire-seeds.js → server/src/questionnaire-seeds.js} +0 -0
  201. /package/dist/server/{questionnaire-types.js → server/src/questionnaire-types.js} +0 -0
  202. /package/dist/server/{repositories → server/src/repositories}/ai-processors.js +0 -0
  203. /package/dist/server/{repositories → server/src/repositories}/collaboration.js +0 -0
  204. /package/dist/server/{repositories → server/src/repositories}/deleted-entities.js +0 -0
  205. /package/dist/server/{repositories → server/src/repositories}/diagnostic-logs.js +0 -0
  206. /package/dist/server/{repositories → server/src/repositories}/domains.js +0 -0
  207. /package/dist/server/{repositories → server/src/repositories}/entity-ownership.js +0 -0
  208. /package/dist/server/{repositories → server/src/repositories}/event-log.js +0 -0
  209. /package/dist/server/{repositories → server/src/repositories}/goals.js +0 -0
  210. /package/dist/server/{repositories → server/src/repositories}/preferences.js +0 -0
  211. /package/dist/server/{repositories → server/src/repositories}/projects.js +0 -0
  212. /package/dist/server/{repositories → server/src/repositories}/psyche.js +0 -0
  213. /package/dist/server/{repositories → server/src/repositories}/questionnaires.js +0 -0
  214. /package/dist/server/{repositories → server/src/repositories}/rewards.js +0 -0
  215. /package/dist/server/{repositories → server/src/repositories}/strategies.js +0 -0
  216. /package/dist/server/{repositories → server/src/repositories}/surface-layouts.js +0 -0
  217. /package/dist/server/{repositories → server/src/repositories}/tags.js +0 -0
  218. /package/dist/server/{repositories → server/src/repositories}/task-runs.js +0 -0
  219. /package/dist/server/{repositories → server/src/repositories}/users.js +0 -0
  220. /package/dist/server/{repositories → server/src/repositories}/weekly-reviews.js +0 -0
  221. /package/dist/server/{repositories → server/src/repositories}/wiki-memory.js +0 -0
  222. /package/dist/server/{repositories → server/src/repositories}/work-adjustments.js +0 -0
  223. /package/dist/server/{seed-demo.js → server/src/seed-demo.js} +0 -0
  224. /package/dist/server/{services → server/src/services}/context.js +0 -0
  225. /package/dist/server/{services → server/src/services}/dashboard.js +0 -0
  226. /package/dist/server/{services → server/src/services}/gamification.js +0 -0
  227. /package/dist/server/{services → server/src/services}/insights.js +0 -0
  228. /package/dist/server/{services → server/src/services}/openai-codex-oauth.js +0 -0
  229. /package/dist/server/{services → server/src/services}/projects.js +0 -0
  230. /package/dist/server/{services → server/src/services}/psyche.js +0 -0
  231. /package/dist/server/{services → server/src/services}/relations.js +0 -0
  232. /package/dist/server/{services → server/src/services}/reviews.js +0 -0
  233. /package/dist/server/{services → server/src/services}/run-recovery.js +0 -0
  234. /package/dist/server/{services → server/src/services}/tagging.js +0 -0
  235. /package/dist/server/{services → server/src/services}/task-run-watchdog.js +0 -0
  236. /package/dist/server/{services → server/src/services}/work-time.js +0 -0
  237. /package/dist/server/{watch-mobile.js → server/src/watch-mobile.js} +0 -0
@@ -1,6 +1,8 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
3
3
  import ical from "node-ical";
4
+ import { logForgeDebug } from "../debug.js";
5
+ import { getGoogleCalendarOauthCallbackPath, isGoogleCalendarOriginAllowed, isGoogleCalendarLoopbackOrigin, resolveGoogleCalendarOauthPrivateConfig } from "./google-calendar-oauth-config.js";
4
6
  import { createDAVClient, DAVNamespaceShort } from "tsdav";
5
7
  import { getSettings } from "../repositories/settings.js";
6
8
  import { createCalendarConnectionRecord, deleteCalendarConnectionRecord, deleteEncryptedSecret, deleteExternalEventsForConnection, detachConnectionFromForgeEvents, getCalendarById, getCalendarConnectionById, getCalendarEventStorageRecord, getCalendarOverview, listCalendarConnections, listCalendarEventSources, listCalendars, listTaskTimeboxes, markCalendarEventSourcesSyncState, readEncryptedSecret, registerCalendarEventSourceProjection, recordCalendarActivity, storeEncryptedSecret, updateCalendarConnectionRecord, updateTaskTimebox, upsertCalendarEventRecord, upsertCalendarRecord } from "../repositories/calendar.js";
@@ -8,7 +10,18 @@ function isWritableCalendarCredentials(credentials) {
8
10
  return credentials.provider !== "microsoft";
9
11
  }
10
12
  const GOOGLE_CALDAV_URL = "https://apidata.googleusercontent.com/caldav/v2/";
11
- const GOOGLE_TOKEN_URL = "https://accounts.google.com/o/oauth2/token";
13
+ const GOOGLE_OAUTH_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth";
14
+ const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
15
+ const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo";
16
+ const GOOGLE_CALENDAR_LIST_URL = "https://www.googleapis.com/calendar/v3/users/me/calendarList";
17
+ const GOOGLE_CALENDAR_CREATE_URL = "https://www.googleapis.com/calendar/v3/calendars";
18
+ const GOOGLE_CALLBACK_PATH = getGoogleCalendarOauthCallbackPath();
19
+ const GOOGLE_OAUTH_SCOPES = [
20
+ "openid",
21
+ "email",
22
+ "profile",
23
+ "https://www.googleapis.com/auth/calendar"
24
+ ];
12
25
  const APPLE_CALDAV_URL = "https://caldav.icloud.com";
13
26
  const MICROSOFT_GRAPH_URL = "https://graph.microsoft.com/v1.0";
14
27
  const MICROSOFT_LOGIN_URL = "https://login.microsoftonline.com";
@@ -18,6 +31,8 @@ const MICROSOFT_CLIENT_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{
18
31
  const FORGE_CALENDAR_NAME = "Forge";
19
32
  const FORGE_CALENDAR_COLOR = "#7dd3fc";
20
33
  const microsoftOauthSessions = new Map();
34
+ const googleOauthSessions = new Map();
35
+ const googleOauthStates = new Map();
21
36
  export class CalendarConnectionConflictError extends Error {
22
37
  connectionId;
23
38
  constructor(message, connectionId) {
@@ -125,6 +140,205 @@ function pruneMicrosoftOauthSessions() {
125
140
  }
126
141
  }
127
142
  }
143
+ function pruneGoogleOauthSessions() {
144
+ const now = Date.now();
145
+ for (const [sessionId, session] of googleOauthSessions.entries()) {
146
+ if (new Date(session.expiresAt).getTime() <= now) {
147
+ googleOauthSessions.set(sessionId, { ...session, status: "expired" });
148
+ }
149
+ if (session.status === "expired" ||
150
+ session.status === "consumed" ||
151
+ new Date(session.expiresAt).getTime() <= now - 5 * 60 * 1000) {
152
+ googleOauthStates.delete(session.state);
153
+ googleOauthSessions.delete(sessionId);
154
+ }
155
+ }
156
+ }
157
+ function encodeBase64Url(value) {
158
+ return Buffer.from(value)
159
+ .toString("base64")
160
+ .replaceAll("+", "-")
161
+ .replaceAll("/", "_")
162
+ .replaceAll("=", "");
163
+ }
164
+ function buildPkcePair() {
165
+ const verifier = encodeBase64Url(randomBytes(32));
166
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
167
+ return { verifier, challenge };
168
+ }
169
+ function findGoogleOauthSessionByState(state) {
170
+ const sessionId = googleOauthStates.get(state);
171
+ if (!sessionId) {
172
+ return null;
173
+ }
174
+ const session = googleOauthSessions.get(sessionId);
175
+ if (!session || session.state !== state) {
176
+ googleOauthStates.delete(state);
177
+ return null;
178
+ }
179
+ return session;
180
+ }
181
+ function finalizeGoogleOauthSession(session) {
182
+ googleOauthStates.delete(session.state);
183
+ session.codeVerifier = null;
184
+ }
185
+ function normalizeRequestOrigin(value) {
186
+ const trimmed = value?.trim();
187
+ if (!trimmed) {
188
+ return null;
189
+ }
190
+ try {
191
+ return new URL(trimmed).origin;
192
+ }
193
+ catch {
194
+ return null;
195
+ }
196
+ }
197
+ function googleOauthStartRejectionMessage(config, browserOrigin) {
198
+ const allowed = config.allowedOrigins.join(", ");
199
+ if (browserOrigin && !isGoogleCalendarLoopbackOrigin(browserOrigin)) {
200
+ return `Google Calendar sign-in cannot start from ${browserOrigin}. Forge is running as a localhost app and Google redirects back to ${config.redirectUri}, so the browser must also be on localhost on the same machine running Forge. Open Forge locally on one of these origins: ${allowed}.`;
201
+ }
202
+ return `Google Calendar sign-in is only available from local Forge browser origins on this machine. Current browser origin: ${browserOrigin ?? "unknown"}. Allowed local origins: ${allowed}. Callback: ${config.redirectUri}.`;
203
+ }
204
+ function resolveStoredGoogleOauthConfig() {
205
+ const settings = getSettings();
206
+ const config = resolveGoogleCalendarOauthPrivateConfig(process.env, {
207
+ clientId: settings.calendarProviders.google.storedClientId,
208
+ clientSecret: settings.calendarProviders.google.storedClientSecret
209
+ });
210
+ logForgeDebug(`[forge-google-oauth] resolve_stored_config clientId=${JSON.stringify(config.clientId)} isConfigured=${JSON.stringify(config.isConfigured)} redirectUri=${JSON.stringify(config.redirectUri)} allowedOrigins=${JSON.stringify(config.allowedOrigins)} hasServerClientSecret=${JSON.stringify(Boolean(config.clientSecret))}`);
211
+ return config;
212
+ }
213
+ function ensureGoogleOauthStartAllowed(input) {
214
+ const config = resolveStoredGoogleOauthConfig();
215
+ const browserOrigin = normalizeRequestOrigin(input.browserOrigin) ??
216
+ normalizeRequestOrigin(input.openerOrigin);
217
+ const openerOrigin = normalizeRequestOrigin(input.openerOrigin) ?? browserOrigin;
218
+ const requestBaseOrigin = normalizeRequestOrigin(input.requestBaseOrigin);
219
+ const browserOriginAllowed = browserOrigin !== null &&
220
+ isGoogleCalendarOriginAllowed(browserOrigin, config.allowedOrigins);
221
+ const callbackReachableFromBrowser = browserOrigin !== null &&
222
+ isGoogleCalendarLoopbackOrigin(config.redirectUri) &&
223
+ isGoogleCalendarLoopbackOrigin(browserOrigin);
224
+ const pairingAllowed = config.isConfigured && browserOriginAllowed && callbackReachableFromBrowser;
225
+ logForgeDebug(`[forge-google-oauth] start_check browserOrigin=${JSON.stringify(browserOrigin)} openerOrigin=${JSON.stringify(openerOrigin)} requestBaseOrigin=${JSON.stringify(requestBaseOrigin)} browserOriginAllowed=${JSON.stringify(browserOriginAllowed)} callbackReachableFromBrowser=${JSON.stringify(callbackReachableFromBrowser)} pairingAllowed=${JSON.stringify(pairingAllowed)}`);
226
+ recordCalendarActivity("calendar_google_oauth_start_checked", "calendar_connection", "google_oauth", "Google OAuth start checked", pairingAllowed
227
+ ? "Forge accepted a Google OAuth start request."
228
+ : "Forge rejected a Google OAuth start request.", { source: "system", actor: null }, {
229
+ configured: config.isConfigured,
230
+ pairingAllowed,
231
+ browserOrigin,
232
+ openerOrigin,
233
+ requestBaseOrigin,
234
+ appBaseUrl: config.appBaseUrl,
235
+ redirectUri: config.redirectUri
236
+ });
237
+ if (!config.isConfigured) {
238
+ throw new Error(config.setupMessage);
239
+ }
240
+ if (!browserOriginAllowed || !callbackReachableFromBrowser || !openerOrigin) {
241
+ throw new Error(googleOauthStartRejectionMessage(config, browserOrigin));
242
+ }
243
+ return {
244
+ config,
245
+ browserOrigin: browserOrigin,
246
+ openerOrigin: openerOrigin,
247
+ requestBaseOrigin: requestBaseOrigin
248
+ };
249
+ }
250
+ function toGoogleOauthSessionPayload(session) {
251
+ return {
252
+ sessionId: session.sessionId,
253
+ status: session.status,
254
+ authUrl: session.authUrl,
255
+ accountLabel: session.accountLabel,
256
+ error: session.error,
257
+ discovery: session.discovery
258
+ };
259
+ }
260
+ function explainGoogleOauthError(input) {
261
+ const raw = `${input.error ?? ""} ${input.errorDescription ?? ""}`.toLowerCase();
262
+ if (raw.includes("redirect_uri_mismatch")) {
263
+ return `Google rejected the redirect URI. Register exactly ${input.redirectUri} on the local Google OAuth client and reopen Forge on localhost on the same machine running Forge.`;
264
+ }
265
+ if (raw.includes("access_denied") || raw.includes("cancel")) {
266
+ return "Google consent was denied or cancelled. Retry the Google sign-in and grant the requested calendar access.";
267
+ }
268
+ if (raw.includes("invalid_scope")) {
269
+ return "Google rejected the requested calendar scopes. Confirm the Calendar API is enabled and the consent screen includes the requested Calendar scopes.";
270
+ }
271
+ if (raw.includes("client_secret is missing")) {
272
+ return "Google rejected this OAuth client because it still requires a client secret. Set GOOGLE_CLIENT_SECRET on the Forge server for this install, or replace the client with a Google Desktop app client that supports the local PKCE flow.";
273
+ }
274
+ return input.errorDescription?.trim() || input.error?.trim() || "Google sign-in could not be completed.";
275
+ }
276
+ function mapGoogleRuntimeError(error) {
277
+ const message = error instanceof Error ? error.message : "Google calendar sync failed.";
278
+ const raw = message.toLowerCase();
279
+ if (raw.includes("invalid_grant")) {
280
+ return new Error("The stored Google refresh token is missing, expired, or revoked. Reconnect Google Calendar from Settings so Forge can keep syncing.");
281
+ }
282
+ if (raw.includes("invalid_client") || raw.includes("unauthorized_client")) {
283
+ return new Error("The local Google OAuth client was rejected. Check the Google client ID and the registered localhost redirect URI.");
284
+ }
285
+ if (raw.includes("redirect_uri_mismatch")) {
286
+ return new Error("Google rejected the configured redirect URI. Register the exact Forge callback URI in Google Cloud Console and reconnect Google Calendar.");
287
+ }
288
+ return error instanceof Error ? error : new Error(message);
289
+ }
290
+ async function refreshGoogleCaldavAccessToken(credentials) {
291
+ const config = resolveStoredGoogleOauthConfig();
292
+ logForgeDebug(`[forge-google-oauth] caldav_refresh_start username=${JSON.stringify(credentials.username)} hasRefreshToken=${JSON.stringify(Boolean(credentials.refreshToken))} hasServerClientSecret=${JSON.stringify(Boolean(config.clientSecret))}`);
293
+ const requestBody = new URLSearchParams({
294
+ client_id: config.clientId,
295
+ grant_type: "refresh_token",
296
+ refresh_token: credentials.refreshToken
297
+ });
298
+ if (config.clientSecret) {
299
+ requestBody.set("client_secret", config.clientSecret);
300
+ }
301
+ const response = await fetch(GOOGLE_TOKEN_URL, {
302
+ method: "POST",
303
+ headers: {
304
+ "Content-Type": "application/x-www-form-urlencoded",
305
+ Accept: "application/json"
306
+ },
307
+ body: requestBody.toString()
308
+ });
309
+ const payload = (await response.json());
310
+ if (!response.ok) {
311
+ throw new Error(explainGoogleOauthError({
312
+ error: typeof payload.error === "string" ? payload.error : null,
313
+ errorDescription: typeof payload.error_description === "string"
314
+ ? payload.error_description
315
+ : null,
316
+ redirectUri: config.redirectUri,
317
+ appBaseUrl: config.appBaseUrl
318
+ }));
319
+ }
320
+ const accessToken = typeof payload.access_token === "string" ? payload.access_token : "";
321
+ if (!accessToken) {
322
+ throw new Error("Google refresh completed without an access token.");
323
+ }
324
+ const expiresInSeconds = typeof payload.expires_in === "number"
325
+ ? payload.expires_in
326
+ : Number(payload.expires_in ?? 0);
327
+ const accessTokenExpiresAt = Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
328
+ ? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
329
+ : credentials.accessTokenExpiresAt;
330
+ const scopeText = typeof payload.scope === "string" ? payload.scope : "";
331
+ const grantedScopes = scopeText
332
+ .split(/\s+/)
333
+ .map((entry) => entry.trim())
334
+ .filter((entry) => entry.length > 0);
335
+ logForgeDebug(`[forge-google-oauth] caldav_refresh_complete username=${JSON.stringify(credentials.username)} accessTokenExpiresAt=${JSON.stringify(accessTokenExpiresAt)} grantedScopeCount=${JSON.stringify((grantedScopes.length > 0 ? grantedScopes : credentials.grantedScopes).length)}`);
336
+ return {
337
+ accessToken,
338
+ accessTokenExpiresAt,
339
+ grantedScopes: grantedScopes.length > 0 ? grantedScopes : credentials.grantedScopes
340
+ };
341
+ }
128
342
  function normalizeUrl(value) {
129
343
  const url = new URL(value);
130
344
  if (!url.pathname.endsWith("/")) {
@@ -135,6 +349,209 @@ function normalizeUrl(value) {
135
349
  function normalizeAccountIdentity(value) {
136
350
  return value.trim().toLowerCase();
137
351
  }
352
+ function buildGoogleCalendarCollectionUrl(calendarId) {
353
+ return normalizeUrl(new URL(`${encodeURIComponent(calendarId)}/events`, GOOGLE_CALDAV_URL).toString());
354
+ }
355
+ function extractGoogleCalendarIdFromCollectionUrl(calendarUrl) {
356
+ const url = new URL(calendarUrl);
357
+ const match = /^\/caldav\/v2\/(.+)\/events\/$/.exec(url.pathname);
358
+ if (!match?.[1]) {
359
+ throw new Error(`Forge could not derive the Google calendar ID from ${calendarUrl}.`);
360
+ }
361
+ return decodeURIComponent(match[1]);
362
+ }
363
+ function buildGoogleCalendarEventApiUrl(calendarId, eventId) {
364
+ return `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`;
365
+ }
366
+ function buildGoogleDavAccount(email) {
367
+ const normalizedEmail = normalizeAccountIdentity(email);
368
+ const principalUrl = normalizeUrl(new URL(`${encodeURIComponent(normalizedEmail)}/user`, GOOGLE_CALDAV_URL).toString());
369
+ return {
370
+ accountType: "caldav",
371
+ serverUrl: GOOGLE_CALDAV_URL,
372
+ rootUrl: GOOGLE_CALDAV_URL,
373
+ principalUrl,
374
+ homeUrl: normalizeUrl(new URL(`${encodeURIComponent(normalizedEmail)}/`, GOOGLE_CALDAV_URL).toString())
375
+ };
376
+ }
377
+ async function fetchGoogleCalendarList(credentials) {
378
+ const refreshed = await refreshGoogleCaldavAccessToken(credentials);
379
+ const calendars = [];
380
+ let pageToken = null;
381
+ do {
382
+ const url = new URL(GOOGLE_CALENDAR_LIST_URL);
383
+ if (pageToken) {
384
+ url.searchParams.set("pageToken", pageToken);
385
+ }
386
+ const response = await fetch(url.toString(), {
387
+ headers: {
388
+ Authorization: `Bearer ${refreshed.accessToken}`,
389
+ Accept: "application/json"
390
+ }
391
+ });
392
+ const payload = (await response.json());
393
+ if (!response.ok) {
394
+ throw new Error(payload.error?.message?.trim() ||
395
+ "Google sign-in completed, but Forge could not list the available calendars.");
396
+ }
397
+ for (const item of payload.items ?? []) {
398
+ const calendarId = typeof item.id === "string" && item.id.trim().length > 0
399
+ ? item.id.trim()
400
+ : null;
401
+ if (!calendarId) {
402
+ continue;
403
+ }
404
+ const displayName = safeDisplayName(item.summary, item.primary ? "Primary" : "Calendar");
405
+ const accessRole = safeDisplayName(item.accessRole, "");
406
+ const canWrite = accessRole === "owner" || accessRole === "writer";
407
+ calendars.push({
408
+ url: buildGoogleCalendarCollectionUrl(calendarId),
409
+ displayName,
410
+ description: safeDisplayName(item.description, "") ||
411
+ (item.primary ? "Primary Google calendar" : ""),
412
+ calendarColor: typeof item.backgroundColor === "string" && item.backgroundColor.trim().length > 0
413
+ ? item.backgroundColor.trim()
414
+ : FORGE_CALENDAR_COLOR,
415
+ timezone: normalizeTimezone(item.timeZone),
416
+ components: ["VEVENT"],
417
+ resourcetype: ["collection", "calendar"],
418
+ reports: ["syncCollection"],
419
+ ctag: undefined,
420
+ syncToken: undefined,
421
+ objects: [],
422
+ account: undefined,
423
+ data: undefined,
424
+ etag: undefined,
425
+ props: undefined,
426
+ addressBook: undefined
427
+ });
428
+ if (!canWrite) {
429
+ const last = calendars[calendars.length - 1];
430
+ if (last) {
431
+ last._forgeCanWrite = false;
432
+ }
433
+ }
434
+ }
435
+ pageToken =
436
+ typeof payload.nextPageToken === "string" && payload.nextPageToken.trim().length > 0
437
+ ? payload.nextPageToken.trim()
438
+ : null;
439
+ } while (pageToken);
440
+ return calendars;
441
+ }
442
+ async function createGoogleForgeCalendar(credentials) {
443
+ const refreshed = await refreshGoogleCaldavAccessToken(credentials);
444
+ const response = await fetch(GOOGLE_CALENDAR_CREATE_URL, {
445
+ method: "POST",
446
+ headers: {
447
+ Authorization: `Bearer ${refreshed.accessToken}`,
448
+ Accept: "application/json",
449
+ "Content-Type": "application/json"
450
+ },
451
+ body: JSON.stringify({
452
+ summary: FORGE_CALENDAR_NAME,
453
+ description: "Forge-owned work blocks and task timeboxes",
454
+ timeZone: "UTC"
455
+ })
456
+ });
457
+ const payload = (await response.json());
458
+ if (!response.ok) {
459
+ throw new Error(payload.error?.message?.trim() ||
460
+ "Forge could not create the dedicated Google calendar.");
461
+ }
462
+ const calendarId = typeof payload.id === "string" && payload.id.trim().length > 0
463
+ ? payload.id.trim()
464
+ : null;
465
+ if (!calendarId) {
466
+ throw new Error("Google created a calendar without returning its ID.");
467
+ }
468
+ return buildGoogleCalendarCollectionUrl(calendarId);
469
+ }
470
+ function parseGoogleCalendarEventDate(value, fallbackToEndOfDay = false) {
471
+ if (!value) {
472
+ return null;
473
+ }
474
+ if (typeof value.dateTime === "string" && value.dateTime.trim().length > 0) {
475
+ const candidate = new Date(value.dateTime);
476
+ return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString();
477
+ }
478
+ if (typeof value.date === "string" && value.date.trim().length > 0) {
479
+ const suffix = fallbackToEndOfDay ? "T23:59:59.999Z" : "T00:00:00.000Z";
480
+ const candidate = new Date(`${value.date}${suffix}`);
481
+ return Number.isNaN(candidate.getTime()) ? null : candidate.toISOString();
482
+ }
483
+ return null;
484
+ }
485
+ function mapGoogleCalendarEventToSyncInput(calendarUrl, event) {
486
+ const calendarId = extractGoogleCalendarIdFromCollectionUrl(calendarUrl);
487
+ const startAt = parseGoogleCalendarEventDate(event.start, false);
488
+ const endAt = parseGoogleCalendarEventDate(event.end, true);
489
+ if (!startAt || !endAt) {
490
+ return null;
491
+ }
492
+ const remoteId = typeof event.id === "string" && event.id.trim().length > 0
493
+ ? event.id.trim()
494
+ : typeof event.iCalUID === "string" && event.iCalUID.trim().length > 0
495
+ ? event.iCalUID.trim()
496
+ : null;
497
+ if (!remoteId) {
498
+ return null;
499
+ }
500
+ return {
501
+ calendarRemoteId: normalizeUrl(calendarUrl),
502
+ remoteId,
503
+ remoteHref: typeof event.htmlLink === "string" && event.htmlLink.trim().length > 0
504
+ ? event.htmlLink.trim()
505
+ : buildGoogleCalendarEventApiUrl(calendarId, remoteId),
506
+ remoteEtag: null,
507
+ ownership: "external",
508
+ status: event.status === "cancelled"
509
+ ? "cancelled"
510
+ : event.status === "tentative"
511
+ ? "tentative"
512
+ : "confirmed",
513
+ title: typeof event.summary === "string" && event.summary.trim().length > 0
514
+ ? event.summary.trim()
515
+ : "(untitled event)",
516
+ description: typeof event.description === "string" ? event.description : "",
517
+ location: typeof event.location === "string" ? event.location : "",
518
+ startAt,
519
+ endAt,
520
+ isAllDay: typeof event.start?.date === "string" && event.start.date.trim().length > 0,
521
+ availability: event.transparency === "transparent" ? "free" : "busy",
522
+ eventType: "",
523
+ categories: [],
524
+ rawPayload: event,
525
+ remoteUpdatedAt: typeof event.updated === "string" && event.updated.trim().length > 0
526
+ ? event.updated.trim()
527
+ : null,
528
+ deletedAt: event.status === "cancelled" ? new Date().toISOString() : null
529
+ };
530
+ }
531
+ async function fetchGoogleCalendarEvents(credentials, calendarUrl, range) {
532
+ const refreshed = await refreshGoogleCaldavAccessToken(credentials);
533
+ const calendarId = extractGoogleCalendarIdFromCollectionUrl(calendarUrl);
534
+ const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`);
535
+ url.searchParams.set("timeMin", range.start);
536
+ url.searchParams.set("timeMax", range.end);
537
+ url.searchParams.set("singleEvents", "true");
538
+ url.searchParams.set("showDeleted", "true");
539
+ url.searchParams.set("maxResults", "2500");
540
+ const response = await fetch(url.toString(), {
541
+ headers: {
542
+ Authorization: `Bearer ${refreshed.accessToken}`,
543
+ Accept: "application/json"
544
+ }
545
+ });
546
+ const payload = (await response.json());
547
+ if (!response.ok) {
548
+ throw new Error(payload.error?.message?.trim() ||
549
+ `Google Calendar API event sync failed for ${calendarId}.`);
550
+ }
551
+ return (payload.items ?? [])
552
+ .map((event) => mapGoogleCalendarEventToSyncInput(calendarUrl, event))
553
+ .filter((event) => event !== null);
554
+ }
138
555
  function safeDisplayName(value, fallback) {
139
556
  if (typeof value === "string" && value.trim().length > 0) {
140
557
  return value.trim();
@@ -148,6 +565,9 @@ function normalizeTimezone(value) {
148
565
  const normalized = value?.trim();
149
566
  return normalized && normalized.length > 0 ? normalized : "UTC";
150
567
  }
568
+ function canWriteDavCalendar(calendar) {
569
+ return calendar._forgeCanWrite ?? true;
570
+ }
151
571
  function buildEventIcs(payload) {
152
572
  const dt = (value) => value.replaceAll(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
153
573
  return [
@@ -337,18 +757,21 @@ async function createProviderClient(credentials) {
337
757
  };
338
758
  }
339
759
  const client = credentials.provider === "google"
340
- ? await createDAVClient({
341
- serverUrl: credentials.serverUrl,
342
- credentials: {
343
- username: credentials.username,
344
- tokenUrl: GOOGLE_TOKEN_URL,
345
- refreshToken: credentials.refreshToken,
346
- clientId: credentials.clientId,
347
- clientSecret: credentials.clientSecret
348
- },
349
- authMethod: "Oauth",
350
- defaultAccountType: "caldav"
351
- })
760
+ ? await (async () => {
761
+ try {
762
+ const refreshed = await refreshGoogleCaldavAccessToken(credentials);
763
+ return await createDAVClient({
764
+ serverUrl: credentials.serverUrl,
765
+ credentials: {
766
+ accessToken: refreshed.accessToken
767
+ },
768
+ authMethod: "Bearer"
769
+ });
770
+ }
771
+ catch (error) {
772
+ throw mapGoogleRuntimeError(error);
773
+ }
774
+ })()
352
775
  : await createDAVClient({
353
776
  serverUrl: credentials.serverUrl,
354
777
  credentials: {
@@ -358,15 +781,33 @@ async function createProviderClient(credentials) {
358
781
  authMethod: "Basic",
359
782
  defaultAccountType: "caldav"
360
783
  });
361
- const account = await client.createAccount({
362
- account: {
363
- accountType: "caldav"
784
+ let account;
785
+ let calendars;
786
+ try {
787
+ if (credentials.provider === "google") {
788
+ account = buildGoogleDavAccount(credentials.username);
789
+ logForgeDebug(`[forge-google-oauth] google_caldav_account username=${JSON.stringify(credentials.username)} principalUrl=${JSON.stringify(account.principalUrl ?? null)} homeUrl=${JSON.stringify(account.homeUrl ?? null)}`);
790
+ calendars = await fetchGoogleCalendarList(credentials);
364
791
  }
365
- });
366
- const calendars = await client.fetchCalendars({ account });
792
+ else {
793
+ account = await client.createAccount({
794
+ account: {
795
+ accountType: "caldav"
796
+ }
797
+ });
798
+ calendars = await client.fetchCalendars({ account });
799
+ }
800
+ }
801
+ catch (error) {
802
+ if (credentials.provider === "google") {
803
+ throw mapGoogleRuntimeError(error);
804
+ }
805
+ throw error;
806
+ }
367
807
  return {
368
808
  mode: "dav",
369
809
  client,
810
+ credentials,
370
811
  account,
371
812
  calendars,
372
813
  accountLabel: credentials.username,
@@ -411,7 +852,7 @@ function mapDiscoveryPayload(provider, state) {
411
852
  color: calendar.calendarColor ?? FORGE_CALENDAR_COLOR,
412
853
  timezone: normalizeTimezone(calendar.timezone),
413
854
  isPrimary: false,
414
- canWrite: true,
855
+ canWrite: canWriteDavCalendar(calendar),
415
856
  selectedByDefault: !isForgeName(displayName),
416
857
  isForgeCandidate: isForgeName(displayName)
417
858
  };
@@ -594,10 +1035,227 @@ export async function completeMicrosoftCalendarOauth(input) {
594
1035
  }
595
1036
  return { session: toMicrosoftOauthSessionPayload(session), openerOrigin: session.origin };
596
1037
  }
1038
+ export async function startGoogleCalendarOauth(input, requestContext) {
1039
+ pruneGoogleOauthSessions();
1040
+ const { config, browserOrigin, openerOrigin, requestBaseOrigin } = ensureGoogleOauthStartAllowed(requestContext);
1041
+ const sessionId = randomUUID();
1042
+ const state = encodeBase64Url(randomBytes(32));
1043
+ const pkce = buildPkcePair();
1044
+ const params = new URLSearchParams({
1045
+ client_id: config.clientId,
1046
+ redirect_uri: config.redirectUri,
1047
+ response_type: "code",
1048
+ access_type: "offline",
1049
+ include_granted_scopes: "true",
1050
+ prompt: "consent",
1051
+ scope: GOOGLE_OAUTH_SCOPES.join(" "),
1052
+ state,
1053
+ code_challenge: pkce.challenge,
1054
+ code_challenge_method: "S256"
1055
+ });
1056
+ const authUrl = `${GOOGLE_OAUTH_AUTHORIZE_URL}?${params.toString()}`;
1057
+ const now = new Date();
1058
+ googleOauthSessions.set(sessionId, {
1059
+ sessionId,
1060
+ state,
1061
+ label: input.label?.trim() || null,
1062
+ browserOrigin,
1063
+ openerOrigin,
1064
+ requestBaseOrigin,
1065
+ redirectUri: config.redirectUri,
1066
+ appBaseUrl: config.appBaseUrl,
1067
+ clientId: config.clientId,
1068
+ codeVerifier: pkce.verifier,
1069
+ createdAt: now.toISOString(),
1070
+ expiresAt: new Date(now.getTime() + 15 * 60 * 1000).toISOString(),
1071
+ status: "pending",
1072
+ authUrl,
1073
+ accountLabel: null,
1074
+ error: null,
1075
+ discovery: null,
1076
+ credentials: null
1077
+ });
1078
+ googleOauthStates.set(state, sessionId);
1079
+ return {
1080
+ session: toGoogleOauthSessionPayload(googleOauthSessions.get(sessionId))
1081
+ };
1082
+ }
1083
+ export function getGoogleCalendarOauthSession(sessionId) {
1084
+ pruneGoogleOauthSessions();
1085
+ const session = googleOauthSessions.get(sessionId);
1086
+ if (!session) {
1087
+ throw new Error(`Unknown Google calendar auth session ${sessionId}`);
1088
+ }
1089
+ return {
1090
+ session: toGoogleOauthSessionPayload(session)
1091
+ };
1092
+ }
1093
+ export async function completeGoogleCalendarOauth(input) {
1094
+ pruneGoogleOauthSessions();
1095
+ const state = input.state?.trim() || "";
1096
+ const session = findGoogleOauthSessionByState(state);
1097
+ if (!session) {
1098
+ throw new Error("Google sign-in state is invalid or expired.");
1099
+ }
1100
+ const logGoogleOauthCompletion = () => {
1101
+ logForgeDebug(`[forge-google-oauth] callback_complete sessionId=${JSON.stringify(session.sessionId)} status=${JSON.stringify(session.status)} accountLabel=${JSON.stringify(session.accountLabel)} hasDiscovery=${JSON.stringify(Boolean(session.discovery))} error=${JSON.stringify(session.error)}`);
1102
+ };
1103
+ if (new Date(session.expiresAt).getTime() <= Date.now()) {
1104
+ session.status = "expired";
1105
+ session.error = "The Google sign-in session expired. Start the guided sign-in again.";
1106
+ finalizeGoogleOauthSession(session);
1107
+ logGoogleOauthCompletion();
1108
+ return { session: toGoogleOauthSessionPayload(session) };
1109
+ }
1110
+ if (input.error) {
1111
+ session.status = "error";
1112
+ session.error = explainGoogleOauthError({
1113
+ error: input.error,
1114
+ errorDescription: input.errorDescription,
1115
+ redirectUri: session.redirectUri,
1116
+ appBaseUrl: session.appBaseUrl
1117
+ });
1118
+ finalizeGoogleOauthSession(session);
1119
+ logGoogleOauthCompletion();
1120
+ return { session: toGoogleOauthSessionPayload(session), openerOrigin: session.openerOrigin };
1121
+ }
1122
+ if (!input.code) {
1123
+ session.status = "error";
1124
+ session.error = "Google did not return an authorization code.";
1125
+ finalizeGoogleOauthSession(session);
1126
+ logGoogleOauthCompletion();
1127
+ return { session: toGoogleOauthSessionPayload(session), openerOrigin: session.openerOrigin };
1128
+ }
1129
+ if (!session.codeVerifier) {
1130
+ session.status = "error";
1131
+ session.error =
1132
+ "The Google PKCE verifier is missing or expired for this sign-in session. Start the guided Google sign-in again.";
1133
+ finalizeGoogleOauthSession(session);
1134
+ logGoogleOauthCompletion();
1135
+ return {
1136
+ session: toGoogleOauthSessionPayload(session),
1137
+ openerOrigin: session.openerOrigin
1138
+ };
1139
+ }
1140
+ try {
1141
+ const config = resolveStoredGoogleOauthConfig();
1142
+ logForgeDebug(`[forge-google-oauth] code_exchange_start sessionId=${JSON.stringify(session.sessionId)} hasServerClientSecret=${JSON.stringify(Boolean(config.clientSecret))} redirectUri=${JSON.stringify(session.redirectUri)}`);
1143
+ const tokenRequestBody = new URLSearchParams({
1144
+ code: input.code,
1145
+ client_id: config.clientId,
1146
+ redirect_uri: session.redirectUri,
1147
+ grant_type: "authorization_code",
1148
+ code_verifier: session.codeVerifier
1149
+ });
1150
+ if (config.clientSecret) {
1151
+ tokenRequestBody.set("client_secret", config.clientSecret);
1152
+ }
1153
+ const tokenResponse = await fetch(GOOGLE_TOKEN_URL, {
1154
+ method: "POST",
1155
+ headers: {
1156
+ "Content-Type": "application/x-www-form-urlencoded",
1157
+ Accept: "application/json"
1158
+ },
1159
+ body: tokenRequestBody.toString()
1160
+ });
1161
+ const tokenPayload = (await tokenResponse.json());
1162
+ if (!tokenResponse.ok) {
1163
+ throw new Error(explainGoogleOauthError({
1164
+ error: typeof tokenPayload.error === "string" ? tokenPayload.error : null,
1165
+ errorDescription: typeof tokenPayload.error_description === "string"
1166
+ ? tokenPayload.error_description
1167
+ : null,
1168
+ redirectUri: session.redirectUri,
1169
+ appBaseUrl: session.appBaseUrl
1170
+ }));
1171
+ }
1172
+ const accessToken = typeof tokenPayload.access_token === "string"
1173
+ ? tokenPayload.access_token
1174
+ : "";
1175
+ const refreshToken = typeof tokenPayload.refresh_token === "string"
1176
+ ? tokenPayload.refresh_token
1177
+ : "";
1178
+ if (!accessToken) {
1179
+ throw new Error("Google sign-in completed without an access token.");
1180
+ }
1181
+ if (!refreshToken) {
1182
+ throw new Error("Google did not return a refresh token. Revoke the existing Forge access for this Google account or retry consent so Forge can keep syncing in the background.");
1183
+ }
1184
+ const scopeText = typeof tokenPayload.scope === "string" ? tokenPayload.scope : "";
1185
+ const grantedScopes = scopeText
1186
+ .split(/\s+/)
1187
+ .map((entry) => entry.trim())
1188
+ .filter((entry) => entry.length > 0);
1189
+ const expiresInSeconds = typeof tokenPayload.expires_in === "number"
1190
+ ? tokenPayload.expires_in
1191
+ : Number(tokenPayload.expires_in ?? 0);
1192
+ const accessTokenExpiresAt = Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
1193
+ ? new Date(Date.now() + expiresInSeconds * 1000).toISOString()
1194
+ : null;
1195
+ const profileResponse = await fetch(GOOGLE_USERINFO_URL, {
1196
+ headers: {
1197
+ Authorization: `Bearer ${accessToken}`,
1198
+ Accept: "application/json"
1199
+ }
1200
+ });
1201
+ const profilePayload = (await profileResponse.json());
1202
+ if (!profileResponse.ok) {
1203
+ throw new Error("Google sign-in completed, but Forge could not read the account profile.");
1204
+ }
1205
+ const email = typeof profilePayload.email === "string" && profilePayload.email.trim().length > 0
1206
+ ? profilePayload.email.trim()
1207
+ : "";
1208
+ if (!email) {
1209
+ throw new Error("Google sign-in completed without an account email.");
1210
+ }
1211
+ const provisionalCredentials = {
1212
+ provider: "google",
1213
+ serverUrl: GOOGLE_CALDAV_URL,
1214
+ username: email,
1215
+ refreshToken,
1216
+ accessTokenExpiresAt,
1217
+ grantedScopes
1218
+ };
1219
+ const state = await createProviderClient(provisionalCredentials);
1220
+ const discovery = mapDiscoveryPayload("google", state);
1221
+ session.status = "authorized";
1222
+ session.accountLabel = discovery.accountLabel;
1223
+ session.discovery = discovery;
1224
+ session.credentials = provisionalCredentials;
1225
+ session.error = null;
1226
+ finalizeGoogleOauthSession(session);
1227
+ }
1228
+ catch (error) {
1229
+ session.status = "error";
1230
+ session.error =
1231
+ error instanceof Error ? error.message : "Google sign-in failed.";
1232
+ finalizeGoogleOauthSession(session);
1233
+ }
1234
+ logGoogleOauthCompletion();
1235
+ return {
1236
+ session: toGoogleOauthSessionPayload(session),
1237
+ openerOrigin: session.openerOrigin
1238
+ };
1239
+ }
597
1240
  async function ensureForgeCalendar(state) {
598
1241
  if (state.mode !== "dav") {
599
1242
  throw new Error("This calendar provider is read-only, so Forge cannot create a dedicated write calendar there.");
600
1243
  }
1244
+ if (state.credentials.provider === "google") {
1245
+ const existingForge = state.calendars.find((calendar) => isForgeName(safeDisplayName(calendar.displayName, "")));
1246
+ if (existingForge) {
1247
+ return {
1248
+ forgeCalendarUrl: normalizeUrl(existingForge.url),
1249
+ calendars: state.calendars
1250
+ };
1251
+ }
1252
+ const forgeCalendarUrl = await createGoogleForgeCalendar(state.credentials);
1253
+ const calendars = await fetchGoogleCalendarList(state.credentials);
1254
+ return {
1255
+ forgeCalendarUrl,
1256
+ calendars
1257
+ };
1258
+ }
601
1259
  const existingForge = state.calendars.find((calendar) => isForgeName(safeDisplayName(calendar.displayName, "")));
602
1260
  if (existingForge) {
603
1261
  return {
@@ -701,7 +1359,7 @@ function mapCalendarRecord(calendar, options) {
701
1359
  color: calendar.calendarColor ?? FORGE_CALENDAR_COLOR,
702
1360
  timezone: normalizeTimezone(calendar.timezone),
703
1361
  isPrimary: false,
704
- canWrite: true,
1362
+ canWrite: canWriteDavCalendar(calendar),
705
1363
  selectedForSync: forgeCalendarUrl ? remoteUrl !== forgeCalendarUrl : true,
706
1364
  forgeManaged: forgeCalendarUrl ? remoteUrl === forgeCalendarUrl : false
707
1365
  };
@@ -805,7 +1463,14 @@ async function syncDiscoveredState(connectionId, credentials) {
805
1463
  const forgeCalendarUrl = normalizeUrl(credentials.forgeCalendarUrl);
806
1464
  const calendarsToSync = state.calendars.filter((calendar) => {
807
1465
  const normalized = normalizeUrl(calendar.url);
808
- return selected.has(normalized) || normalized === forgeCalendarUrl;
1466
+ if (selected.has(normalized)) {
1467
+ return true;
1468
+ }
1469
+ if (credentials.provider === "google" && normalized === forgeCalendarUrl) {
1470
+ logForgeDebug(`[forge-calendar-sync] skip_forge_readback connectionId=${JSON.stringify(connectionId)} provider=${JSON.stringify(credentials.provider)} calendarUrl=${JSON.stringify(normalized)}`);
1471
+ return false;
1472
+ }
1473
+ return normalized === forgeCalendarUrl;
809
1474
  });
810
1475
  for (const calendar of state.calendars) {
811
1476
  const normalized = normalizeUrl(calendar.url);
@@ -819,19 +1484,34 @@ async function syncDiscoveredState(connectionId, credentials) {
819
1484
  const end = new Date(now.getTime() + 180 * 24 * 60 * 60 * 1000).toISOString();
820
1485
  for (const calendar of calendarsToSync) {
821
1486
  const ownership = normalizeUrl(calendar.url) === forgeCalendarUrl ? "forge" : "external";
822
- const objects = await state.client.fetchCalendarObjects({
823
- calendar,
824
- timeRange: {
1487
+ const calendarUrl = normalizeUrl(calendar.url);
1488
+ logForgeDebug(`[forge-calendar-sync] fetch_calendar_objects_start connectionId=${JSON.stringify(connectionId)} provider=${JSON.stringify(credentials.provider)} calendarUrl=${JSON.stringify(calendarUrl)} ownership=${JSON.stringify(ownership)}`);
1489
+ if (credentials.provider === "google") {
1490
+ const events = await fetchGoogleCalendarEvents(credentials, calendarUrl, {
825
1491
  start,
826
1492
  end
827
- }
828
- });
829
- for (const object of objects) {
830
- const mapped = mapDavObjectToEvents(normalizeUrl(calendar.url), object, ownership);
831
- for (const event of mapped) {
1493
+ });
1494
+ logForgeDebug(`[forge-calendar-sync] fetch_calendar_objects_complete connectionId=${JSON.stringify(connectionId)} provider=${JSON.stringify(credentials.provider)} calendarUrl=${JSON.stringify(calendarUrl)} objectCount=${JSON.stringify(events.length)}`);
1495
+ for (const event of events) {
832
1496
  upsertCalendarEventRecord(connectionId, event);
833
1497
  }
834
1498
  }
1499
+ else {
1500
+ const objects = await state.client.fetchCalendarObjects({
1501
+ calendar,
1502
+ timeRange: {
1503
+ start,
1504
+ end
1505
+ }
1506
+ });
1507
+ logForgeDebug(`[forge-calendar-sync] fetch_calendar_objects_complete connectionId=${JSON.stringify(connectionId)} provider=${JSON.stringify(credentials.provider)} calendarUrl=${JSON.stringify(calendarUrl)} objectCount=${JSON.stringify(objects.length)}`);
1508
+ for (const object of objects) {
1509
+ const mapped = mapDavObjectToEvents(normalizeUrl(calendar.url), object, ownership);
1510
+ for (const event of mapped) {
1511
+ upsertCalendarEventRecord(connectionId, event);
1512
+ }
1513
+ }
1514
+ }
835
1515
  }
836
1516
  return {
837
1517
  state,
@@ -839,20 +1519,8 @@ async function syncDiscoveredState(connectionId, credentials) {
839
1519
  };
840
1520
  }
841
1521
  function toStoredCredentials(input, forgeCalendarUrl) {
842
- if (input.provider === "microsoft") {
843
- throw new Error("Exchange Online connections must be created from a completed Microsoft sign-in session.");
844
- }
845
- if (input.provider === "google") {
846
- return {
847
- provider: "google",
848
- serverUrl: GOOGLE_CALDAV_URL,
849
- username: input.username,
850
- clientId: input.clientId,
851
- clientSecret: input.clientSecret,
852
- refreshToken: input.refreshToken,
853
- selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
854
- forgeCalendarUrl: normalizeUrl(forgeCalendarUrl)
855
- };
1522
+ if (input.provider === "microsoft" || input.provider === "google") {
1523
+ throw new Error(`${input.provider === "google" ? "Google Calendar" : "Exchange Online"} connections must be created from a completed OAuth sign-in session.`);
856
1524
  }
857
1525
  if (input.provider === "apple") {
858
1526
  return {
@@ -902,18 +1570,8 @@ function findExistingCalendarConnection(incoming, secrets) {
902
1570
  });
903
1571
  }
904
1572
  function toDiscoveryCredentials(input) {
905
- if (input.provider === "microsoft") {
906
- throw new Error("Exchange Online discovery now uses the guided Microsoft sign-in flow.");
907
- }
908
- if (input.provider === "google") {
909
- return {
910
- provider: "google",
911
- serverUrl: GOOGLE_CALDAV_URL,
912
- username: input.username,
913
- clientId: input.clientId,
914
- clientSecret: input.clientSecret,
915
- refreshToken: input.refreshToken
916
- };
1573
+ if (input.provider === "microsoft" || input.provider === "google") {
1574
+ throw new Error(`${input.provider === "google" ? "Google Calendar" : "Exchange Online"} discovery now uses the guided OAuth sign-in flow.`);
917
1575
  }
918
1576
  if (input.provider === "apple") {
919
1577
  return {
@@ -944,6 +1602,65 @@ export async function discoverExistingCalendarConnection(connectionId, secrets)
944
1602
  return mapDiscoveryPayload(connection.provider, state);
945
1603
  }
946
1604
  export async function createCalendarConnection(input, secrets, activity = { source: "ui" }) {
1605
+ if (input.provider === "google") {
1606
+ pruneGoogleOauthSessions();
1607
+ const session = googleOauthSessions.get(input.authSessionId);
1608
+ if (!session || session.status !== "authorized" || !session.discovery || !session.credentials) {
1609
+ throw new Error("Complete the Google sign-in flow before saving this Google Calendar connection.");
1610
+ }
1611
+ const existingConnection = listCalendarConnections().find((connection) => {
1612
+ try {
1613
+ const existing = requireSecretRecord(secrets, connection.credentialsSecretId);
1614
+ return (existing.provider === "google" &&
1615
+ normalizeAccountIdentity(existing.username) ===
1616
+ normalizeAccountIdentity(session.credentials?.username ?? ""));
1617
+ }
1618
+ catch {
1619
+ return false;
1620
+ }
1621
+ });
1622
+ if (existingConnection) {
1623
+ throw new CalendarConnectionConflictError(`${existingConnection.label} is already connected for ${existingConnection.accountLabel || "this account"}. Remove it first if you want to reconnect with different settings.`, existingConnection.id);
1624
+ }
1625
+ const state = await createProviderClient(session.credentials);
1626
+ if (state.mode !== "dav") {
1627
+ throw new Error("Forge expected a writable DAV provider state for this Google Calendar connection.");
1628
+ }
1629
+ let forgeCalendarUrl = input.forgeCalendarUrl?.trim() ||
1630
+ session.discovery.calendars.find((calendar) => calendar.isForgeCandidate)?.url ||
1631
+ null;
1632
+ if (!forgeCalendarUrl && input.createForgeCalendar) {
1633
+ const created = await ensureForgeCalendar(state);
1634
+ forgeCalendarUrl = created.forgeCalendarUrl;
1635
+ }
1636
+ if (!forgeCalendarUrl) {
1637
+ throw new Error("Select the calendar Forge should write into, or create a new calendar named Forge.");
1638
+ }
1639
+ const secretId = `calendar_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1640
+ const storedCredentials = {
1641
+ ...session.credentials,
1642
+ selectedCalendarUrls: input.selectedCalendarUrls.map(normalizeUrl),
1643
+ forgeCalendarUrl: normalizeUrl(forgeCalendarUrl)
1644
+ };
1645
+ // The app credentials belong to Forge itself. Only user-specific OAuth tokens
1646
+ // are stored per calendar connection.
1647
+ storeEncryptedSecret(secretId, secrets.sealJson(storedCredentials), `${input.label} google calendar credentials`);
1648
+ const connection = createCalendarConnectionRecord({
1649
+ provider: "google",
1650
+ label: input.label,
1651
+ accountLabel: session.accountLabel ?? session.discovery.accountLabel,
1652
+ config: {
1653
+ serverUrl: session.discovery.serverUrl,
1654
+ selectedCalendarCount: storedCredentials.selectedCalendarUrls.length,
1655
+ forgeCalendarUrl: normalizeUrl(storedCredentials.forgeCalendarUrl)
1656
+ },
1657
+ credentialsSecretId: secretId
1658
+ });
1659
+ await syncCalendarConnection(connection.id, secrets, activity);
1660
+ session.status = "consumed";
1661
+ recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, "Google Calendar is now connected to Forge through the Authorization Code + PKCE flow.", activity, { provider: input.provider });
1662
+ return getCalendarConnectionById(connection.id);
1663
+ }
947
1664
  if (input.provider === "microsoft") {
948
1665
  pruneMicrosoftOauthSessions();
949
1666
  const session = microsoftOauthSessions.get(input.authSessionId);
@@ -1024,7 +1741,7 @@ export async function createCalendarConnection(input, secrets, activity = { sour
1024
1741
  credentialsSecretId: secretId
1025
1742
  });
1026
1743
  await syncCalendarConnection(connection.id, secrets, activity);
1027
- recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, `${input.provider === "apple" ? "Apple Calendar" : input.provider === "google" ? "Google Calendar" : "Custom CalDAV"} is now connected to Forge.`, activity, { provider: input.provider });
1744
+ recordCalendarActivity("calendar_connection_created", "calendar_connection", connection.id, `Calendar connection created: ${connection.label}`, `${input.provider === "apple" ? "Apple Calendar" : "Custom CalDAV"} is now connected to Forge.`, activity, { provider: input.provider });
1028
1745
  return getCalendarConnectionById(connection.id);
1029
1746
  }
1030
1747
  export async function removeCalendarConnection(connectionId, secrets, activity = { source: "ui" }) {
@@ -1274,7 +1991,7 @@ export function listCalendarProviderMetadata() {
1274
1991
  provider: "google",
1275
1992
  label: "Google Calendar",
1276
1993
  supportsDedicatedForgeCalendar: true,
1277
- connectionHelp: "Use your Google email, client credentials, and refresh token. Forge discovers calendars and can create or reuse a Forge calendar automatically."
1994
+ connectionHelp: "Forge uses a localhost Authorization Code + PKCE flow. Users sign in with Google from the same machine running Forge, Forge exchanges the code on the backend, and stores a per-user refresh token server-side."
1278
1995
  },
1279
1996
  {
1280
1997
  provider: "apple",