tide-commander 1.165.1 → 1.167.0

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 (138) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/{BossLogsModal-IqVqHwz-.js → BossLogsModal-CLmSG1H0.js} +1 -1
  3. package/dist/assets/BossSpawnModal-DZlB8KRy.js +1 -0
  4. package/dist/assets/ControlsModal-B1-STBnD.js +1 -0
  5. package/dist/assets/DockerLogsModal-CWnhsG_r.js +2 -0
  6. package/dist/assets/{EmbeddedEditor-BA9mX_Lg.js → EmbeddedEditor-5po-iz0Q.js} +1 -1
  7. package/dist/assets/{GmailOAuthSetup-7nj6aLot.js → GmailOAuthSetup-BB10r44e.js} +1 -1
  8. package/dist/assets/GoogleOAuthSetup-CW_Ib5kq.js +270 -0
  9. package/dist/assets/{IframeModal-DkkCx6nq.js → IframeModal-CPt5x5fr.js} +1 -1
  10. package/dist/assets/IntegrationsPanel-CO-5iHtn.js +2 -0
  11. package/dist/assets/{LogViewerModal-DKYB8dHo.js → LogViewerModal-DicJq6l-.js} +1 -1
  12. package/dist/assets/{MonitoringModal-DOKMIa5U.js → MonitoringModal-B3nwcYZn.js} +1 -1
  13. package/dist/assets/{PM2LogsModal-XlbT9uoY.js → PM2LogsModal-Viw6fBjN.js} +2 -2
  14. package/dist/assets/RestoreArchivedAreaModal-BVH9wxhk.js +1 -0
  15. package/dist/assets/Scene2DCanvas-D6TCimYq.js +1 -0
  16. package/dist/assets/SceneManager-BdJs75zx.js +104 -0
  17. package/dist/assets/{SkillsPanel-BKjpKM1N.js → SkillsPanel-D1WonIBN.js} +1 -1
  18. package/dist/assets/{SlackMultiInstanceSetup-C0k60ioO.js → SlackMultiInstanceSetup-DSTZ5Hry.js} +1 -1
  19. package/dist/assets/SpawnModal-DUpIkQ08.js +1 -0
  20. package/dist/assets/StatisticsModal-BoDgM_8E.js +1 -0
  21. package/dist/assets/SubordinateAssignmentModal-LysAkNGy.js +1 -0
  22. package/dist/assets/{TriggerManagerPanel-RWrID12k.js → TriggerManagerPanel-BHxmBRvy.js} +1 -1
  23. package/dist/assets/{WorkflowEditorPanel-Ccwm4H5t.js → WorkflowEditorPanel-BHayKmMV.js} +1 -1
  24. package/dist/assets/abnfDiagram-VRR7QNED-DBZ5EpAj.js +1 -0
  25. package/dist/assets/arc-UCe2q9lp.js +1 -0
  26. package/dist/assets/architectureDiagram-ZJ3FMSHR-D5zOdUA8.js +36 -0
  27. package/dist/assets/{area-logos-DnrrrkFh.js → area-logos-4nlUPBxf.js} +1 -1
  28. package/dist/assets/blockDiagram-677ZJIJ3-7S-qZlh0.js +132 -0
  29. package/dist/assets/c4Diagram-LMCZKHZV-CgLv5SCO.js +10 -0
  30. package/dist/assets/channel-C59jtig-.js +1 -0
  31. package/dist/assets/chunk-2Q5K7J3B-J08qsAGr.js +1 -0
  32. package/dist/assets/chunk-32BRIVSS-Xy5D5wu7.js +1 -0
  33. package/dist/assets/chunk-5VM5RSS4-DqhmRB6E.js +15 -0
  34. package/dist/assets/chunk-EX3LRPZG-QE89_WgA.js +231 -0
  35. package/dist/assets/chunk-JWPE2WC7-YSc_ua_g.js +1 -0
  36. package/dist/assets/chunk-MOJQB5TN-CcH7NM7I.js +88 -0
  37. package/dist/assets/chunk-RYQCIY6F-CndQTJJ1.js +1 -0
  38. package/dist/assets/chunk-V7JOEXUC-Dauixo5p.js +206 -0
  39. package/dist/assets/chunk-VR4S4FIN-BBU6oFw0.js +1 -0
  40. package/dist/assets/chunk-XXDRQBXY-DXMolb4D.js +1 -0
  41. package/dist/assets/classDiagram-OUVF2IWQ-DHvVmqF9.js +1 -0
  42. package/dist/assets/classDiagram-v2-EOCWNBFH-DHvVmqF9.js +1 -0
  43. package/dist/assets/cose-bilkent-JH36ORCC-Dd9IB924.js +1 -0
  44. package/dist/assets/cynefin-VYW2F7L2-BQxwdSSb.js +178 -0
  45. package/dist/assets/cynefinDiagram-TSTJHNR4-BLnxgd0m.js +62 -0
  46. package/dist/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  47. package/dist/assets/dagre-VKFMJZFB-BZ7fXVWR.js +4 -0
  48. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  49. package/dist/assets/diagram-FQU43EPY-BFQ1xjHE.js +3 -0
  50. package/dist/assets/diagram-G47NLZAW-sXsGErM6.js +24 -0
  51. package/dist/assets/diagram-NH7WQ7WH-D6SPifDp.js +24 -0
  52. package/dist/assets/diagram-OA4YK3LP-Mu16f_2g.js +30 -0
  53. package/dist/assets/diagram-WEI45ONY-3BTJg43I.js +41 -0
  54. package/dist/assets/ebnfDiagram-CCIWWBDH-BGl2hs1X.js +1 -0
  55. package/dist/assets/erDiagram-Q63AITRT-D03B1rgc.js +85 -0
  56. package/dist/assets/flowDiagram-23GEKE2U-BCaZJGGE.js +156 -0
  57. package/dist/assets/ganttDiagram-NO4QXBWP-DrTSQR9G.js +292 -0
  58. package/dist/assets/gitGraphDiagram-IHSO6WYX-BvrZLcji.js +106 -0
  59. package/dist/assets/graph-C9eacEi8.js +1 -0
  60. package/dist/assets/index-B7l8Nsf8.js +1 -0
  61. package/dist/assets/index-BK7LL53p.js +1 -0
  62. package/dist/assets/index-CJ6x10Zs.js +2 -0
  63. package/dist/assets/index-D6btqUwj.js +2 -0
  64. package/dist/assets/{index-D-MKKw7S.js → index-DAXUzeVz.js} +6 -6
  65. package/dist/assets/{index-Cjgy2CnG.js → index-DcD9tVKJ.js} +3 -3
  66. package/dist/assets/{index-DZ7i37eV.js → index-Dzup44Dy.js} +6 -6
  67. package/dist/assets/index-JEgWBCoB.js +1 -0
  68. package/dist/assets/index-J_xWyk3Q.js +1 -0
  69. package/dist/assets/infoDiagram-FWYZ7A6U-Bw9k-wkS.js +2 -0
  70. package/dist/assets/init-Gi6I4Gst.js +1 -0
  71. package/dist/assets/ishikawaDiagram-FXEZZL3T-C2oVKtw0.js +70 -0
  72. package/dist/assets/journeyDiagram-5HDEW3XC-pRpwSEa5.js +139 -0
  73. package/dist/assets/kanban-definition-HUTT4EX6-CYq_poR1.js +89 -0
  74. package/dist/assets/katex-C5jXJg4s.js +257 -0
  75. package/dist/assets/layout-DEXfKzaS.js +1 -0
  76. package/dist/assets/linear-DLT2RlFJ.js +1 -0
  77. package/dist/assets/{main-DIf9R6Jx.js → main-06kmV-SQ.js} +125 -125
  78. package/dist/assets/main-BzGnAbiA.css +1 -0
  79. package/dist/assets/map-Czzmt4hB.js +1 -0
  80. package/dist/assets/mermaid.core-DmOBz9SE.js +314 -0
  81. package/dist/assets/mindmap-definition-LN4V7U3C-DzucjpI_.js +96 -0
  82. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  83. package/dist/assets/pegDiagram-2B236MQR-CIf_bLf4.js +1 -0
  84. package/dist/assets/pieDiagram-ENE6RG2P-Dh9HXeSf.js +39 -0
  85. package/dist/assets/quadrantDiagram-ABIIQ3AL-B5hZRZ5w.js +7 -0
  86. package/dist/assets/railroadDiagram-RFXS5EU6-B1oWAO1u.js +1 -0
  87. package/dist/assets/requirementDiagram-TGXJPOKE-Cg7bjm4O.js +84 -0
  88. package/dist/assets/sankeyDiagram-HTMAVEWB-TLCrcbtk.js +40 -0
  89. package/dist/assets/sequenceDiagram-DBY2YBRQ-BD3b_psw.js +162 -0
  90. package/dist/assets/sizeCapture-X5ZJPWSS-D26nx1uQ.js +1 -0
  91. package/dist/assets/stateDiagram-2N3HPSRC-CIwRQUnK.js +1 -0
  92. package/dist/assets/stateDiagram-v2-6OUMAXLB-C5qcZ4o1.js +1 -0
  93. package/dist/assets/swimlanes-5IMT3BWC-DYbes851.js +2 -0
  94. package/dist/assets/swimlanesDiagram-G3AALYLV-Ke-NRRIa.js +8 -0
  95. package/dist/assets/timeline-definition-FHXFAJF6-Fr9U6JU6.js +120 -0
  96. package/dist/assets/{vendor-three-Chj50gSY.js → vendor-three-3OKhI9bz.js} +1 -1
  97. package/dist/assets/vennDiagram-L72KCM5P-Cns-KLq7.js +34 -0
  98. package/dist/assets/wardleyDiagram-EHGQE667-DHA7rD9Z.js +78 -0
  99. package/dist/assets/{web-DDxnPoTK.js → web-B7c8sthS.js} +1 -1
  100. package/dist/assets/{web-DhZ0XCan.js → web-C05qFIA-.js} +1 -1
  101. package/dist/assets/{web-Dlc8Nyra.js → web-Dja1kfLs.js} +1 -1
  102. package/dist/assets/xychartDiagram-FW5EYKEG-DCnL05Wq.js +7 -0
  103. package/dist/build-info.json +1 -1
  104. package/dist/index.html +3 -3
  105. package/dist/src/packages/server/claude/runner/watchdog.js +27 -0
  106. package/dist/src/packages/server/codex/app-server/codex-app-server-runner.js +34 -4
  107. package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
  108. package/dist/src/packages/server/data/builtin-skills/mermaid-diagrams.js +76 -0
  109. package/dist/src/packages/server/integrations/gmail/gmail-client.js +73 -5
  110. package/dist/src/packages/server/integrations/gmail/gmail-routes.js +9 -4
  111. package/dist/src/packages/server/integrations/google-auth/token-health.js +196 -0
  112. package/dist/src/packages/server/integrations/google-calendar/calendar-client.js +61 -6
  113. package/dist/src/packages/server/integrations/google-calendar/calendar-routes.js +9 -4
  114. package/dist/src/packages/server/integrations/google-drive/drive-client.js +61 -6
  115. package/dist/src/packages/server/integrations/google-drive/drive-routes.js +9 -4
  116. package/dist/src/packages/server/opencode/server/opencode-server-process.js +102 -7
  117. package/dist/src/packages/server/opencode/server/opencode-server-runner.js +13 -0
  118. package/dist/src/packages/server/services/agent-service.js +7 -0
  119. package/dist/src/packages/server/services/runtime-events.js +1 -0
  120. package/package.json +2 -1
  121. package/dist/assets/BossSpawnModal-Dj9ayWfR.js +0 -1
  122. package/dist/assets/ControlsModal-ki85jvDA.js +0 -1
  123. package/dist/assets/DockerLogsModal-CeIuKlvk.js +0 -2
  124. package/dist/assets/GoogleOAuthSetup-Bh-Cy-dE.js +0 -236
  125. package/dist/assets/IntegrationsPanel-DHjpWEFF.js +0 -2
  126. package/dist/assets/RestoreArchivedAreaModal-Du1f8Urv.js +0 -1
  127. package/dist/assets/Scene2DCanvas-BTnpyyK6.js +0 -1
  128. package/dist/assets/SceneManager-owoMSHKT.js +0 -104
  129. package/dist/assets/SpawnModal-Cww_6dcz.js +0 -1
  130. package/dist/assets/StatisticsModal-Dl5loLLI.js +0 -1
  131. package/dist/assets/SubordinateAssignmentModal-DefQL38f.js +0 -1
  132. package/dist/assets/index-B5iakYkB.js +0 -1
  133. package/dist/assets/index-CbJ-R04c.js +0 -2
  134. package/dist/assets/index-CcHBNDP_.js +0 -2
  135. package/dist/assets/index-DG7HgtQn.js +0 -1
  136. package/dist/assets/index-Fcs4x2Vj.js +0 -1
  137. package/dist/assets/index-g0yAcpoy.js +0 -1
  138. package/dist/assets/main-Dnp-3iMX.css +0 -1
