patchwork-os 0.2.0-alpha.34 → 0.2.0-alpha.35

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 (117) hide show
  1. package/README.md +142 -88
  2. package/deploy/bootstrap-new-vps.sh +12 -12
  3. package/deploy/bootstrap-vps.sh +6 -3
  4. package/deploy/deploy-landing.sh +59 -2
  5. package/dist/bridge.js +32 -1
  6. package/dist/bridge.js.map +1 -1
  7. package/dist/commands/recipe.js +18 -1
  8. package/dist/commands/recipe.js.map +1 -1
  9. package/dist/commands/recipeInstall.d.ts +79 -1
  10. package/dist/commands/recipeInstall.js +241 -13
  11. package/dist/commands/recipeInstall.js.map +1 -1
  12. package/dist/connectors/asana.d.ts +198 -0
  13. package/dist/connectors/asana.js +680 -0
  14. package/dist/connectors/asana.js.map +1 -0
  15. package/dist/connectors/baseConnector.d.ts +16 -0
  16. package/dist/connectors/baseConnector.js +106 -24
  17. package/dist/connectors/baseConnector.js.map +1 -1
  18. package/dist/connectors/discord.d.ts +150 -0
  19. package/dist/connectors/discord.js +544 -0
  20. package/dist/connectors/discord.js.map +1 -0
  21. package/dist/connectors/github.js +11 -4
  22. package/dist/connectors/github.js.map +1 -1
  23. package/dist/connectors/gitlab.d.ts +180 -0
  24. package/dist/connectors/gitlab.js +582 -0
  25. package/dist/connectors/gitlab.js.map +1 -0
  26. package/dist/connectors/gmail.js +11 -0
  27. package/dist/connectors/gmail.js.map +1 -1
  28. package/dist/connectors/googleDrive.d.ts +34 -0
  29. package/dist/connectors/googleDrive.js +305 -0
  30. package/dist/connectors/googleDrive.js.map +1 -0
  31. package/dist/connectors/linear.js +23 -4
  32. package/dist/connectors/linear.js.map +1 -1
  33. package/dist/connectors/pagerduty.d.ts +160 -0
  34. package/dist/connectors/pagerduty.js +464 -0
  35. package/dist/connectors/pagerduty.js.map +1 -0
  36. package/dist/connectors/slack.d.ts +1 -1
  37. package/dist/connectors/slack.js +3 -1
  38. package/dist/connectors/slack.js.map +1 -1
  39. package/dist/featureFlags.d.ts +17 -11
  40. package/dist/featureFlags.js +52 -47
  41. package/dist/featureFlags.js.map +1 -1
  42. package/dist/index.js +255 -127
  43. package/dist/index.js.map +1 -1
  44. package/dist/recipeOrchestration.d.ts +7 -0
  45. package/dist/recipeOrchestration.js +149 -28
  46. package/dist/recipeOrchestration.js.map +1 -1
  47. package/dist/recipes/captureForRunlog.d.ts +27 -0
  48. package/dist/recipes/captureForRunlog.js +128 -0
  49. package/dist/recipes/captureForRunlog.js.map +1 -0
  50. package/dist/recipes/chainedRunner.d.ts +39 -3
  51. package/dist/recipes/chainedRunner.js +183 -28
  52. package/dist/recipes/chainedRunner.js.map +1 -1
  53. package/dist/recipes/detectSilentFail.d.ts +34 -0
  54. package/dist/recipes/detectSilentFail.js +105 -0
  55. package/dist/recipes/detectSilentFail.js.map +1 -0
  56. package/dist/recipes/manifest.js +21 -6
  57. package/dist/recipes/manifest.js.map +1 -1
  58. package/dist/recipes/replayRun.d.ts +62 -0
  59. package/dist/recipes/replayRun.js +97 -0
  60. package/dist/recipes/replayRun.js.map +1 -0
  61. package/dist/recipes/scheduler.js +102 -11
  62. package/dist/recipes/scheduler.js.map +1 -1
  63. package/dist/recipes/schemaGenerator.js +3 -3
  64. package/dist/recipes/schemaGenerator.js.map +1 -1
  65. package/dist/recipes/toolRegistry.d.ts +5 -0
  66. package/dist/recipes/toolRegistry.js +9 -0
  67. package/dist/recipes/toolRegistry.js.map +1 -1
  68. package/dist/recipes/tools/asana.d.ts +16 -0
  69. package/dist/recipes/tools/asana.js +524 -0
  70. package/dist/recipes/tools/asana.js.map +1 -0
  71. package/dist/recipes/tools/discord.d.ts +18 -0
  72. package/dist/recipes/tools/discord.js +254 -0
  73. package/dist/recipes/tools/discord.js.map +1 -0
  74. package/dist/recipes/tools/github.js +29 -4
  75. package/dist/recipes/tools/github.js.map +1 -1
  76. package/dist/recipes/tools/gitlab.d.ts +11 -0
  77. package/dist/recipes/tools/gitlab.js +285 -0
  78. package/dist/recipes/tools/gitlab.js.map +1 -0
  79. package/dist/recipes/tools/gmail.d.ts +1 -1
  80. package/dist/recipes/tools/gmail.js +230 -6
  81. package/dist/recipes/tools/gmail.js.map +1 -1
  82. package/dist/recipes/tools/googleDrive.d.ts +1 -0
  83. package/dist/recipes/tools/googleDrive.js +55 -0
  84. package/dist/recipes/tools/googleDrive.js.map +1 -0
  85. package/dist/recipes/tools/index.d.ts +6 -0
  86. package/dist/recipes/tools/index.js +6 -0
  87. package/dist/recipes/tools/index.js.map +1 -1
  88. package/dist/recipes/tools/linear.d.ts +2 -1
  89. package/dist/recipes/tools/linear.js +222 -1
  90. package/dist/recipes/tools/linear.js.map +1 -1
  91. package/dist/recipes/tools/meetingNotes.d.ts +21 -0
  92. package/dist/recipes/tools/meetingNotes.js +701 -0
  93. package/dist/recipes/tools/meetingNotes.js.map +1 -0
  94. package/dist/recipes/tools/pagerduty.d.ts +15 -0
  95. package/dist/recipes/tools/pagerduty.js +451 -0
  96. package/dist/recipes/tools/pagerduty.js.map +1 -0
  97. package/dist/recipes/tools/slack.js +8 -2
  98. package/dist/recipes/tools/slack.js.map +1 -1
  99. package/dist/recipes/yamlRunner.d.ts +23 -2
  100. package/dist/recipes/yamlRunner.js +263 -58
  101. package/dist/recipes/yamlRunner.js.map +1 -1
  102. package/dist/recipesHttp.d.ts +32 -0
  103. package/dist/recipesHttp.js +310 -1
  104. package/dist/recipesHttp.js.map +1 -1
  105. package/dist/runLog.d.ts +64 -2
  106. package/dist/runLog.js +116 -2
  107. package/dist/runLog.js.map +1 -1
  108. package/dist/server.d.ts +8 -0
  109. package/dist/server.js +331 -9
  110. package/dist/server.js.map +1 -1
  111. package/dist/streamableHttp.d.ts +31 -1
  112. package/dist/streamableHttp.js +20 -2
  113. package/dist/streamableHttp.js.map +1 -1
  114. package/dist/tools/slackPostMessage.js +1 -1
  115. package/dist/tools/slackPostMessage.js.map +1 -1
  116. package/package.json +19 -4
  117. package/templates/recipes/project-health-check.yaml +1 -1
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Asana connector — read workspaces, projects, tasks, and current user, plus
3
+ * writes (createTask, updateTask, completeTask, addTaskComment).
4
+ *
5
+ * OAuth 2.0 Authorization Code Grant. Asana is a confidential client (bridge
6
+ * holds client secret), so PKCE is not used. Refresh tokens are issued; access
7
+ * tokens expire after 1 hour (3600s).
8
+ *
9
+ * Auth: standard OAuth 2.0 with `client_id` + `client_secret` + `redirect_uri`.
10
+ * - Env vars: ASANA_CLIENT_ID, ASANA_CLIENT_SECRET (mirrors discord/slack)
11
+ * - Stored: getSecretJsonSync("asana") → AsanaTokens
12
+ * - Header: Authorization: Bearer <access_token>
13
+ *
14
+ * Scope note: Asana's only OAuth scope is `default`, which grants read+write
15
+ * combined — there is no read-only-only scope. Defense lives at the recipe-tool
16
+ * layer where each tool declares `isWrite: true|false`. Newer Asana accounts
17
+ * may also expose granular scopes; we set `default` for compatibility.
18
+ *
19
+ * Read tools: getCurrentUser, listWorkspaces, listProjects, listTasks, getTask.
20
+ * Write tools: createTask, updateTask, completeTask, addTaskComment.
21
+ *
22
+ * Idempotency: Asana doesn't honor an Idempotency-Key header. Writes simply
23
+ * throw on error — callers must dedupe via app-level checks if needed.
24
+ *
25
+ * HTTP routes (wired in src/server.ts):
26
+ * GET /connections/asana/auth — redirect to Asana consent
27
+ * GET /connections/asana/callback — exchange code for tokens
28
+ * POST /connections/asana/test — ping Asana API
29
+ * DELETE /connections/asana — clear stored tokens
30
+ *
31
+ * Asana wraps every API response in `{ data: ... }`. We unwrap consistently
32
+ * inside this class so recipe-tool wrappers see clean arrays/objects.
33
+ *
34
+ * Extends BaseConnector for unified auth, retry, rate-limit, error handling.
35
+ * Token refresh is delegated to BaseConnector.refreshToken() via apiCall.
36
+ */
37
+ import crypto from "node:crypto";
38
+ import { BaseConnector, } from "./baseConnector.js";
39
+ import { escHtml } from "./htmlEscape.js";
40
+ import { deleteSecretJsonSync, getSecretJsonSync, storeSecretJsonSync, } from "./tokenStorage.js";
41
+ const ASANA_API_BASE = "https://app.asana.com/api/1.0";
42
+ const ASANA_AUTH_URL = "https://app.asana.com/-/oauth_authorize";
43
+ const ASANA_TOKEN_URL = "https://app.asana.com/-/oauth_token";
44
+ const SCOPES = ["default"];
45
+ // ── Config ───────────────────────────────────────────────────────────────────
46
+ function clientId() {
47
+ return process.env.ASANA_CLIENT_ID ?? "";
48
+ }
49
+ function clientSecret() {
50
+ return process.env.ASANA_CLIENT_SECRET ?? "";
51
+ }
52
+ function redirectUri() {
53
+ const base = (process.env.PATCHWORK_BRIDGE_URL ??
54
+ `http://localhost:${process.env.PATCHWORK_BRIDGE_PORT ?? "3101"}`).replace(/\/$/, "");
55
+ return `${base}/connections/asana/callback`;
56
+ }
57
+ function isConfigured() {
58
+ return Boolean(clientId() && clientSecret());
59
+ }
60
+ // ── Token persistence ────────────────────────────────────────────────────────
61
+ export function loadTokens() {
62
+ return getSecretJsonSync("asana");
63
+ }
64
+ export function saveTokens(tokens) {
65
+ storeSecretJsonSync("asana", tokens);
66
+ }
67
+ export function clearTokens() {
68
+ try {
69
+ deleteSecretJsonSync("asana");
70
+ }
71
+ catch {
72
+ // ignore
73
+ }
74
+ }
75
+ export function isConnected() {
76
+ return loadTokens() !== null;
77
+ }
78
+ // ── State (CSRF) ─────────────────────────────────────────────────────────────
79
+ // In-memory map keyed by hex random — short-lived (5 min). Mirrors discord's
80
+ // approach (Set + setTimeout).
81
+ const pendingStates = new Set();
82
+ const STATE_TTL_MS = 5 * 60 * 1000;
83
+ function generateState() {
84
+ const state = crypto.randomBytes(32).toString("hex");
85
+ pendingStates.add(state);
86
+ setTimeout(() => pendingStates.delete(state), STATE_TTL_MS);
87
+ return state;
88
+ }
89
+ function consumeState(state) {
90
+ if (!pendingStates.has(state))
91
+ return false;
92
+ pendingStates.delete(state);
93
+ return true;
94
+ }
95
+ // ── Connector class ──────────────────────────────────────────────────────────
96
+ export class AsanaConnector extends BaseConnector {
97
+ providerName = "asana";
98
+ getOAuthConfig() {
99
+ // Resolve credentials from env first, then fall back to credentials we
100
+ // stored at auth time (so refresh keeps working even if env is unset).
101
+ const tokens = loadTokens();
102
+ const id = clientId() || tokens?._client_id || "";
103
+ const secret = clientSecret() || tokens?._client_secret || "";
104
+ if (!id || !secret)
105
+ return null;
106
+ return {
107
+ clientId: id,
108
+ clientSecret: secret,
109
+ tokenEndpoint: ASANA_TOKEN_URL,
110
+ scopes: SCOPES,
111
+ };
112
+ }
113
+ async authenticate() {
114
+ const tokens = loadTokens();
115
+ if (!tokens) {
116
+ throw new Error("Asana not connected. Visit /connections/asana/auth to authorize.");
117
+ }
118
+ return {
119
+ token: tokens.access_token,
120
+ refreshToken: tokens.refresh_token,
121
+ expiresAt: tokens.expires_at ? new Date(tokens.expires_at) : undefined,
122
+ scopes: tokens.scope ? tokens.scope.split(" ") : SCOPES,
123
+ };
124
+ }
125
+ /**
126
+ * Persist refreshed tokens after BaseConnector.refreshToken() updates
127
+ * `this.auth`. BaseConnector calls `saveTokens()` (its own method on the
128
+ * tokenStorage StoredToken shape); we additionally mirror to our
129
+ * Asana-specific JSON so loadTokens() keeps working for HTTP probes.
130
+ */
131
+ async saveTokens() {
132
+ await super.saveTokens();
133
+ if (!this.auth)
134
+ return;
135
+ const existing = loadTokens();
136
+ saveTokens({
137
+ access_token: this.auth.token,
138
+ refresh_token: this.auth.refreshToken,
139
+ expires_at: this.auth.expiresAt
140
+ ? this.auth.expiresAt.getTime()
141
+ : undefined,
142
+ scope: this.auth.scopes?.join(" "),
143
+ token_type: existing?.token_type ?? "Bearer",
144
+ _client_id: existing?._client_id,
145
+ _client_secret: existing?._client_secret,
146
+ username: existing?.username,
147
+ user_gid: existing?.user_gid,
148
+ email: existing?.email,
149
+ connected_at: existing?.connected_at ?? new Date().toISOString(),
150
+ });
151
+ }
152
+ async healthCheck() {
153
+ try {
154
+ const result = await this.apiCall(async (token) => {
155
+ const res = await fetch(`${ASANA_API_BASE}/users/me`, {
156
+ headers: this.buildHeaders(token),
157
+ });
158
+ if (!res.ok)
159
+ throw res;
160
+ return res.json();
161
+ });
162
+ if ("error" in result)
163
+ return { ok: false, error: result.error };
164
+ return { ok: true };
165
+ }
166
+ catch (err) {
167
+ return { ok: false, error: this.normalizeError(err) };
168
+ }
169
+ }
170
+ normalizeError(error) {
171
+ if (error instanceof Response) {
172
+ const s = error.status;
173
+ // Asana 4xx payloads have an `errors: [{message, help}]` array — surface
174
+ // the first message when we can. We can't await here because the body
175
+ // may already be consumed; callers that want detail should pre-read.
176
+ const detail = error
177
+ ._asanaDetail;
178
+ const tag = detail ? `: ${detail}` : "";
179
+ if (s === 401)
180
+ return {
181
+ code: "auth_expired",
182
+ message: `Asana authentication failed — token expired or revoked${tag}`,
183
+ retryable: true,
184
+ suggestedAction: "Reconnect via /connections/asana/auth",
185
+ };
186
+ if (s === 403)
187
+ return {
188
+ code: "permission_denied",
189
+ message: `Insufficient Asana permissions for this resource${tag}`,
190
+ retryable: false,
191
+ };
192
+ if (s === 404)
193
+ return {
194
+ code: "not_found",
195
+ message: `Asana resource not found${tag}`,
196
+ retryable: false,
197
+ };
198
+ if (s === 429) {
199
+ const retryAfter = error.headers.get("retry-after");
200
+ return {
201
+ code: "rate_limited",
202
+ message: `Asana API rate limit exceeded${retryAfter ? ` (retry after ${retryAfter}s)` : ""}${tag}`,
203
+ retryable: true,
204
+ suggestedAction: retryAfter
205
+ ? `Wait ${retryAfter}s and retry`
206
+ : "Wait and retry",
207
+ providerDetail: retryAfter ? { retryAfter } : undefined,
208
+ };
209
+ }
210
+ return {
211
+ code: "provider_error",
212
+ message: `Asana API error: HTTP ${s}${tag}`,
213
+ retryable: s >= 500,
214
+ };
215
+ }
216
+ if (error instanceof Error) {
217
+ if (error.message.includes("ENOTFOUND") ||
218
+ error.message.includes("ECONNREFUSED")) {
219
+ return {
220
+ code: "network_error",
221
+ message: `Cannot connect to Asana: ${error.message}`,
222
+ retryable: true,
223
+ };
224
+ }
225
+ }
226
+ return {
227
+ code: "provider_error",
228
+ message: error instanceof Error ? error.message : String(error),
229
+ retryable: false,
230
+ };
231
+ }
232
+ getStatus() {
233
+ const tokens = loadTokens();
234
+ return {
235
+ id: "asana",
236
+ status: tokens ? "connected" : "disconnected",
237
+ lastSync: tokens?.connected_at,
238
+ workspace: tokens?.username,
239
+ };
240
+ }
241
+ // ── API Methods ────────────────────────────────────────────────────────────
242
+ async getCurrentUser() {
243
+ const result = await this.apiCall(async (token) => {
244
+ const res = await fetch(`${ASANA_API_BASE}/users/me`, {
245
+ headers: this.buildHeaders(token),
246
+ });
247
+ this.captureRateLimit(res);
248
+ if (!res.ok)
249
+ throw await this.attachErrorDetail(res);
250
+ const json = (await res.json());
251
+ return json.data;
252
+ });
253
+ if ("error" in result)
254
+ throw new Error(result.error.message);
255
+ return result.data;
256
+ }
257
+ async listWorkspaces(params = {}) {
258
+ const result = await this.apiCall(async (token) => {
259
+ const qs = new URLSearchParams({
260
+ limit: String(Math.min(Math.max(params.limit ?? 50, 1), 100)),
261
+ });
262
+ const res = await fetch(`${ASANA_API_BASE}/workspaces?${qs}`, {
263
+ headers: this.buildHeaders(token),
264
+ });
265
+ this.captureRateLimit(res);
266
+ if (!res.ok)
267
+ throw await this.attachErrorDetail(res);
268
+ const json = (await res.json());
269
+ return json.data;
270
+ });
271
+ if ("error" in result)
272
+ throw new Error(result.error.message);
273
+ return result.data;
274
+ }
275
+ async listProjects(params) {
276
+ if (!params.workspaceGid) {
277
+ throw new Error("listProjects requires workspaceGid");
278
+ }
279
+ const result = await this.apiCall(async (token) => {
280
+ const qs = new URLSearchParams({
281
+ workspace: params.workspaceGid,
282
+ limit: String(Math.min(Math.max(params.limit ?? 50, 1), 100)),
283
+ });
284
+ const res = await fetch(`${ASANA_API_BASE}/projects?${qs}`, {
285
+ headers: this.buildHeaders(token),
286
+ });
287
+ this.captureRateLimit(res);
288
+ if (!res.ok)
289
+ throw await this.attachErrorDetail(res);
290
+ const json = (await res.json());
291
+ return json.data;
292
+ });
293
+ if ("error" in result)
294
+ throw new Error(result.error.message);
295
+ return result.data;
296
+ }
297
+ async listTasks(params = {}) {
298
+ // Asana's GET /tasks requires either `project` OR (`assignee` + `workspace`).
299
+ // Asana itself returns a 400 when this is wrong; we surface a clearer error
300
+ // up front so recipe authors see the real problem instantly.
301
+ const hasProject = Boolean(params.projectGid);
302
+ const hasAssigneePair = Boolean(params.assignee && params.workspaceGid);
303
+ if (!hasProject && !hasAssigneePair) {
304
+ throw new Error("listTasks requires either projectGid, or assignee + workspaceGid");
305
+ }
306
+ const result = await this.apiCall(async (token) => {
307
+ const qs = new URLSearchParams({
308
+ limit: String(Math.min(Math.max(params.limit ?? 50, 1), 100)),
309
+ });
310
+ if (params.projectGid)
311
+ qs.set("project", params.projectGid);
312
+ if (params.assignee)
313
+ qs.set("assignee", params.assignee);
314
+ if (params.workspaceGid)
315
+ qs.set("workspace", params.workspaceGid);
316
+ const res = await fetch(`${ASANA_API_BASE}/tasks?${qs}`, {
317
+ headers: this.buildHeaders(token),
318
+ });
319
+ this.captureRateLimit(res);
320
+ if (!res.ok)
321
+ throw await this.attachErrorDetail(res);
322
+ const json = (await res.json());
323
+ return json.data;
324
+ });
325
+ if ("error" in result)
326
+ throw new Error(result.error.message);
327
+ return result.data;
328
+ }
329
+ async getTask(taskGid) {
330
+ if (!taskGid)
331
+ throw new Error("getTask requires taskGid");
332
+ const result = await this.apiCall(async (token) => {
333
+ const res = await fetch(`${ASANA_API_BASE}/tasks/${encodeURIComponent(taskGid)}`, { headers: this.buildHeaders(token) });
334
+ this.captureRateLimit(res);
335
+ if (!res.ok)
336
+ throw await this.attachErrorDetail(res);
337
+ const json = (await res.json());
338
+ return json.data;
339
+ });
340
+ if ("error" in result)
341
+ throw new Error(result.error.message);
342
+ return result.data;
343
+ }
344
+ // ── Write methods ──────────────────────────────────────────────────────────
345
+ // Asana wraps both request and response bodies in `{ data: ... }`. We
346
+ // unwrap the response consistently here so recipe-tool wrappers see the
347
+ // clean object. Required-param validation happens up front so recipe
348
+ // authors get a clear error instead of an Asana 400.
349
+ async createTask(params) {
350
+ if (!params.name)
351
+ throw new Error("createTask requires name");
352
+ if (!params.workspaceGid) {
353
+ throw new Error("createTask requires workspaceGid");
354
+ }
355
+ const result = await this.apiCall(async (token) => {
356
+ const data = {
357
+ workspace: params.workspaceGid,
358
+ name: params.name,
359
+ };
360
+ if (params.projectGid)
361
+ data.projects = [params.projectGid];
362
+ if (params.notes !== undefined)
363
+ data.notes = params.notes;
364
+ if (params.assigneeGid)
365
+ data.assignee = params.assigneeGid;
366
+ if (params.dueOn)
367
+ data.due_on = params.dueOn;
368
+ if (params.parentTaskGid)
369
+ data.parent = params.parentTaskGid;
370
+ const res = await fetch(`${ASANA_API_BASE}/tasks`, {
371
+ method: "POST",
372
+ headers: this.buildHeaders(token),
373
+ body: JSON.stringify({ data }),
374
+ });
375
+ this.captureRateLimit(res);
376
+ if (!res.ok)
377
+ throw await this.attachErrorDetail(res);
378
+ const json = (await res.json());
379
+ return json.data;
380
+ });
381
+ if ("error" in result)
382
+ throw new Error(result.error.message);
383
+ return result.data;
384
+ }
385
+ async updateTask(taskGid, updates) {
386
+ if (!taskGid)
387
+ throw new Error("updateTask requires taskGid");
388
+ // Strip undefined keys — Asana interprets `undefined`-stringified values
389
+ // differently than missing keys. Only forward fields that were explicitly
390
+ // provided by the caller.
391
+ const data = {};
392
+ if (updates.name !== undefined)
393
+ data.name = updates.name;
394
+ if (updates.notes !== undefined)
395
+ data.notes = updates.notes;
396
+ if (updates.completed !== undefined)
397
+ data.completed = updates.completed;
398
+ if (updates.assigneeGid !== undefined)
399
+ data.assignee = updates.assigneeGid;
400
+ if (updates.dueOn !== undefined)
401
+ data.due_on = updates.dueOn;
402
+ if (Object.keys(data).length === 0) {
403
+ throw new Error("updateTask requires at least one field to update");
404
+ }
405
+ const result = await this.apiCall(async (token) => {
406
+ const res = await fetch(`${ASANA_API_BASE}/tasks/${encodeURIComponent(taskGid)}`, {
407
+ method: "PUT",
408
+ headers: this.buildHeaders(token),
409
+ body: JSON.stringify({ data }),
410
+ });
411
+ this.captureRateLimit(res);
412
+ if (!res.ok)
413
+ throw await this.attachErrorDetail(res);
414
+ const json = (await res.json());
415
+ return json.data;
416
+ });
417
+ if ("error" in result)
418
+ throw new Error(result.error.message);
419
+ return result.data;
420
+ }
421
+ /**
422
+ * Convenience wrapper around updateTask that flips `completed: true`. Lower
423
+ * risk than other updates since it's a single, well-known state transition.
424
+ */
425
+ async completeTask(taskGid) {
426
+ if (!taskGid)
427
+ throw new Error("completeTask requires taskGid");
428
+ return this.updateTask(taskGid, { completed: true });
429
+ }
430
+ async addTaskComment(taskGid, options) {
431
+ if (!taskGid)
432
+ throw new Error("addTaskComment requires taskGid");
433
+ if (!options.text)
434
+ throw new Error("addTaskComment requires non-empty text");
435
+ const result = await this.apiCall(async (token) => {
436
+ const res = await fetch(`${ASANA_API_BASE}/tasks/${encodeURIComponent(taskGid)}/stories`, {
437
+ method: "POST",
438
+ headers: this.buildHeaders(token),
439
+ body: JSON.stringify({
440
+ data: { text: options.text, type: "comment" },
441
+ }),
442
+ });
443
+ this.captureRateLimit(res);
444
+ if (!res.ok)
445
+ throw await this.attachErrorDetail(res);
446
+ const json = (await res.json());
447
+ return json.data;
448
+ });
449
+ if ("error" in result)
450
+ throw new Error(result.error.message);
451
+ return result.data;
452
+ }
453
+ // ── Helpers ────────────────────────────────────────────────────────────────
454
+ buildHeaders(token) {
455
+ return {
456
+ Authorization: `Bearer ${token}`,
457
+ Accept: "application/json",
458
+ "Content-Type": "application/json",
459
+ };
460
+ }
461
+ captureRateLimit(res) {
462
+ this.updateRateLimitFromHeaders({
463
+ "x-ratelimit-remaining": res.headers.get("x-ratelimit-remaining") ?? undefined,
464
+ "x-ratelimit-reset": res.headers.get("x-ratelimit-reset") ?? undefined,
465
+ "retry-after": res.headers.get("retry-after") ?? undefined,
466
+ });
467
+ }
468
+ /**
469
+ * Read the response body once and stash the first Asana `errors[].message`
470
+ * onto a sidecar property so normalizeError can include it without a second
471
+ * read. Returns the same Response for `throw` to consume.
472
+ */
473
+ async attachErrorDetail(res) {
474
+ try {
475
+ const body = (await res.clone().json());
476
+ const first = body?.errors?.[0]?.message;
477
+ if (first) {
478
+ res._asanaDetail = first;
479
+ }
480
+ }
481
+ catch {
482
+ // body wasn't JSON or already consumed — proceed without detail
483
+ }
484
+ return res;
485
+ }
486
+ }
487
+ // ── Singleton ────────────────────────────────────────────────────────────────
488
+ let _instance = null;
489
+ function resetAsanaConnector() {
490
+ _instance = null;
491
+ }
492
+ export function getAsanaConnector() {
493
+ if (!_instance) {
494
+ _instance = new AsanaConnector();
495
+ }
496
+ return _instance;
497
+ }
498
+ export { getAsanaConnector as asana };
499
+ /**
500
+ * GET /connections/asana/auth — redirect to Asana consent screen.
501
+ */
502
+ export function handleAsanaAuthorize() {
503
+ if (!isConfigured()) {
504
+ return {
505
+ status: 503,
506
+ contentType: "application/json",
507
+ body: JSON.stringify({
508
+ ok: false,
509
+ error: "Asana connector not configured. Set ASANA_CLIENT_ID and ASANA_CLIENT_SECRET.",
510
+ }),
511
+ };
512
+ }
513
+ const state = generateState();
514
+ const params = new URLSearchParams({
515
+ client_id: clientId(),
516
+ redirect_uri: redirectUri(),
517
+ response_type: "code",
518
+ scope: SCOPES.join(" "),
519
+ state,
520
+ });
521
+ return {
522
+ status: 302,
523
+ body: "",
524
+ redirect: `${ASANA_AUTH_URL}?${params.toString()}`,
525
+ };
526
+ }
527
+ /**
528
+ * GET /connections/asana/callback — exchange code for tokens.
529
+ */
530
+ export async function handleAsanaCallback(code, state, error) {
531
+ if (error) {
532
+ return {
533
+ status: 400,
534
+ contentType: "text/html",
535
+ body: `<html><body><h2>Asana connect failed</h2><pre>${escHtml(error)}</pre></body></html>`,
536
+ };
537
+ }
538
+ if (!code || !state) {
539
+ return {
540
+ status: 400,
541
+ contentType: "text/html",
542
+ body: `<html><body><h2>Asana connect failed</h2><pre>missing code or state</pre></body></html>`,
543
+ };
544
+ }
545
+ if (!consumeState(state)) {
546
+ return {
547
+ status: 400,
548
+ contentType: "text/html",
549
+ body: `<html><body><h2>Asana connect failed</h2><pre>invalid or expired state</pre></body></html>`,
550
+ };
551
+ }
552
+ try {
553
+ const params = new URLSearchParams({
554
+ grant_type: "authorization_code",
555
+ code,
556
+ redirect_uri: redirectUri(),
557
+ client_id: clientId(),
558
+ client_secret: clientSecret(),
559
+ });
560
+ const res = await fetch(ASANA_TOKEN_URL, {
561
+ method: "POST",
562
+ headers: {
563
+ "Content-Type": "application/x-www-form-urlencoded",
564
+ Accept: "application/json",
565
+ },
566
+ body: params.toString(),
567
+ });
568
+ if (!res.ok) {
569
+ const body = await res.text();
570
+ throw new Error(`Token exchange HTTP ${res.status}: ${body}`);
571
+ }
572
+ const json = (await res.json());
573
+ if (!json.access_token) {
574
+ throw new Error("Token exchange returned no access_token");
575
+ }
576
+ // Asana's token response includes `data: { gid, name, email }` for the
577
+ // authorizing user — use that if present, otherwise fall back to /users/me.
578
+ let username = json.data?.name;
579
+ let userGid = json.data?.gid ??
580
+ (typeof json.data?.id === "number" ? String(json.data.id) : undefined);
581
+ let userEmail = json.data?.email;
582
+ if (!username) {
583
+ try {
584
+ const userRes = await fetch(`${ASANA_API_BASE}/users/me`, {
585
+ headers: { Authorization: `Bearer ${json.access_token}` },
586
+ });
587
+ if (userRes.ok) {
588
+ const u = (await userRes.json());
589
+ username = u.data?.name;
590
+ userGid = u.data?.gid ?? userGid;
591
+ userEmail = u.data?.email ?? userEmail;
592
+ }
593
+ }
594
+ catch {
595
+ // best-effort
596
+ }
597
+ }
598
+ const expiresAt = typeof json.expires_in === "number" && json.expires_in > 0
599
+ ? Date.now() + json.expires_in * 1000
600
+ : undefined;
601
+ saveTokens({
602
+ access_token: json.access_token,
603
+ refresh_token: json.refresh_token,
604
+ expires_at: expiresAt,
605
+ scope: json.scope,
606
+ token_type: json.token_type ?? "Bearer",
607
+ _client_id: clientId() || undefined,
608
+ _client_secret: clientSecret() || undefined,
609
+ username,
610
+ user_gid: userGid,
611
+ email: userEmail,
612
+ connected_at: new Date().toISOString(),
613
+ });
614
+ resetAsanaConnector();
615
+ return {
616
+ status: 200,
617
+ contentType: "text/html",
618
+ body: `<html><body><h2>Asana connected${username ? ` as ${escHtml(username)}` : ""}</h2><script>try { window.opener.postMessage('patchwork:asana:connected', '*'); } catch(_) {} window.close();</script></body></html>`,
619
+ };
620
+ }
621
+ catch (err) {
622
+ return {
623
+ status: 400,
624
+ contentType: "text/html",
625
+ body: `<html><body><h2>Asana connect failed</h2><pre>${escHtml(err instanceof Error ? err.message : String(err))}</pre></body></html>`,
626
+ };
627
+ }
628
+ }
629
+ /**
630
+ * POST /connections/asana/test — verify stored token works.
631
+ */
632
+ export async function handleAsanaTest() {
633
+ const tokens = loadTokens();
634
+ if (!tokens) {
635
+ return {
636
+ status: 400,
637
+ contentType: "application/json",
638
+ body: JSON.stringify({ ok: false, error: "Asana not connected" }),
639
+ };
640
+ }
641
+ try {
642
+ const connector = getAsanaConnector();
643
+ const check = await connector.healthCheck();
644
+ return {
645
+ status: check.ok ? 200 : 401,
646
+ contentType: "application/json",
647
+ body: JSON.stringify(check.ok
648
+ ? { ok: true, username: tokens.username }
649
+ : { ok: false, error: check.error?.message }),
650
+ };
651
+ }
652
+ catch (err) {
653
+ return {
654
+ status: 500,
655
+ contentType: "application/json",
656
+ body: JSON.stringify({
657
+ ok: false,
658
+ error: err instanceof Error ? err.message : String(err),
659
+ }),
660
+ };
661
+ }
662
+ }
663
+ /**
664
+ * DELETE /connections/asana — clear stored tokens.
665
+ *
666
+ * Asana does not expose a public OAuth revocation endpoint, so we just drop
667
+ * local credentials. The token remains valid at Asana until it naturally
668
+ * expires (1h) or the user revokes the integration in their Asana account
669
+ * settings.
670
+ */
671
+ export async function handleAsanaDisconnect() {
672
+ clearTokens();
673
+ resetAsanaConnector();
674
+ return {
675
+ status: 200,
676
+ contentType: "application/json",
677
+ body: JSON.stringify({ ok: true }),
678
+ };
679
+ }
680
+ //# sourceMappingURL=asana.js.map