@devosurf/tesser-connectors 0.1.0-alpha.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.
@@ -0,0 +1,265 @@
1
+ // Google Drive connector — third connector behind the shared `google` Provider (one
2
+ // OAuth app backs gmail + google-calendar + google-drive). Ships least-privilege:
3
+ // drive.file is NON-SENSITIVE (per-file: only files the app created or the user opened
4
+ // with it). Listing/searching the whole Drive, or a changes feed over arbitrary files,
5
+ // needs the broader RESTRICTED `drive`/`drive.readonly` scope (security assessment).
6
+
7
+ import { z } from "zod";
8
+ import { action, defineConnector, oauth2, trigger, type ActionCtx } from "@devosurf/tesser-sdk/connector";
9
+ import { googleProvider } from "../providers/google.js";
10
+
11
+ const FOLDER_MIME = "application/vnd.google-apps.folder";
12
+ // fields projection shared by list/get/create/copy — keeps responses to our stable shape.
13
+ const FILE_FIELDS = "id,name,mimeType,modifiedTime,webViewLink";
14
+ const LIST_PAGE_CAP = 10;
15
+
16
+ const fileShape = z.object({
17
+ id: z.string(),
18
+ name: z.string(),
19
+ mimeType: z.string(),
20
+ modifiedTime: z.string(),
21
+ webViewLink: z.string().optional(),
22
+ });
23
+
24
+ type RawFile = {
25
+ id: string;
26
+ name?: string;
27
+ mimeType?: string;
28
+ modifiedTime?: string;
29
+ webViewLink?: string;
30
+ };
31
+
32
+ function mapFile(raw: RawFile): z.infer<typeof fileShape> {
33
+ return {
34
+ id: raw.id,
35
+ name: raw.name ?? "",
36
+ mimeType: raw.mimeType ?? "",
37
+ modifiedTime: raw.modifiedTime ?? "",
38
+ ...(raw.webViewLink !== undefined ? { webViewLink: raw.webViewLink } : {}),
39
+ };
40
+ }
41
+
42
+ // One files.list page. Caller owns the pageToken loop (no built-in paginator).
43
+ async function listPage(
44
+ ctx: ActionCtx,
45
+ params: { q?: string; pageSize: number; orderBy?: string; pageToken?: string },
46
+ ): Promise<{ files: RawFile[]; nextPageToken?: string }> {
47
+ const res = (await ctx.http.get("/files", {
48
+ query: {
49
+ fields: `nextPageToken,files(${FILE_FIELDS})`,
50
+ pageSize: params.pageSize,
51
+ ...(params.q !== undefined ? { q: params.q } : {}),
52
+ ...(params.orderBy !== undefined ? { orderBy: params.orderBy } : {}),
53
+ ...(params.pageToken !== undefined ? { pageToken: params.pageToken } : {}),
54
+ },
55
+ })) as { files?: RawFile[]; nextPageToken?: string };
56
+ return {
57
+ files: res.files ?? [],
58
+ ...(res.nextPageToken !== undefined ? { nextPageToken: res.nextPageToken } : {}),
59
+ };
60
+ }
61
+
62
+ export default defineConnector({
63
+ id: "google-drive",
64
+ describe: "Google Drive — list, fetch, create, copy, and share app-created or user-opened files",
65
+ provider: googleProvider,
66
+ baseUrl: "https://www.googleapis.com/drive/v3",
67
+ auth: oauth2({
68
+ provider: "google",
69
+ // drive.file is NON-SENSITIVE and least-privilege (app-created / user-opened files).
70
+ // A "list everything" connector would need the RESTRICTED `drive` scope.
71
+ scopes: ["https://www.googleapis.com/auth/drive.file"],
72
+ }),
73
+ // Drive has NO idempotency header, so writes (createFolder/copy/delete/permissions.create)
74
+ // do not auto-retry — correct for non-idempotent operations.
75
+ samples: {
76
+ "files.list": [
77
+ {
78
+ id: "1AbCfile",
79
+ name: "Quarterly report.pdf",
80
+ mimeType: "application/pdf",
81
+ modifiedTime: "2026-01-01T00:00:00.000Z",
82
+ webViewLink: "https://drive.google.com/file/d/1AbCfile/view",
83
+ },
84
+ ],
85
+ "files.get": {
86
+ id: "1AbCfile",
87
+ name: "Quarterly report.pdf",
88
+ mimeType: "application/pdf",
89
+ modifiedTime: "2026-01-01T00:00:00.000Z",
90
+ webViewLink: "https://drive.google.com/file/d/1AbCfile/view",
91
+ },
92
+ "files.createFolder": {
93
+ id: "1FolderId",
94
+ name: "Invoices",
95
+ mimeType: FOLDER_MIME,
96
+ modifiedTime: "2026-01-01T00:00:00.000Z",
97
+ webViewLink: "https://drive.google.com/drive/folders/1FolderId",
98
+ },
99
+ "files.copy": {
100
+ id: "1CopyId",
101
+ name: "Quarterly report (copy).pdf",
102
+ mimeType: "application/pdf",
103
+ modifiedTime: "2026-01-02T00:00:00.000Z",
104
+ webViewLink: "https://drive.google.com/file/d/1CopyId/view",
105
+ },
106
+ "files.delete": { id: "1AbCfile", deleted: true },
107
+ "permissions.create": { id: "perm123", role: "reader", type: "user" },
108
+ "trigger:newFileInFolder": {
109
+ id: "1NewFile",
110
+ name: "uploaded.csv",
111
+ mimeType: "text/csv",
112
+ modifiedTime: "2026-01-03T00:00:00.000Z",
113
+ webViewLink: "https://drive.google.com/file/d/1NewFile/view",
114
+ },
115
+ },
116
+ actions: {
117
+ files: {
118
+ list: action({
119
+ describe: "List/search files visible to the app with a raw Drive q query (loops pages, ~10-page cap)",
120
+ input: z.object({
121
+ q: z.string().optional(),
122
+ pageSize: z.number().int().min(1).max(100).default(100),
123
+ }),
124
+ output: z.array(fileShape),
125
+ safety: "read",
126
+ run: async (ctx, i) => {
127
+ const out: RawFile[] = [];
128
+ let pageToken: string | undefined;
129
+ for (let page = 0; page < LIST_PAGE_CAP; page++) {
130
+ const res = await listPage(ctx, {
131
+ pageSize: i.pageSize,
132
+ ...(i.q !== undefined ? { q: i.q } : {}),
133
+ ...(pageToken !== undefined ? { pageToken } : {}),
134
+ });
135
+ out.push(...res.files);
136
+ if (res.nextPageToken === undefined) break;
137
+ pageToken = res.nextPageToken;
138
+ }
139
+ return out.map(mapFile);
140
+ },
141
+ }),
142
+ get: action({
143
+ describe: "Fetch one file's metadata",
144
+ input: z.object({ fileId: z.string().min(1) }),
145
+ output: fileShape,
146
+ safety: "read",
147
+ run: async (ctx, i) => {
148
+ const res = (await ctx.http.get(`/files/${encodeURIComponent(i.fileId)}`, {
149
+ query: { fields: FILE_FIELDS },
150
+ })) as RawFile;
151
+ return mapFile(res);
152
+ },
153
+ }),
154
+ createFolder: action({
155
+ describe: "Create a folder (optionally under a parent folder)",
156
+ input: z.object({
157
+ name: z.string().min(1),
158
+ parents: z.array(z.string()).optional(),
159
+ }),
160
+ output: fileShape,
161
+ run: async (ctx, i) => {
162
+ const res = (await ctx.http.post(
163
+ "/files",
164
+ {
165
+ name: i.name,
166
+ mimeType: FOLDER_MIME,
167
+ ...(i.parents !== undefined ? { parents: i.parents } : {}),
168
+ },
169
+ { query: { fields: FILE_FIELDS } },
170
+ )) as RawFile;
171
+ return mapFile(res);
172
+ },
173
+ }),
174
+ copy: action({
175
+ describe: "Copy a file (no folder recursion; optionally rename/reparent)",
176
+ input: z.object({
177
+ fileId: z.string().min(1),
178
+ name: z.string().optional(),
179
+ parents: z.array(z.string()).optional(),
180
+ }),
181
+ output: fileShape,
182
+ run: async (ctx, i) => {
183
+ const res = (await ctx.http.post(
184
+ `/files/${encodeURIComponent(i.fileId)}/copy`,
185
+ {
186
+ ...(i.name !== undefined ? { name: i.name } : {}),
187
+ ...(i.parents !== undefined ? { parents: i.parents } : {}),
188
+ },
189
+ { query: { fields: FILE_FIELDS } },
190
+ )) as RawFile;
191
+ return mapFile(res);
192
+ },
193
+ }),
194
+ delete: action({
195
+ describe: "Permanently delete a file (cascades on folders, irreversible)",
196
+ input: z.object({ fileId: z.string().min(1) }),
197
+ output: z.object({ id: z.string(), deleted: z.literal(true) }),
198
+ run: async (ctx, i) => {
199
+ // DELETE returns 204 with an empty body.
200
+ await ctx.http.delete(`/files/${encodeURIComponent(i.fileId)}`);
201
+ return { id: i.fileId, deleted: true as const };
202
+ },
203
+ }),
204
+ },
205
+ permissions: {
206
+ create: action({
207
+ describe: "Grant a permission on a file (share with a user/group/domain/anyone)",
208
+ input: z.object({
209
+ fileId: z.string().min(1),
210
+ role: z.enum([
211
+ "reader",
212
+ "commenter",
213
+ "writer",
214
+ "fileOrganizer",
215
+ "organizer",
216
+ "owner",
217
+ ]),
218
+ type: z.enum(["user", "group", "domain", "anyone"]),
219
+ emailAddress: z.string().optional(),
220
+ }),
221
+ output: z.object({
222
+ id: z.string(),
223
+ role: z.string(),
224
+ type: z.string(),
225
+ }),
226
+ run: async (ctx, i) => {
227
+ const res = (await ctx.http.post(
228
+ `/files/${encodeURIComponent(i.fileId)}/permissions`,
229
+ {
230
+ role: i.role,
231
+ type: i.type,
232
+ ...(i.emailAddress !== undefined ? { emailAddress: i.emailAddress } : {}),
233
+ },
234
+ { query: { fields: "id,role,type" } },
235
+ )) as { id: string; role?: string; type?: string };
236
+ return { id: res.id, role: res.role ?? i.role, type: res.type ?? i.type };
237
+ },
238
+ }),
239
+ },
240
+ },
241
+ triggers: {
242
+ newFileInFolder: trigger.poll({
243
+ describe: "Fires for each new app-accessible file added to a folder (optionally filtered by mimeType)",
244
+ input: z.object({
245
+ folderId: z.string().min(1),
246
+ q: z.string().optional(),
247
+ }),
248
+ output: fileShape,
249
+ interval: { default: "1m", floor: "30s" },
250
+ order: "newest-first",
251
+ poll: async (ctx, params) => {
252
+ const clauses = [`'${params.folderId}' in parents`, "trashed = false"];
253
+ if (params.q !== undefined) clauses.push(params.q);
254
+ const res = await listPage(ctx, {
255
+ q: clauses.join(" and "),
256
+ pageSize: 50,
257
+ orderBy: "createdTime desc",
258
+ });
259
+ return res.files;
260
+ },
261
+ dedupeKey: (file: RawFile) => file.id,
262
+ map: (file: RawFile) => mapFile(file),
263
+ }),
264
+ },
265
+ });
@@ -0,0 +1,361 @@
1
+ // Google Sheets connector — behind the shared `google` Provider (one OAuth app backs
2
+ // gmail + google-calendar + google-sheets). Values ops are 1-based A1 by sheet TITLE;
3
+ // structural ops (addSheet) go through :batchUpdate. No native webhook — newRow polls.
4
+
5
+ import { z } from "zod";
6
+ import {
7
+ action,
8
+ defineConnector,
9
+ oauth2,
10
+ trigger,
11
+ type ClassifyError,
12
+ } from "@devosurf/tesser-sdk/connector";
13
+ import { googleProvider } from "../providers/google.js";
14
+
15
+ // Google 403 is overloaded: rate-limit 403 is retryable, permission 403 is terminal.
16
+ // Default mapping makes every 4xx terminal, so promote the rate-limit reasons (the
17
+ // reason text rides in the body snippet) back to retry; 429 already retries.
18
+ const googleRetry: ClassifyError = (err) =>
19
+ err.status === 403 && /rateLimitExceeded|userRateLimitExceeded/.test(err.bodySnippet)
20
+ ? "retry"
21
+ : undefined;
22
+
23
+ // valueInputOption has NO usable default on write methods (the unspecified enum value
24
+ // "must not be used"), so it is always sent. USER_ENTERED parses '=A1+B1' and '5/1'
25
+ // like the UI; RAW stores them as literal text.
26
+ const valueInputOption = z.enum(["USER_ENTERED", "RAW"]).default("USER_ENTERED");
27
+
28
+ const newRowInput = z.object({
29
+ spreadsheetId: z.string().min(1),
30
+ range: z.string().min(1),
31
+ // Explicit by design: rowIndex suits append-only sheets; keyColumn survives inserts
32
+ // above the watched range but requires a stable, unique cell value.
33
+ dedupeMode: z.enum(["rowIndex", "keyColumn"]),
34
+ keyColumnIndex: z.number().int().min(0).optional(),
35
+ }).superRefine((input, ctx) => {
36
+ if (input.dedupeMode === "keyColumn" && input.keyColumnIndex === undefined) {
37
+ ctx.addIssue({
38
+ code: "custom",
39
+ path: ["keyColumnIndex"],
40
+ message: "keyColumnIndex is required when dedupeMode is keyColumn",
41
+ });
42
+ }
43
+ });
44
+
45
+ const valueRange = z.object({
46
+ range: z.string(),
47
+ values: z.array(z.array(z.string())),
48
+ });
49
+
50
+ // updatedRange/updatedRows/updatedColumns/updatedCells live INSIDE "updates" on the
51
+ // append response (AppendValuesResponse.updates), not at the top level.
52
+ const updateCounts = z.object({
53
+ updatedRange: z.string(),
54
+ updatedRows: z.number(),
55
+ updatedColumns: z.number(),
56
+ updatedCells: z.number(),
57
+ });
58
+
59
+ const sheetMeta = z.object({ sheetId: z.number(), title: z.string() });
60
+
61
+ type RawValueRange = { range?: string; values?: unknown[][] };
62
+ type RawUpdateCounts = {
63
+ updatedRange?: string;
64
+ updatedRows?: number;
65
+ updatedColumns?: number;
66
+ updatedCells?: number;
67
+ };
68
+
69
+ // Range reads omit trailing empty cells (jagged rows) and a fully empty range returns
70
+ // no `values` at all; coerce every cell to a string and tolerate the missing field.
71
+ function rows(raw: RawValueRange): string[][] {
72
+ return (raw.values ?? []).map((r) => r.map((c) => (c == null ? "" : String(c))));
73
+ }
74
+
75
+ function counts(raw: RawUpdateCounts): z.infer<typeof updateCounts> {
76
+ return {
77
+ updatedRange: raw.updatedRange ?? "",
78
+ updatedRows: raw.updatedRows ?? 0,
79
+ updatedColumns: raw.updatedColumns ?? 0,
80
+ updatedCells: raw.updatedCells ?? 0,
81
+ };
82
+ }
83
+
84
+ // {range} path segments carry '!' ':' and spaces — encode them (as gmail/calendar do).
85
+ function valuesPath(spreadsheetId: string, range: string, suffix = ""): string {
86
+ return `/spreadsheets/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}${suffix}`;
87
+ }
88
+
89
+ export default defineConnector({
90
+ id: "google-sheets",
91
+ describe: "Google Sheets — read and write spreadsheet values and structure",
92
+ provider: googleProvider,
93
+ baseUrl: "https://sheets.googleapis.com/v4",
94
+ auth: oauth2({
95
+ provider: "google",
96
+ scopes: [
97
+ "https://www.googleapis.com/auth/spreadsheets",
98
+ "https://www.googleapis.com/auth/drive.file",
99
+ ],
100
+ }),
101
+ samples: {
102
+ "values.get": { range: "Sheet1!A1:B2", values: [["a", "b"], ["c", "d"]] },
103
+ "values.append": {
104
+ spreadsheetId: "1abc",
105
+ tableRange: "Sheet1!A1:B2",
106
+ updates: {
107
+ updatedRange: "Sheet1!A3:B3",
108
+ updatedRows: 1,
109
+ updatedColumns: 2,
110
+ updatedCells: 2,
111
+ },
112
+ },
113
+ "values.update": {
114
+ spreadsheetId: "1abc",
115
+ updatedRange: "Sheet1!A1:B1",
116
+ updatedRows: 1,
117
+ updatedColumns: 2,
118
+ updatedCells: 2,
119
+ },
120
+ "values.clear": { spreadsheetId: "1abc", clearedRange: "Sheet1!A1:B2" },
121
+ "spreadsheets.get": {
122
+ spreadsheetId: "1abc",
123
+ title: "My Sheet",
124
+ sheets: [{ sheetId: 0, title: "Sheet1" }],
125
+ },
126
+ "spreadsheets.create": {
127
+ spreadsheetId: "1abc",
128
+ title: "My Sheet",
129
+ url: "https://docs.google.com/spreadsheets/d/1abc/edit",
130
+ },
131
+ "spreadsheets.addSheet": { sheetId: 1, title: "Sheet2", index: 1 },
132
+ "trigger:newRow": {
133
+ spreadsheetId: "1abc",
134
+ range: "Sheet1!A2:Z",
135
+ rowIndex: 0,
136
+ values: ["alice", "alice@example.com"],
137
+ },
138
+ },
139
+ actions: {
140
+ values: {
141
+ get: action({
142
+ describe: "Read a range as rows (majorDimension ROWS)",
143
+ input: z.object({
144
+ spreadsheetId: z.string().min(1),
145
+ range: z.string().min(1),
146
+ }),
147
+ output: valueRange,
148
+ safety: "read",
149
+ classifyError: googleRetry,
150
+ run: async (ctx, i) => {
151
+ const res = (await ctx.http.get(valuesPath(i.spreadsheetId, i.range), {
152
+ query: { majorDimension: "ROWS" },
153
+ })) as RawValueRange;
154
+ return { range: res.range ?? i.range, values: rows(res) };
155
+ },
156
+ }),
157
+ append: action({
158
+ describe: "Append rows after the table touching {range} (non-idempotent)",
159
+ input: z.object({
160
+ spreadsheetId: z.string().min(1),
161
+ range: z.string().min(1),
162
+ values: z.array(z.array(z.string())).min(1),
163
+ valueInputOption,
164
+ }),
165
+ output: z.object({
166
+ spreadsheetId: z.string(),
167
+ tableRange: z.string().optional(),
168
+ updates: updateCounts,
169
+ }),
170
+ // Non-idempotent: a re-run duplicates rows. Stays a plain write (no retrySafe).
171
+ classifyError: googleRetry,
172
+ run: async (ctx, i) => {
173
+ const res = (await ctx.http.post(
174
+ valuesPath(i.spreadsheetId, i.range, ":append"),
175
+ { values: i.values },
176
+ {
177
+ query: {
178
+ valueInputOption: i.valueInputOption,
179
+ insertDataOption: "INSERT_ROWS",
180
+ },
181
+ },
182
+ )) as { spreadsheetId?: string; tableRange?: string; updates?: RawUpdateCounts };
183
+ return {
184
+ spreadsheetId: res.spreadsheetId ?? i.spreadsheetId,
185
+ ...(res.tableRange !== undefined ? { tableRange: res.tableRange } : {}),
186
+ updates: counts(res.updates ?? {}),
187
+ };
188
+ },
189
+ }),
190
+ update: action({
191
+ describe: "Overwrite a fixed range (idempotent-in-effect)",
192
+ input: z.object({
193
+ spreadsheetId: z.string().min(1),
194
+ range: z.string().min(1),
195
+ values: z.array(z.array(z.string())).min(1),
196
+ valueInputOption,
197
+ }),
198
+ output: z.object({ spreadsheetId: z.string() }).and(updateCounts),
199
+ // Fixed-range overwrite is idempotent in effect, so retrying is safe.
200
+ retrySafe: true,
201
+ classifyError: googleRetry,
202
+ run: async (ctx, i) => {
203
+ const res = (await ctx.http.put(
204
+ valuesPath(i.spreadsheetId, i.range),
205
+ { values: i.values },
206
+ { query: { valueInputOption: i.valueInputOption } },
207
+ )) as { spreadsheetId?: string } & RawUpdateCounts;
208
+ return { spreadsheetId: res.spreadsheetId ?? i.spreadsheetId, ...counts(res) };
209
+ },
210
+ }),
211
+ clear: action({
212
+ describe: "Clear values in a range (formatting kept; idempotent)",
213
+ input: z.object({
214
+ spreadsheetId: z.string().min(1),
215
+ range: z.string().min(1),
216
+ }),
217
+ output: z.object({ spreadsheetId: z.string(), clearedRange: z.string() }),
218
+ retrySafe: true,
219
+ classifyError: googleRetry,
220
+ run: async (ctx, i) => {
221
+ // The :clear body MUST be an empty object.
222
+ const res = (await ctx.http.post(
223
+ valuesPath(i.spreadsheetId, i.range, ":clear"),
224
+ {},
225
+ )) as { spreadsheetId?: string; clearedRange?: string };
226
+ return {
227
+ spreadsheetId: res.spreadsheetId ?? i.spreadsheetId,
228
+ clearedRange: res.clearedRange ?? i.range,
229
+ };
230
+ },
231
+ }),
232
+ },
233
+ spreadsheets: {
234
+ get: action({
235
+ describe: "Fetch spreadsheet metadata (title + sheet list, no grid)",
236
+ input: z.object({ spreadsheetId: z.string().min(1) }),
237
+ output: z.object({
238
+ spreadsheetId: z.string(),
239
+ title: z.string(),
240
+ sheets: z.array(sheetMeta),
241
+ }),
242
+ safety: "read",
243
+ classifyError: googleRetry,
244
+ run: async (ctx, i) => {
245
+ // Always send a fields mask; includeGridData would return the entire grid.
246
+ const res = (await ctx.http.get(
247
+ `/spreadsheets/${encodeURIComponent(i.spreadsheetId)}`,
248
+ { query: { fields: "spreadsheetId,properties.title,sheets.properties" } },
249
+ )) as {
250
+ spreadsheetId?: string;
251
+ properties?: { title?: string };
252
+ sheets?: Array<{ properties?: { sheetId?: number; title?: string } }>;
253
+ };
254
+ return {
255
+ spreadsheetId: res.spreadsheetId ?? i.spreadsheetId,
256
+ title: res.properties?.title ?? "",
257
+ sheets: (res.sheets ?? []).map((s) => ({
258
+ sheetId: s.properties?.sheetId ?? 0,
259
+ title: s.properties?.title ?? "",
260
+ })),
261
+ };
262
+ },
263
+ }),
264
+ create: action({
265
+ describe: "Create a spreadsheet (lands an app-scoped Drive file; non-idempotent)",
266
+ input: z.object({ title: z.string().min(1) }),
267
+ output: z.object({
268
+ spreadsheetId: z.string(),
269
+ title: z.string(),
270
+ url: z.string(),
271
+ }),
272
+ classifyError: googleRetry,
273
+ run: async (ctx, i) => {
274
+ const res = (await ctx.http.post("/spreadsheets", {
275
+ properties: { title: i.title },
276
+ })) as {
277
+ spreadsheetId?: string;
278
+ properties?: { title?: string };
279
+ spreadsheetUrl?: string;
280
+ };
281
+ return {
282
+ spreadsheetId: res.spreadsheetId ?? "",
283
+ title: res.properties?.title ?? i.title,
284
+ url: res.spreadsheetUrl ?? "",
285
+ };
286
+ },
287
+ }),
288
+ addSheet: action({
289
+ describe: "Add a tab via :batchUpdate addSheet",
290
+ input: z.object({
291
+ spreadsheetId: z.string().min(1),
292
+ title: z.string().min(1),
293
+ }),
294
+ output: sheetMeta.extend({ index: z.number() }),
295
+ classifyError: googleRetry,
296
+ run: async (ctx, i) => {
297
+ const res = (await ctx.http.post(
298
+ `/spreadsheets/${encodeURIComponent(i.spreadsheetId)}:batchUpdate`,
299
+ { requests: [{ addSheet: { properties: { title: i.title } } }] },
300
+ )) as {
301
+ replies?: Array<{
302
+ addSheet?: { properties?: { sheetId?: number; title?: string; index?: number } };
303
+ }>;
304
+ };
305
+ const props = res.replies?.[0]?.addSheet?.properties ?? {};
306
+ return {
307
+ sheetId: props.sheetId ?? 0,
308
+ title: props.title ?? i.title,
309
+ index: props.index ?? 0,
310
+ };
311
+ },
312
+ }),
313
+ },
314
+ },
315
+ triggers: {
316
+ newRow: trigger.poll({
317
+ describe: "Fires for each new row in a range (poll-only; Sheets has no webhook)",
318
+ input: newRowInput,
319
+ output: z.object({
320
+ spreadsheetId: z.string(),
321
+ range: z.string(),
322
+ rowIndex: z.number(),
323
+ values: z.array(z.string()),
324
+ }),
325
+ // One read per poll; respect the 60 reads/min/user ceiling.
326
+ interval: { default: "1m", floor: "30s" },
327
+ // Rows read top-to-bottom; runtime fires new ones oldest-first.
328
+ order: "oldest-first",
329
+ // poll() carries the explicit dedupe mode onto each row so dedupeKey (item-only)
330
+ // can read it; map() strips it back to the stable output shape.
331
+ poll: async (ctx, params) => {
332
+ const res = (await ctx.http.get(valuesPath(params.spreadsheetId, params.range), {
333
+ query: { majorDimension: "ROWS" },
334
+ })) as RawValueRange;
335
+ return rows(res).map((values, rowIndex) => ({
336
+ spreadsheetId: params.spreadsheetId,
337
+ range: params.range,
338
+ rowIndex,
339
+ values,
340
+ dedupeMode: params.dedupeMode,
341
+ keyColumnIndex: params.keyColumnIndex,
342
+ }));
343
+ },
344
+ // rowIndex mode is append-only but preserves duplicate rows; keyColumn mode is
345
+ // shift-safe for inserts/deletes above the range when the chosen cell is stable.
346
+ dedupeKey: (item) => {
347
+ if (item.dedupeMode === "keyColumn") {
348
+ const cell = item.values[item.keyColumnIndex ?? -1] ?? "";
349
+ return `${item.spreadsheetId}:${item.range}:key:${item.keyColumnIndex}:${cell}`;
350
+ }
351
+ return `${item.spreadsheetId}:${item.range}:row:${item.rowIndex}`;
352
+ },
353
+ map: (item) => ({
354
+ spreadsheetId: item.spreadsheetId,
355
+ range: item.range,
356
+ rowIndex: item.rowIndex,
357
+ values: item.values,
358
+ }),
359
+ }),
360
+ },
361
+ });
package/http/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ // Generic HTTP connector — the escape hatch for services without a typed connector
2
+ // (CONTEXT.md "Secret" spirit: not the default reach). No real credential of its own;
3
+ // pair it with `secrets: { … }` on the automation when a bespoke endpoint needs one.
4
+
5
+ import { z } from "zod";
6
+ import { action, customAuth, defineConnector } from "@devosurf/tesser-sdk/connector";
7
+
8
+ const requestInput = z.object({
9
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]).default("GET"),
10
+ url: z.string().url(),
11
+ headers: z.record(z.string(), z.string()).optional(),
12
+ query: z.record(z.string(), z.string()).optional(),
13
+ /** JSON-encoded unless `bodyText` is used instead. */
14
+ body: z.unknown().optional(),
15
+ bodyText: z.string().optional(),
16
+ });
17
+
18
+ const response = z.object({
19
+ status: z.number(),
20
+ body: z.unknown(),
21
+ headers: z.record(z.string(), z.string()),
22
+ });
23
+
24
+ async function perform(
25
+ http: import("@devosurf/tesser-sdk/connector").ActionCtx["http"],
26
+ i: z.infer<typeof requestInput>,
27
+ ): Promise<z.infer<typeof response>> {
28
+ const res = (await http.request({
29
+ method: i.method,
30
+ path: i.url,
31
+ raw: true,
32
+ ...(i.headers !== undefined ? { headers: i.headers } : {}),
33
+ ...(i.query !== undefined ? { query: i.query } : {}),
34
+ ...(i.bodyText !== undefined ? { body: i.bodyText } : i.body !== undefined ? { body: i.body } : {}),
35
+ })) as Response;
36
+ const text = await res.text();
37
+ let body: unknown = text;
38
+ try {
39
+ body = JSON.parse(text);
40
+ } catch {
41
+ // keep text
42
+ }
43
+ const headers: Record<string, string> = {};
44
+ res.headers.forEach((v, k) => {
45
+ headers[k] = v;
46
+ });
47
+ return { status: res.status, body, headers };
48
+ }
49
+
50
+ export default defineConnector({
51
+ id: "http",
52
+ describe: "Plain HTTP requests to any endpoint",
53
+ // No credential fields: the connection is ready the moment it is created.
54
+ auth: customAuth({ describe: "No credential — requests go out as-is", sign: () => {} }),
55
+ samples: {
56
+ request: { status: 200, body: { ok: true }, headers: { "content-type": "application/json" } },
57
+ get: { status: 200, body: { ok: true }, headers: { "content-type": "application/json" } },
58
+ },
59
+ actions: {
60
+ request: action({
61
+ describe: "Perform an HTTP request (any method)",
62
+ input: requestInput,
63
+ output: response,
64
+ // A generic request can be a write; never auto-retry it.
65
+ retrySafe: false,
66
+ run: async (ctx, i) => perform(ctx.http, i),
67
+ }),
68
+ get: action({
69
+ describe: "Perform an HTTP GET",
70
+ input: requestInput.pick({ url: true, headers: true, query: true }),
71
+ output: response,
72
+ safety: "read",
73
+ run: async (ctx, i) => perform(ctx.http, { ...i, method: "GET" }),
74
+ }),
75
+ },
76
+ });