@@ -0,0 +1,76 @@
1
+ export const mermaidDiagrams = {
2
+ slug: 'mermaid-diagrams',
3
+ name: 'Mermaid Diagrams',
4
+ description: 'Use whenever a picture communicates faster than prose — architecture, control/data flow, sequence of calls, state machine, data model, or timeline. Output a ```mermaid fenced block and Tide Commander renders it as a diagram inline in the chat.',
5
+ // Output-only skill: no tools needed, it changes HOW you present, not what you can do.
6
+ allowedTools: [],
7
+ // Available to every agent by default.
8
+ assignedAgentClasses: ['*'],
9
+ content: `# Mermaid Diagrams
10
+
11
+ Tide Commander renders **Mermaid** diagrams inline in the chat UI. When a visual would
12
+ land faster than a paragraph, emit a fenced \`mermaid\` code block — it shows up as a
13
+ rendered diagram, no tool call required.
14
+
15
+ ## When to reach for a diagram
16
+ - Multi-step flow / control flow / algorithm → **flowchart**
17
+ - Interactions or calls between services, agents, or components over time → **sequenceDiagram**
18
+ - Entities and their relationships (DB schema, domain model) → **erDiagram**
19
+ - States and transitions → **stateDiagram-v2**
20
+ - Project phases / schedule → **gantt**
21
+ - Class or module structure → **classDiagram**
22
+
23
+ Skip it when a single sentence already does the job — don't diagram the trivial.
24
+
25
+ ## How
26
+ Put valid Mermaid inside a fenced block tagged \`mermaid\`:
27
+
28
+ \`\`\`mermaid
29
+ flowchart TD
30
+ A[User request] --> B{Cached?}
31
+ B -- yes --> C[Serve from cache]
32
+ B -- no --> D[Query DB] --> E[Cache result] --> C
33
+ \`\`\`
34
+
35
+ If the syntax is invalid (or still streaming), the UI safely falls back to showing the
36
+ raw source instead of breaking — so only ship diagrams whose syntax you're confident is valid.
37
+
38
+ ## More examples
39
+
40
+ Sequence of calls:
41
+ \`\`\`mermaid
42
+ sequenceDiagram
43
+ participant U as User
44
+ participant API
45
+ participant DB
46
+ U->>API: POST /order
47
+ API->>DB: INSERT order
48
+ DB-->>API: ok (id=42)
49
+ API-->>U: 201 Created
50
+ \`\`\`
51
+
52
+ Data model:
53
+ \`\`\`mermaid
54
+ erDiagram
55
+ CUSTOMER ||--o{ ORDER : places
56
+ ORDER ||--|{ LINE_ITEM : contains
57
+ PRODUCT ||--o{ LINE_ITEM : "referenced by"
58
+ \`\`\`
59
+
60
+ State machine:
61
+ \`\`\`mermaid
62
+ stateDiagram-v2
63
+ [*] --> Idle
64
+ Idle --> Working: assign task
65
+ Working --> Idle: done
66
+ Working --> Error: fail
67
+ Error --> Idle: retry
68
+ \`\`\`
69
+
70
+ ## Tips
71
+ - Keep node/participant labels short; long text overflows — wrap or abbreviate.
72
+ - One diagram per fenced block.
73
+ - Special characters in labels (\`(\`, \`:\`, \`#\`, \`"\`) often need quoting — Mermaid is picky.
74
+ - Prefer the modern \`flowchart\` keyword over the legacy \`graph\`.
75
+ - Mentally validate the syntax before sending; a broken diagram just renders as code.`,
76
+ };
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { google } from 'googleapis';
6
6
  import * as fs from 'fs';
