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.
- package/CHANGELOG.md +24 -0
- package/dist/assets/{BossLogsModal-IqVqHwz-.js → BossLogsModal-CLmSG1H0.js} +1 -1
- package/dist/assets/BossSpawnModal-DZlB8KRy.js +1 -0
- package/dist/assets/ControlsModal-B1-STBnD.js +1 -0
- package/dist/assets/DockerLogsModal-CWnhsG_r.js +2 -0
- package/dist/assets/{EmbeddedEditor-BA9mX_Lg.js → EmbeddedEditor-5po-iz0Q.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-7nj6aLot.js → GmailOAuthSetup-BB10r44e.js} +1 -1
- package/dist/assets/GoogleOAuthSetup-CW_Ib5kq.js +270 -0
- package/dist/assets/{IframeModal-DkkCx6nq.js → IframeModal-CPt5x5fr.js} +1 -1
- package/dist/assets/IntegrationsPanel-CO-5iHtn.js +2 -0
- package/dist/assets/{LogViewerModal-DKYB8dHo.js → LogViewerModal-DicJq6l-.js} +1 -1
- package/dist/assets/{MonitoringModal-DOKMIa5U.js → MonitoringModal-B3nwcYZn.js} +1 -1
- package/dist/assets/{PM2LogsModal-XlbT9uoY.js → PM2LogsModal-Viw6fBjN.js} +2 -2
- package/dist/assets/RestoreArchivedAreaModal-BVH9wxhk.js +1 -0
- package/dist/assets/Scene2DCanvas-D6TCimYq.js +1 -0
- package/dist/assets/SceneManager-BdJs75zx.js +104 -0
- package/dist/assets/{SkillsPanel-BKjpKM1N.js → SkillsPanel-D1WonIBN.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-C0k60ioO.js → SlackMultiInstanceSetup-DSTZ5Hry.js} +1 -1
- package/dist/assets/SpawnModal-DUpIkQ08.js +1 -0
- package/dist/assets/StatisticsModal-BoDgM_8E.js +1 -0
- package/dist/assets/SubordinateAssignmentModal-LysAkNGy.js +1 -0
- package/dist/assets/{TriggerManagerPanel-RWrID12k.js → TriggerManagerPanel-BHxmBRvy.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-Ccwm4H5t.js → WorkflowEditorPanel-BHayKmMV.js} +1 -1
- package/dist/assets/abnfDiagram-VRR7QNED-DBZ5EpAj.js +1 -0
- package/dist/assets/arc-UCe2q9lp.js +1 -0
- package/dist/assets/architectureDiagram-ZJ3FMSHR-D5zOdUA8.js +36 -0
- package/dist/assets/{area-logos-DnrrrkFh.js → area-logos-4nlUPBxf.js} +1 -1
- package/dist/assets/blockDiagram-677ZJIJ3-7S-qZlh0.js +132 -0
- package/dist/assets/c4Diagram-LMCZKHZV-CgLv5SCO.js +10 -0
- package/dist/assets/channel-C59jtig-.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-J08qsAGr.js +1 -0
- package/dist/assets/chunk-32BRIVSS-Xy5D5wu7.js +1 -0
- package/dist/assets/chunk-5VM5RSS4-DqhmRB6E.js +15 -0
- package/dist/assets/chunk-EX3LRPZG-QE89_WgA.js +231 -0
- package/dist/assets/chunk-JWPE2WC7-YSc_ua_g.js +1 -0
- package/dist/assets/chunk-MOJQB5TN-CcH7NM7I.js +88 -0
- package/dist/assets/chunk-RYQCIY6F-CndQTJJ1.js +1 -0
- package/dist/assets/chunk-V7JOEXUC-Dauixo5p.js +206 -0
- package/dist/assets/chunk-VR4S4FIN-BBU6oFw0.js +1 -0
- package/dist/assets/chunk-XXDRQBXY-DXMolb4D.js +1 -0
- package/dist/assets/classDiagram-OUVF2IWQ-DHvVmqF9.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-DHvVmqF9.js +1 -0
- package/dist/assets/cose-bilkent-JH36ORCC-Dd9IB924.js +1 -0
- package/dist/assets/cynefin-VYW2F7L2-BQxwdSSb.js +178 -0
- package/dist/assets/cynefinDiagram-TSTJHNR4-BLnxgd0m.js +62 -0
- package/dist/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/dist/assets/dagre-VKFMJZFB-BZ7fXVWR.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-FQU43EPY-BFQ1xjHE.js +3 -0
- package/dist/assets/diagram-G47NLZAW-sXsGErM6.js +24 -0
- package/dist/assets/diagram-NH7WQ7WH-D6SPifDp.js +24 -0
- package/dist/assets/diagram-OA4YK3LP-Mu16f_2g.js +30 -0
- package/dist/assets/diagram-WEI45ONY-3BTJg43I.js +41 -0
- package/dist/assets/ebnfDiagram-CCIWWBDH-BGl2hs1X.js +1 -0
- package/dist/assets/erDiagram-Q63AITRT-D03B1rgc.js +85 -0
- package/dist/assets/flowDiagram-23GEKE2U-BCaZJGGE.js +156 -0
- package/dist/assets/ganttDiagram-NO4QXBWP-DrTSQR9G.js +292 -0
- package/dist/assets/gitGraphDiagram-IHSO6WYX-BvrZLcji.js +106 -0
- package/dist/assets/graph-C9eacEi8.js +1 -0
- package/dist/assets/index-B7l8Nsf8.js +1 -0
- package/dist/assets/index-BK7LL53p.js +1 -0
- package/dist/assets/index-CJ6x10Zs.js +2 -0
- package/dist/assets/index-D6btqUwj.js +2 -0
- package/dist/assets/{index-D-MKKw7S.js → index-DAXUzeVz.js} +6 -6
- package/dist/assets/{index-Cjgy2CnG.js → index-DcD9tVKJ.js} +3 -3
- package/dist/assets/{index-DZ7i37eV.js → index-Dzup44Dy.js} +6 -6
- package/dist/assets/index-JEgWBCoB.js +1 -0
- package/dist/assets/index-J_xWyk3Q.js +1 -0
- package/dist/assets/infoDiagram-FWYZ7A6U-Bw9k-wkS.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-FXEZZL3T-C2oVKtw0.js +70 -0
- package/dist/assets/journeyDiagram-5HDEW3XC-pRpwSEa5.js +139 -0
- package/dist/assets/kanban-definition-HUTT4EX6-CYq_poR1.js +89 -0
- package/dist/assets/katex-C5jXJg4s.js +257 -0
- package/dist/assets/layout-DEXfKzaS.js +1 -0
- package/dist/assets/linear-DLT2RlFJ.js +1 -0
- package/dist/assets/{main-DIf9R6Jx.js → main-06kmV-SQ.js} +125 -125
- package/dist/assets/main-BzGnAbiA.css +1 -0
- package/dist/assets/map-Czzmt4hB.js +1 -0
- package/dist/assets/mermaid.core-DmOBz9SE.js +314 -0
- package/dist/assets/mindmap-definition-LN4V7U3C-DzucjpI_.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pegDiagram-2B236MQR-CIf_bLf4.js +1 -0
- package/dist/assets/pieDiagram-ENE6RG2P-Dh9HXeSf.js +39 -0
- package/dist/assets/quadrantDiagram-ABIIQ3AL-B5hZRZ5w.js +7 -0
- package/dist/assets/railroadDiagram-RFXS5EU6-B1oWAO1u.js +1 -0
- package/dist/assets/requirementDiagram-TGXJPOKE-Cg7bjm4O.js +84 -0
- package/dist/assets/sankeyDiagram-HTMAVEWB-TLCrcbtk.js +40 -0
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BD3b_psw.js +162 -0
- package/dist/assets/sizeCapture-X5ZJPWSS-D26nx1uQ.js +1 -0
- package/dist/assets/stateDiagram-2N3HPSRC-CIwRQUnK.js +1 -0
- package/dist/assets/stateDiagram-v2-6OUMAXLB-C5qcZ4o1.js +1 -0
- package/dist/assets/swimlanes-5IMT3BWC-DYbes851.js +2 -0
- package/dist/assets/swimlanesDiagram-G3AALYLV-Ke-NRRIa.js +8 -0
- package/dist/assets/timeline-definition-FHXFAJF6-Fr9U6JU6.js +120 -0
- package/dist/assets/{vendor-three-Chj50gSY.js → vendor-three-3OKhI9bz.js} +1 -1
- package/dist/assets/vennDiagram-L72KCM5P-Cns-KLq7.js +34 -0
- package/dist/assets/wardleyDiagram-EHGQE667-DHA7rD9Z.js +78 -0
- package/dist/assets/{web-DDxnPoTK.js → web-B7c8sthS.js} +1 -1
- package/dist/assets/{web-DhZ0XCan.js → web-C05qFIA-.js} +1 -1
- package/dist/assets/{web-Dlc8Nyra.js → web-Dja1kfLs.js} +1 -1
- package/dist/assets/xychartDiagram-FW5EYKEG-DCnL05Wq.js +7 -0
- package/dist/build-info.json +1 -1
- package/dist/index.html +3 -3
- package/dist/src/packages/server/claude/runner/watchdog.js +27 -0
- package/dist/src/packages/server/codex/app-server/codex-app-server-runner.js +34 -4
- package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
- package/dist/src/packages/server/data/builtin-skills/mermaid-diagrams.js +76 -0
- package/dist/src/packages/server/integrations/gmail/gmail-client.js +73 -5
- package/dist/src/packages/server/integrations/gmail/gmail-routes.js +9 -4
- package/dist/src/packages/server/integrations/google-auth/token-health.js +196 -0
- package/dist/src/packages/server/integrations/google-calendar/calendar-client.js +61 -6
- package/dist/src/packages/server/integrations/google-calendar/calendar-routes.js +9 -4
- package/dist/src/packages/server/integrations/google-drive/drive-client.js +61 -6
- package/dist/src/packages/server/integrations/google-drive/drive-routes.js +9 -4
- package/dist/src/packages/server/opencode/server/opencode-server-process.js +102 -7
- package/dist/src/packages/server/opencode/server/opencode-server-runner.js +13 -0
- package/dist/src/packages/server/services/agent-service.js +7 -0
- package/dist/src/packages/server/services/runtime-events.js +1 -0
- package/package.json +2 -1
- package/dist/assets/BossSpawnModal-Dj9ayWfR.js +0 -1
- package/dist/assets/ControlsModal-ki85jvDA.js +0 -1
- package/dist/assets/DockerLogsModal-CeIuKlvk.js +0 -2
- package/dist/assets/GoogleOAuthSetup-Bh-Cy-dE.js +0 -236
- package/dist/assets/IntegrationsPanel-DHjpWEFF.js +0 -2
- package/dist/assets/RestoreArchivedAreaModal-Du1f8Urv.js +0 -1
- package/dist/assets/Scene2DCanvas-BTnpyyK6.js +0 -1
- package/dist/assets/SceneManager-owoMSHKT.js +0 -104
- package/dist/assets/SpawnModal-Cww_6dcz.js +0 -1
- package/dist/assets/StatisticsModal-Dl5loLLI.js +0 -1
- package/dist/assets/SubordinateAssignmentModal-DefQL38f.js +0 -1
- package/dist/assets/index-B5iakYkB.js +0 -1
- package/dist/assets/index-CbJ-R04c.js +0 -2
- package/dist/assets/index-CcHBNDP_.js +0 -2
- package/dist/assets/index-DG7HgtQn.js +0 -1
- package/dist/assets/index-Fcs4x2Vj.js +0 -1
- package/dist/assets/index-g0yAcpoy.js +0 -1
- 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 =
|
|
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:
|
|
216
|
+
authenticated: live && !reauth,
|
|
166
217
|
emailAddress: authenticatedEmail,
|
|
167
218
|
pollingActive: pollingTimer !== null,
|
|
168
|
-
connected:
|
|
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 =
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 =
|
|
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:
|
|
45
|
-
lastChecked: Date.now(),
|
|
46
|
-
error
|
|
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 =
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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) => {
|