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.
- package/README.md +59 -3
- package/dist/assets/{board-VmF4FAfr.js → board-C6jCchjI.js} +3 -3
- package/dist/assets/{board-VmF4FAfr.js.map → board-C6jCchjI.js.map} +1 -1
- package/dist/assets/index-DVvS8iiU.css +1 -0
- package/dist/assets/index-zYB-9Dfo.js +85 -0
- package/dist/assets/index-zYB-9Dfo.js.map +1 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
- package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
- package/dist/assets/{motion-DvkU14p-.js → motion-DFHrH2rd.js} +2 -2
- package/dist/assets/{motion-DvkU14p-.js.map → motion-DFHrH2rd.js.map} +1 -1
- package/dist/assets/{table-DgiPof9E.js → table-ZL7Di_u3.js} +2 -2
- package/dist/assets/{table-DgiPof9E.js.map → table-ZL7Di_u3.js.map} +1 -1
- package/dist/assets/{ui-nYfoC0Gq.js → ui-CKNPpz7q.js} +2 -2
- package/dist/assets/{ui-nYfoC0Gq.js.map → ui-CKNPpz7q.js.map} +1 -1
- package/dist/assets/vendor-DoNZuFhn.js +1247 -0
- package/dist/assets/vendor-DoNZuFhn.js.map +1 -0
- package/dist/index.html +7 -8
- package/dist/openclaw/local-runtime.d.ts +3 -1
- package/dist/openclaw/local-runtime.js +67 -15
- package/dist/openclaw/plugin-entry-shared.js +24 -2
- package/dist/openclaw/plugin-sdk-types.d.ts +17 -0
- package/dist/openclaw/routes.d.ts +27 -0
- package/dist/openclaw/routes.js +16 -12
- package/dist/openclaw/tools.js +0 -3
- package/dist/server/server/migrations/001_core.sql +411 -0
- package/dist/server/server/migrations/002_psyche.sql +392 -0
- package/dist/server/server/migrations/003_habits.sql +30 -0
- package/dist/server/server/migrations/004_habit_links.sql +8 -0
- package/dist/server/server/migrations/005_habit_psyche_links.sql +24 -0
- package/dist/server/server/migrations/006_work_adjustments.sql +14 -0
- package/dist/server/server/migrations/007_weekly_review_closures.sql +17 -0
- package/dist/server/server/migrations/008_calendar_execution.sql +147 -0
- package/dist/server/server/migrations/009_true_calendar_events.sql +195 -0
- package/dist/server/server/migrations/010_calendar_selection_state.sql +6 -0
- package/dist/server/server/migrations/011_calendar_timezone_backfill.sql +11 -0
- package/dist/server/server/migrations/012_work_block_ranges.sql +7 -0
- package/dist/server/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
- package/dist/server/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
- package/dist/server/server/migrations/015_multi_user_and_strategies.sql +244 -0
- package/dist/server/server/migrations/016_health_companion.sql +158 -0
- package/dist/server/server/migrations/016_strategy_contracts_and_user_graph.sql +22 -0
- package/dist/server/server/migrations/017_preferences.sql +131 -0
- package/dist/server/server/migrations/018_preference_catalogs.sql +31 -0
- package/dist/server/server/migrations/019_wiki_memory.sql +255 -0
- package/dist/server/server/migrations/020_wiki_page_hierarchy.sql +11 -0
- package/dist/server/server/migrations/021_hide_evidence_from_wiki_index.sql +3 -0
- package/dist/server/server/migrations/022_wiki_ingest_background.sql +85 -0
- package/dist/server/server/migrations/023_diagnostic_logs.sql +28 -0
- package/dist/server/server/migrations/024_questionnaires.sql +96 -0
- package/dist/server/server/migrations/025_ai_model_connections.sql +26 -0
- package/dist/server/server/migrations/026_custom_theme_settings.sql +2 -0
- package/dist/server/server/migrations/027_ai_processors.sql +31 -0
- package/dist/server/server/migrations/028_movement_domain.sql +136 -0
- package/dist/server/server/migrations/029_watch_micro_capture.sql +23 -0
- package/dist/server/server/migrations/030_surface_layouts.sql +5 -0
- package/dist/server/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
- package/dist/server/server/migrations/032_ai_connectors.sql +44 -0
- package/dist/server/server/migrations/033_movement_trip_point_sync.sql +36 -0
- package/dist/server/server/migrations/034_movement_segment_sync.sql +49 -0
- package/dist/server/server/migrations/035_google_local_auth_settings.sql +2 -0
- package/dist/server/server/migrations/036_google_local_auth_client_secret.sql +2 -0
- package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
- package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
- package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
- package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
- package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/dist/server/{app.js → server/src/app.js} +2112 -414
- package/dist/server/server/src/connectors/box-registry.js +223 -0
- package/dist/server/server/src/data-management-types.js +107 -0
- package/dist/server/{db.js → server/src/db.js} +72 -4
- package/dist/server/server/src/debug.js +19 -0
- package/dist/server/{demo-data.js → server/src/demo-data.js} +2 -2
- package/dist/server/{health.js → server/src/health.js} +702 -18
- package/dist/server/{managers → server/src/managers}/platform/llm-manager.js +7 -4
- package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
- package/dist/server/{managers → server/src/managers}/platform/secrets-manager.js +18 -1
- package/dist/server/{managers → server/src/managers}/runtime.js +9 -0
- package/dist/server/{movement.js → server/src/movement.js} +1971 -112
- package/dist/server/{openapi.js → server/src/openapi.js} +491 -3
- package/dist/server/{psyche-types.js → server/src/psyche-types.js} +9 -1
- package/dist/server/{repositories → server/src/repositories}/activity-events.js +8 -0
- package/dist/server/{repositories → server/src/repositories}/ai-connectors.js +758 -47
- package/dist/server/{repositories → server/src/repositories}/calendar.js +1 -1
- package/dist/server/{repositories → server/src/repositories}/habits.js +37 -1
- package/dist/server/{repositories → server/src/repositories}/model-settings.js +13 -3
- package/dist/server/{repositories → server/src/repositories}/notes.js +3 -0
- package/dist/server/{repositories → server/src/repositories}/settings.js +431 -21
- package/dist/server/{repositories → server/src/repositories}/tasks.js +170 -10
- package/dist/server/server/src/runtime-data-root.js +82 -0
- package/dist/server/server/src/screen-time.js +802 -0
- package/dist/server/{services → server/src/services}/calendar-runtime.js +775 -58
- package/dist/server/server/src/services/data-management.js +788 -0
- package/dist/server/{services → server/src/services}/entity-crud.js +205 -2
- package/dist/server/server/src/services/google-calendar-oauth-config.js +176 -0
- package/dist/server/server/src/services/knowledge-graph.js +1455 -0
- package/dist/server/server/src/services/life-force-model.js +197 -0
- package/dist/server/server/src/services/life-force.js +1270 -0
- package/dist/server/server/src/services/psyche-observation-calendar.js +413 -0
- package/dist/server/{types.js → server/src/types.js} +420 -29
- package/dist/server/server/src/web.js +332 -0
- package/dist/server/src/components/customization/utility-widgets.js +439 -0
- package/dist/server/src/components/ui/info-tooltip.js +25 -0
- package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/health/health-boxes.js +147 -0
- package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
- package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +136 -0
- package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +47 -0
- package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +132 -0
- package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
- package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
- package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
- package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
- package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +6 -0
- package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +49 -0
- package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
- package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
- package/dist/server/src/components/workbench-boxes/today/today-boxes.js +78 -0
- package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
- package/dist/server/src/lib/api-error.js +37 -0
- package/dist/server/src/lib/api.js +2118 -0
- package/dist/server/src/lib/calendar-name-deduper.js +144 -0
- package/dist/server/src/lib/data-management-types.js +1 -0
- package/dist/server/src/lib/diagnostics.js +67 -0
- package/dist/server/src/lib/entity-visuals.js +279 -0
- package/dist/server/src/lib/knowledge-graph-types.js +276 -0
- package/dist/server/src/lib/knowledge-graph.js +470 -0
- package/dist/server/src/lib/psyche-types.js +1 -0
- package/dist/server/src/lib/questionnaire-types.js +1 -0
- package/dist/server/src/lib/runtime-paths.js +24 -0
- package/dist/server/src/lib/schemas.js +238 -0
- package/dist/server/src/lib/snapshot-normalizer.js +416 -0
- package/dist/server/src/lib/theme-system.js +319 -0
- package/dist/server/src/lib/types.js +1 -0
- package/dist/server/src/lib/utils.js +22 -0
- package/dist/server/src/lib/workbench/boxes.js +16 -0
- package/dist/server/src/lib/workbench/contracts.js +229 -0
- package/dist/server/src/lib/workbench/nodes.js +215 -0
- package/dist/server/src/lib/workbench/registry.js +120 -0
- package/dist/server/src/lib/workbench/runtime.js +397 -0
- package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/index.js +68 -0
- package/server/migrations/035_google_local_auth_settings.sql +2 -0
- package/server/migrations/036_google_local_auth_client_secret.sql +2 -0
- package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
- package/server/migrations/038_data_management_settings.sql +11 -0
- package/server/migrations/039_life_force_and_action_points.sql +114 -0
- package/server/migrations/040_screen_time_domain.sql +89 -0
- package/server/migrations/041_companion_source_states.sql +21 -0
- package/server/migrations/042_movement_boxes.sql +47 -0
- package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
- package/skills/forge-openclaw/SKILL.md +27 -11
- package/skills/forge-openclaw/entity_conversation_playbooks.md +411 -46
- package/skills/forge-openclaw/psyche_entity_playbooks.md +195 -20
- package/dist/assets/index-CFCKDIMH.js +0 -67
- package/dist/assets/index-CFCKDIMH.js.map +0 -1
- package/dist/assets/index-ZPY6U1TU.css +0 -1
- package/dist/assets/vendor-D9PTEPSB.js +0 -824
- package/dist/assets/vendor-D9PTEPSB.js.map +0 -1
- package/dist/assets/viz-Cqb6s--o.js +0 -34
- package/dist/assets/viz-Cqb6s--o.js.map +0 -1
- package/dist/server/connectors/box-registry.js +0 -257
- package/dist/server/services/psyche-observation-calendar.js +0 -46
- package/dist/server/web.js +0 -98
- /package/dist/server/{discovery-advertiser.js → server/src/discovery-advertiser.js} +0 -0
- /package/dist/server/{e2e-server.js → server/src/e2e-server.js} +0 -0
- /package/dist/server/{errors.js → server/src/errors.js} +0 -0
- /package/dist/server/{index.js → server/src/index.js} +0 -0
- /package/dist/server/{managers → server/src/managers}/base.js +0 -0
- /package/dist/server/{managers → server/src/managers}/contracts.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/api-gateway-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/audit-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/authentication-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/authorization-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/background-job-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/configuration-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/database-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/event-bus-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/external-service-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/health-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/migration-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/openai-responses-provider.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/search-index-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/session-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/storage-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/token-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/transaction-manager.js +0 -0
- /package/dist/server/{managers → server/src/managers}/platform/trusted-network.js +0 -0
- /package/dist/server/{managers → server/src/managers}/type-guards.js +0 -0
- /package/dist/server/{preferences-seeds.js → server/src/preferences-seeds.js} +0 -0
- /package/dist/server/{preferences-types.js → server/src/preferences-types.js} +0 -0
- /package/dist/server/{questionnaire-flow.js → server/src/questionnaire-flow.js} +0 -0
- /package/dist/server/{questionnaire-seeds.js → server/src/questionnaire-seeds.js} +0 -0
- /package/dist/server/{questionnaire-types.js → server/src/questionnaire-types.js} +0 -0
- /package/dist/server/{repositories → server/src/repositories}/ai-processors.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/collaboration.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/deleted-entities.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/diagnostic-logs.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/domains.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/entity-ownership.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/event-log.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/goals.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/preferences.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/projects.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/psyche.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/questionnaires.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/rewards.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/strategies.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/surface-layouts.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/tags.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/task-runs.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/users.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/weekly-reviews.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/wiki-memory.js +0 -0
- /package/dist/server/{repositories → server/src/repositories}/work-adjustments.js +0 -0
- /package/dist/server/{seed-demo.js → server/src/seed-demo.js} +0 -0
- /package/dist/server/{services → server/src/services}/context.js +0 -0
- /package/dist/server/{services → server/src/services}/dashboard.js +0 -0
- /package/dist/server/{services → server/src/services}/gamification.js +0 -0
- /package/dist/server/{services → server/src/services}/insights.js +0 -0
- /package/dist/server/{services → server/src/services}/openai-codex-oauth.js +0 -0
- /package/dist/server/{services → server/src/services}/projects.js +0 -0
- /package/dist/server/{services → server/src/services}/psyche.js +0 -0
- /package/dist/server/{services → server/src/services}/relations.js +0 -0
- /package/dist/server/{services → server/src/services}/reviews.js +0 -0
- /package/dist/server/{services → server/src/services}/run-recovery.js +0 -0
- /package/dist/server/{services → server/src/services}/tagging.js +0 -0
- /package/dist/server/{services → server/src/services}/task-run-watchdog.js +0 -0
- /package/dist/server/{services → server/src/services}/work-time.js +0 -0
- /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
|
|
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
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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" :
|
|
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: "
|
|
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",
|