7
+ import { getTokenHealth, needsReauth, notifyTokenRotated, onTokenRotated, probeTokenHealth, reportGoogleApiError, reportGoogleApiSuccess, startTokenHealthMonitor, } from '../google-auth/token-health.js';
7
8
  // Gmail API scopes
8
9
  const SCOPES = [
9
10
  'https://www.googleapis.com/auth/gmail.send',
@@ -28,6 +29,40 @@ let lastPollAt;
28
29
  let lastError;
29
30
  let messageCallbacks = [];
30
31
  let lastHistoryId;
32
+ // ─── OAuth client ───
33
+ /**
34
+ * True when Gmail authenticates with the shared `GOOGLE_REFRESH_TOKEN` rather than a
35
+ * service account. Token health is a fact about that refresh token only — in
36
+ * service-account mode Gmail rides a JWT that a dead refresh token says nothing about,
37
+ * so every token-health branch below is gated on this.
38
+ */
39
+ function usingOAuth() {
40
+ return (config.authMethod || 'oauth2') !== 'service_account';
41
+ }
42
+ /**
43
+ * Build an OAuth2 client whose every outbound call updates shared token health.
44
+ *
45
+ * Every Gmail call made with `auth: oauth2Client` funnels through this one `request()`
46
+ * method, so wrapping it here means a revoked token is detected the moment polling or
47
+ * an agent actually touches Gmail — no waiting for the next background probe — without
48
+ * instrumenting each API call site.
49
+ */
50
+ function createOAuthClient(clientId, clientSecret, redirectUri) {
51
+ const client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
52
+ const originalRequest = client.request.bind(client);
53
+ client.request = (async (opts) => {
54
+ try {
55
+ const result = await originalRequest(opts);
56
+ reportGoogleApiSuccess();
57
+ return result;
58
+ }
59
+ catch (err) {
60
+ reportGoogleApiError(err);
61
+ throw err;
62
+ }
63
+ });
64
+ return client;
65
+ }
31
66
  // ─── Lifecycle ───
32
67
  export async function init(context) {
33
68
  ctx = context;
@@ -71,12 +106,19 @@ export async function init(context) {
71
106
  ctx.log.info('Gmail not configured (missing OAuth credentials)');
72
107
  return;
73
108
  }
74
- oauth2Client = new google.auth.OAuth2(config.clientId, config.clientSecret, getRedirectUri());
109
+ oauth2Client = createOAuthClient(config.clientId, config.clientSecret, getRedirectUri());
75
110
  // Load refresh token from secrets
76
111
  const refreshToken = ctx.secrets.get('GOOGLE_REFRESH_TOKEN') || config.refreshToken;
77
112
  if (refreshToken) {
78
113
  oauth2Client.setCredentials({ refresh_token: refreshToken });
79
114
  gmail = google.gmail({ version: 'v1', auth: oauth2Client });
115
+ // Holding the token string proves nothing — it may be revoked. Probe Google (and
116
+ // keep probing) so an expired token is reflected in getStatus(). The monitor is
117
+ // shared with Drive/Calendar: one timer, one verdict for the one refresh token.
118
+ startTokenHealthMonitor(ctx);
119
+ // Re-read the shared refresh token if the user re-consents via Drive or Calendar,
120
+ // otherwise this client keeps using the token it was seeded with here.
121
+ onTokenRotated('gmail', () => init(context));
80
122
  try {
81
123
  const profile = await gmail.users.getProfile({ userId: 'me' });
82
124
  authenticatedEmail = profile.data.emailAddress ?? undefined;
@@ -160,16 +202,35 @@ export function shutdown() {
160
202
  export function getStatus() {
161
203
  const oauthConfigured = Boolean(config.clientId && config.clientSecret);
162
204
  const serviceAccountConfigured = Boolean(config.serviceAccountJson && config.impersonateEmail);
205
+ // Token health only speaks for the OAuth path. Having a live `gmail` handle is
206
+ // necessary but not sufficient there: a revoked refresh token still builds a handle
207
+ // just fine. Only trust it when Google's last verdict wasn't a rejection —
208
+ // 'unreachable' (offline host) is deliberately NOT a rejection, so needsReauth()
209
+ // leaves us connected rather than pushing a pointless re-consent.
210
+ const oauthActive = usingOAuth() && oauthConfigured;
211
+ const health = getTokenHealth();
212
+ const reauth = oauthActive && needsReauth(health);
213
+ const live = Boolean(gmail && authenticatedEmail);
163
214
  return {
164
215
  configured: oauthConfigured || serviceAccountConfigured,
165
- authenticated: Boolean(gmail && authenticatedEmail),
216
+ authenticated: live && !reauth,
166
217
  emailAddress: authenticatedEmail,
167
218
  pollingActive: pollingTimer !== null,
168
- connected: Boolean(gmail && authenticatedEmail),
219
+ connected: live && !reauth,
169
220
  lastChecked: lastPollAt || Date.now(),
170
- error: lastError,
221
+ error: (oauthActive ? health.error : undefined) ?? lastError,
222
+ needsReauth: reauth,
223
+ tokenState: oauthActive ? health.state : undefined,
171
224
  };
172
225
  }
226
+ /** Force a live refresh_token exchange against Google and return the updated status. */
227
+ export async function probeToken() {
228
+ // Service-account mode doesn't use GOOGLE_REFRESH_TOKEN — probing would report on a
229
+ // token Gmail never touches, so skip straight to the (unaffected) status.
230
+ if (ctx && usingOAuth())
231
+ await probeTokenHealth(ctx, { force: true });
232
+ return getStatus();
233
+ }
173
234
  export function isConfigured() {
174
235
  const oauthConfigured = Boolean(config.clientId && config.clientSecret);
175
236
  const serviceAccountConfigured = Boolean(config.serviceAccountJson && config.impersonateEmail);
@@ -181,7 +242,7 @@ export function getAuthUrl() {
181
242
  if (!config.clientId || !config.clientSecret || !ctx) {
182
243
  throw new Error('Gmail OAuth not configured');
183
244
  }
184
- oauth2Client = new google.auth.OAuth2(config.clientId, config.clientSecret, getRedirectUri());
245
+ oauth2Client = createOAuthClient(config.clientId, config.clientSecret, getRedirectUri());
185
246
  }
186
247
  return oauth2Client.generateAuthUrl({
187
248
  access_type: 'offline',
@@ -194,15 +255,22 @@ export async function handleAuthCallback(code) {
194
255
  throw new Error('Gmail OAuth not initialized');
195
256
  const { tokens } = await oauth2Client.getToken(code);
196
257
  oauth2Client.setCredentials(tokens);
258
+ let rotated = false;
197
259
  if (tokens.refresh_token) {
198
260
  ctx.secrets.set('GOOGLE_REFRESH_TOKEN', tokens.refresh_token);
199
261
  config.refreshToken = tokens.refresh_token;
262
+ rotated = true;
200
263
  }
201
264
  gmail = google.gmail({ version: 'v1', auth: oauth2Client });
202
265
  const profile = await gmail.users.getProfile({ userId: 'me' });
203
266
  authenticatedEmail = profile.data.emailAddress ?? undefined;
204
267
  lastHistoryId = profile.data.historyId ?? undefined;
205
268
  lastError = undefined;
269
+ // Fresh consent just produced this token. Clears the 'expired' verdict AND re-arms
270
+ // Drive and Calendar, whose clients still hold the old refresh token — all three
271
+ // share this one secret, so one consent must repair all three.
272
+ if (rotated)
273
+ await notifyTokenRotated();
206
274
  ctx.log.info(`Gmail OAuth complete. Authenticated as ${authenticatedEmail}`);
207
275
  }
208
276
  // ─── Sending ───
@@ -126,10 +126,15 @@ router.post('/check-approvals', async (req, res) => {
126
126
  res.status(500).json({ error: `Failed to check approvals: ${err instanceof Error ? err.message : err}` });
127
127
  }
128
128
  });
129
- // GET /api/email/status — Get Gmail status
130
- router.get('/status', (req, res) => {
131
- const status = gmailClient.getStatus();
132
- res.json(status);
129
+ // GET /api/email/status — Get Gmail status.
130
+ // `?probe=1` forces a live refresh_token exchange against Google instead of reusing
131
+ // the cached verdict — the settings UI uses it to verify the token on open, so a
132
+ // revoked token can't keep rendering as "Connected". No-op in service-account mode.
133
+ router.get('/status', async (req, res) => {
134
+ if (req.query.probe === '1' || req.query.probe === 'true') {
135
+ await gmailClient.probeToken();
136
+ }
137
+ res.json(gmailClient.getStatus());
133
138
  });
134
139
  // GET /api/email/auth/url — Get OAuth authorization URL
135
140
  router.get('/auth/url', (req, res) => {
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Google OAuth token health.
3
+ *
4
+ * Gmail, Calendar, and Drive all authenticate with the SAME `GOOGLE_REFRESH_TOKEN`
5
+ * secret, so token liveness is a single shared fact — this module owns it.
6
+ *
7
+ * Why this exists: each client used to report `connected` from the mere presence of
8
+ * three non-empty secret strings plus a non-null API handle. Nothing ever validated
9
+ * the refresh token against Google and nothing invalidated the handle when Google
10
+ * rejected it, so a revoked or expired token still rendered as "Connected" — and a
11
+ * server restart didn't help, because init() only constructs an OAuth2 client from
12
+ * the token string without exchanging it.
13
+ *
14
+ * The probe below performs a real refresh_token -> access_token exchange, which is
15
+ * the only way to learn that a refresh token is dead.
16
+ */
17
+ import { google } from 'googleapis';
18
+ /** The token is definitively dead and only a fresh OAuth consent will fix it. */
19
+ export function needsReauth(health) {
20
+ return health.state === 'expired' || health.state === 'invalid_client';
21
+ }
22
+ // ─── State ───
23
+ let health = { state: 'unknown', checkedAt: 0 };
24
+ /** In-flight probe, so N concurrent status calls trigger at most one token exchange. */
25
+ let inFlight = null;
26
+ let monitorTimer = null;
27
+ /** Status endpoints are polled every ~3s by the settings UI; only re-probe this often. */
28
+ const PROBE_TTL_MS = 5 * 60_000;
29
+ /** Background re-probe, so a token that dies mid-session surfaces without user action. */
30
+ const MONITOR_INTERVAL_MS = 15 * 60_000;
31
+ export function getTokenHealth() {
32
+ return health;
33
+ }
34
+ // ─── Error classification ───
35
+ /**
36
+ * Map an error from a token exchange or Google API call onto a token state.
37
+ *
38
+ * Returns null when the error says nothing about token validity (a 404 on a file,
39
+ * a missing scope, a rate limit) — callers must leave the health state untouched
40
+ * rather than flapping the UI to "expired" on unrelated failures.
41
+ */
42
+ function classify(err) {
43
+ const e = err;
44
+ const oauthError = e?.response?.data?.error ?? '';
45
+ const description = e?.response?.data?.error_description ?? '';
46
+ const message = e?.message ?? String(err);
47
+ const haystack = `${oauthError} ${message}`.toLowerCase();
48
+ // The definitive signal: Google rejected the refresh token itself. Emitted when the
49
+ // token is revoked, expired (a "Testing"-mode OAuth consent screen expires refresh
50
+ // tokens after 7 days), or the user changed their password.
51
+ if (haystack.includes('invalid_grant')) {
52
+ return {
53
+ state: 'expired',
54
+ error: description || 'Google rejected the refresh token (invalid_grant). Re-authorize to reconnect.',
55
+ };
56
+ }
57
+ // Client ID/secret are wrong or the OAuth client was deleted. Re-consent won't help
58
+ // until the credentials are fixed, but it's still a hard auth failure worth surfacing.
59
+ if (haystack.includes('invalid_client') || haystack.includes('unauthorized_client')) {
60
+ return {
61
+ state: 'invalid_client',
62
+ error: description || 'Google rejected the OAuth client credentials (invalid_client).',
63
+ };
64
+ }
65
+ // Couldn't reach Google at all. Explicitly NOT an auth verdict: reporting "expired"
66
+ // because the host is offline would send users through a pointless re-consent flow.
67
+ const code = String(e?.code ?? '');
68
+ const status = e?.response?.status;
69
+ const networkish = ['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code) ||
70
+ (typeof status === 'number' && status >= 500);
71
+ if (networkish) {
72
+ return { state: 'unreachable', error: `Could not reach Google to verify the token (${code || status}).` };
73
+ }
74
+ return null;
75
+ }
76
+ /**
77
+ * Report an error thrown by a live Google API call (Drive list, Gmail send, ...).
78
+ *
79
+ * Lets a dead token surface immediately at the point of real use instead of waiting
80
+ * for the next background probe. Unrelated errors are ignored.
81
+ */
82
+ export function reportGoogleApiError(err) {
83
+ const verdict = classify(err);
84
+ if (!verdict)
85
+ return;
86
+ // Don't let a transient network blip overwrite a known-good/known-dead verdict.
87
+ if (verdict.state === 'unreachable' && health.state !== 'unknown')
88
+ return;
89
+ health = { ...verdict, checkedAt: Date.now() };
90
+ }
91
+ /** Record that a Google API call succeeded — the token is provably alive. */
92
+ export function reportGoogleApiSuccess() {
93
+ if (health.state === 'valid')
94
+ return;
95
+ health = { state: 'valid', checkedAt: Date.now() };
96
+ }
97
+ // ─── Probe ───
98
+ /**
99
+ * Exchange the stored refresh token for an access token to prove it still works.
100
+ *
101
+ * Uses a throwaway OAuth2 client seeded with ONLY the refresh token: with no cached
102
+ * access token, `getAccessToken()` is forced to hit Google's token endpoint rather
103
+ * than returning a still-valid cached access token, which is what makes this a real
104
+ * validation instead of a no-op.
105
+ */
106
+ export async function probeTokenHealth(ctx, opts = {}) {
107
+ const clientId = ctx.secrets.get('GOOGLE_CLIENT_ID');
108
+ const clientSecret = ctx.secrets.get('GOOGLE_CLIENT_SECRET');
109
+ const refreshToken = ctx.secrets.get('GOOGLE_REFRESH_TOKEN');
110
+ if (!clientId || !clientSecret || !refreshToken) {
111
+ health = { state: 'unknown', checkedAt: Date.now(), error: 'Missing OAuth credentials' };
112
+ return health;
113
+ }
114
+ const fresh = Date.now() - health.checkedAt < PROBE_TTL_MS;
115
+ if (!opts.force && fresh && health.state !== 'unknown')
116
+ return health;
117
+ if (inFlight)
118
+ return inFlight;
119
+ inFlight = (async () => {
120
+ try {
121
+ const client = new google.auth.OAuth2(clientId, clientSecret);
122
+ client.setCredentials({ refresh_token: refreshToken });
123
+ const { token } = await client.getAccessToken();
124
+ health = token
125
+ ? { state: 'valid', checkedAt: Date.now() }
126
+ : {
127
+ state: 'expired',
128
+ checkedAt: Date.now(),
129
+ error: 'Google returned no access token for the stored refresh token.',
130
+ };
131
+ }
132
+ catch (err) {
133
+ const verdict = classify(err) ?? {
134
+ state: 'unreachable',
135
+ error: err instanceof Error ? err.message : 'Token probe failed',
136
+ };
137
+ health = { ...verdict, checkedAt: Date.now() };
138
+ ctx.log.warn(`[GoogleAuth] token probe: ${health.state}${health.error ? ` — ${health.error}` : ''}`);
139
+ }
140
+ return health;
141
+ })().finally(() => {
142
+ inFlight = null;
143
+ });
144
+ return inFlight;
145
+ }
146
+ /**
147
+ * Probe now, then re-probe periodically. Idempotent — the three Google plugins each
148
+ * call this during init() and share the single resulting timer.
149
+ */
150
+ export function startTokenHealthMonitor(ctx) {
151
+ void probeTokenHealth(ctx, { force: true });
152
+ if (monitorTimer)
153
+ return;
154
+ monitorTimer = setInterval(() => {
155
+ void probeTokenHealth(ctx, { force: true });
156
+ }, MONITOR_INTERVAL_MS);
157
+ // Don't hold the event loop open on shutdown.
158
+ monitorTimer.unref?.();
159
+ }
160
+ export function stopTokenHealthMonitor() {
161
+ if (monitorTimer) {
162
+ clearInterval(monitorTimer);
163
+ monitorTimer = null;
164
+ }
165
+ }
166
+ /** Called after a successful OAuth consent — the brand-new refresh token is good. */
167
+ export function markTokenFresh() {
168
+ health = { state: 'valid', checkedAt: Date.now() };
169
+ }
170
+ // ─── Token rotation ───
171
+ /**
172
+ * Re-init hooks, keyed by integration id so repeated init() calls just replace them.
173
+ *
174
+ * Consent granted through ONE integration writes a new `GOOGLE_REFRESH_TOKEN` that the
175
+ * other two never see: their OAuth2 clients keep the refresh token they were seeded
176
+ * with at init. When the reason for re-consenting was a revoked token, those clients
177
+ * are holding a dead string — so re-authorizing via Calendar would leave Drive broken
178
+ * while shared health reported 'valid'. Every client re-reads the secret on rotation.
179
+ */
180
+ const reinitHandlers = new Map();
181
+ export function onTokenRotated(integrationId, reinit) {
182
+ reinitHandlers.set(integrationId, reinit);
183
+ }
184
+ /** Announce a freshly minted refresh token: mark health good and re-arm every client. */
185
+ export async function notifyTokenRotated() {
186
+ markTokenFresh();
187
+ for (const reinit of reinitHandlers.values()) {
188
+ try {
189
+ await reinit();
190
+ }
191
+ catch {
192
+ // A sibling integration failing to re-init must not abort the OAuth callback
193
+ // for the one the user actually just authorized.
194
+ }
195
+ }
196
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { google } from 'googleapis';
8
8
  import { loadConfig, updateConfig } from './calendar-config.js';
9
+ import { getTokenHealth, needsReauth, notifyTokenRotated, onTokenRotated, probeTokenHealth, reportGoogleApiError, reportGoogleApiSuccess, startTokenHealthMonitor, } from '../google-auth/token-health.js';
9
10
  // ─── State ───
10
11
  let ctx = null;
11
12
  let calendarApi = null;
@@ -24,9 +25,15 @@ export async function init(integrationCtx) {
24
25
  ctx.log.info('Google Calendar missing OAuth credentials, skipping init');
25
26
  return;
26
27
  }
27
- oauth2Client = new google.auth.OAuth2(clientId, clientSecret, getRedirectUri());
28
+ oauth2Client = createOAuthClient(clientId, clientSecret, getRedirectUri());
28
29
  oauth2Client.setCredentials({ refresh_token: refreshToken });
29
30
  calendarApi = google.calendar({ version: 'v3', auth: oauth2Client });
31
+ // Constructing the client above proves nothing — it just wraps the token string.
32
+ // Probe Google (and keep probing) so an expired token is reflected in getStatus().
33
+ startTokenHealthMonitor(ctx);
34
+ // Re-read the shared refresh token if the user re-consents via Gmail or Drive,
35
+ // otherwise this client keeps using the token it was seeded with here.
36
+ onTokenRotated('google-calendar', () => init(integrationCtx));
30
37
  ctx.log.info('Google Calendar initialized');
31
38
  }
32
39
  export async function shutdown() {
@@ -39,16 +46,36 @@ export function getStatus() {
39
46
  const hasCredentials = !!(ctx?.secrets.get('GOOGLE_CLIENT_ID') &&
40
47
  ctx?.secrets.get('GOOGLE_CLIENT_SECRET') &&
41
48
  ctx?.secrets.get('GOOGLE_REFRESH_TOKEN'));
49
+ const health = getTokenHealth();
50
+ const reauth = hasCredentials && needsReauth(health);
51
+ // Having credentials on disk is necessary but not sufficient — a revoked refresh
52
+ // token is still three non-empty strings. Only report connected when Google's last
53
+ // verdict wasn't a rejection. An 'unreachable' probe (offline host) is deliberately
54
+ // NOT treated as a rejection: it would push users into a pointless re-consent flow.
55
+ const wired = config.enabled && hasCredentials && calendarApi !== null;
56
+ let error;
57
+ if (!hasCredentials && config.enabled)
58
+ error = 'Missing OAuth credentials';
59
+ else if (reauth)
60
+ error = health.error;
42
61
  return {
43
- authenticated: Boolean(calendarApi && hasCredentials),
44
- connected: config.enabled && hasCredentials && calendarApi !== null,
45
- lastChecked: Date.now(),
46
- error: !hasCredentials && config.enabled ? 'Missing OAuth credentials' : undefined,
62
+ authenticated: Boolean(calendarApi && hasCredentials) && !reauth,
63
+ connected: wired && !reauth,
64
+ lastChecked: health.checkedAt || Date.now(),
65
+ error,
66
+ needsReauth: reauth,
67
+ tokenState: hasCredentials ? health.state : undefined,
47
68
  };
48
69
  }
49
70
  export function isConfigured() {
50
71
  return calendarApi !== null;
51
72
  }
73
+ /** Force a live refresh_token exchange against Google and return the updated status. */
74
+ export async function probeToken() {
75
+ if (ctx)
76
+ await probeTokenHealth(ctx, { force: true });
77
+ return getStatus();
78
+ }
52
79
  // ─── Events CRUD ───
53
80
  export async function createEvent(params) {
54
81
  if (!calendarApi)
@@ -265,6 +292,30 @@ const SCOPES = [
265
292
  ];
266
293
  const REDIRECT_PATH = '/api/calendar/auth/callback'; // Calendar's own callback
267
294
  let oauth2Client = null;
295
+ /**
296
+ * Build an OAuth2 client whose every outbound call updates shared token health.
297
+ *
298
+ * Every Calendar call made with `auth: oauth2Client` funnels through this one
299
+ * `request()` method, so wrapping it here means a revoked token is detected the moment
300
+ * an agent actually touches Calendar — no waiting for the next background probe —
301
+ * without instrumenting each API call site.
302
+ */
303
+ function createOAuthClient(clientId, clientSecret, redirectUri) {
304
+ const client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
305
+ const originalRequest = client.request.bind(client);
306
+ client.request = (async (opts) => {
307
+ try {
308
+ const result = await originalRequest(opts);
309
+ reportGoogleApiSuccess();
310
+ return result;
311
+ }
312
+ catch (err) {
313
+ reportGoogleApiError(err);
314
+ throw err;
315
+ }
316
+ });
317
+ return client;
318
+ }
268
319
  function getRedirectUri() {
269
320
  if (!ctx)
270
321
  throw new Error('Google Calendar not initialized');
@@ -279,7 +330,7 @@ export function getAuthUrl() {
279
330
  if (!clientId || !clientSecret || !ctx) {
280
331
  throw new Error('Google Calendar OAuth not configured');
281
332
  }
282
- oauth2Client = new google.auth.OAuth2(clientId, clientSecret, getRedirectUri());
333
+ oauth2Client = createOAuthClient(clientId, clientSecret, getRedirectUri());
283
334
  }
284
335
  return oauth2Client.generateAuthUrl({
285
336
  access_type: 'offline',
@@ -298,6 +349,10 @@ export async function handleAuthCallback(code) {
298
349
  calendarApi = google.calendar({ version: 'v3', auth: oauth2Client });
299
350
  // Auto-enable the integration after successful OAuth
300
351
  updateConfig({ enabled: true });
352
+ // Fresh consent just produced this token. Clears the 'expired' verdict AND re-arms
353
+ // Gmail and Drive, whose clients still hold the old refresh token — all three share
354
+ // this one secret, so one consent must repair all three.
355
+ await notifyTokenRotated();
301
356
  ctx.log.info(`Google Calendar OAuth complete. Calendar initialized.`);
302
357
  }
303
358
  function mapGoogleEvent(data, source) {
@@ -138,10 +138,15 @@ router.post('/working-days', (req, res) => {
138
138
  res.status(500).json({ error: `Failed to calculate working days: ${err instanceof Error ? err.message : err}` });
139
139
  }
140
140
  });
141
- // GET /api/calendar/status — Get Google Calendar auth status
142
- router.get('/status', (req, res) => {
143
- const status = calendarClient.getStatus();
144
- res.json(status);
141
+ // GET /api/calendar/status — Get Google Calendar auth status.
142
+ // `?probe=1` forces a live refresh_token exchange against Google instead of reusing
143
+ // the cached verdict — the settings UI uses it to verify the token on open, so a
144
+ // revoked token can't keep rendering as "Connected".
145
+ router.get('/status', async (req, res) => {
146
+ if (req.query.probe === '1' || req.query.probe === 'true') {
147
+ await calendarClient.probeToken();
148
+ }
149
+ res.json(calendarClient.getStatus());
145
150
  });
146
151
  // GET /api/calendar/auth/url — Get OAuth authorization URL
147
152
  router.get('/auth/url', (req, res) => {