@isomorph.ai/app-sdk 1.6.2 → 1.8.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/README.md CHANGED
@@ -36,6 +36,93 @@ Replace the connection/resource with the app's existing approved integration. To
36
36
 
37
37
  For images call `generateImage({model: gateway.image(process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS!), prompt: 'A blue notebook'})`. Editing uses `prompt: {text: 'Make it red', images: [inputBytes]}` only when `isomorph ai models` advertises edit. Reuse app files upload/signed reads for authorized input and output storage; respect existing transfer/action limits and never return credentials.
38
38
 
39
+ ## Company integrations
40
+
41
+ `isomorph.integrations.execute(connection, request)` is one method over a closed
42
+ set of operations; the operation fixes whose identity it runs under, and the
43
+ contract's bounds are checked in the client before any request is sent (the
44
+ same sentence the platform answers with). The connection, operation, resource
45
+ and `mode` are literals in the call: the kit gate derives
46
+ `.isomorph/integrations.json` from them. `isomorph dev` without `--real`
47
+ answers every operation from a local fixture.
48
+
49
+ Slack. A history, post, edit or delete runs as the app or as the signed-in
50
+ person (`mode`, the one IT approved for the app); a DM and a user lookup run
51
+ as the app only, against the fixed `workspace` resource. A post or DM needs a
52
+ fresh UUID per explicit Send press.
53
+
54
+ ```ts
55
+ // A thread reply in Slack mrkdwn
56
+ const page = await isomorph.integrations.execute('company-slack', {
57
+ operation: 'slack.channel.history', resource: 'team-updates', input: { limit: 100 },
58
+ });
59
+ await isomorph.integrations.execute('company-slack', {
60
+ operation: 'slack.message.post', resource: 'team-updates',
61
+ input: { text: '*Done* — see <https://example.com/report|the report>', format: 'mrkdwn', threadId: page.messages[0].id },
62
+ idempotencyKey: crypto.randomUUID(), mode: 'app',
63
+ });
64
+
65
+ // Paged history: hasMore and nextCursor say when and how to read the next (older) page
66
+ const older = await isomorph.integrations.execute('company-slack', {
67
+ operation: 'slack.channel.history', resource: 'team-updates', input: { limit: 100, cursor: page.nextCursor },
68
+ });
69
+
70
+ // Edit or delete a message the app posted (its ts is the messageId)
71
+ const posted = await isomorph.integrations.execute('company-slack', {
72
+ operation: 'slack.message.post', resource: 'team-updates', input: { text: 'Deploy starting' }, idempotencyKey: crypto.randomUUID(), mode: 'app',
73
+ });
74
+ await isomorph.integrations.execute('company-slack', {
75
+ operation: 'slack.message.update', resource: 'team-updates', input: { messageId: posted.messageId, text: 'Deploy finished' }, mode: 'app',
76
+ });
77
+ await isomorph.integrations.execute('company-slack', {
78
+ operation: 'slack.message.delete', resource: 'team-updates', input: { messageId: posted.messageId }, mode: 'app',
79
+ });
80
+
81
+ // A direct message by email (1 to 8 people; several open one group DM) and a user lookup
82
+ await isomorph.integrations.execute('company-slack', {
83
+ operation: 'slack.dm.send', resource: 'workspace', input: { to: ['jane@example.com'], text: 'Your review is due today.' },
84
+ idempotencyKey: crypto.randomUUID(),
85
+ });
86
+ const { userId } = await isomorph.integrations.execute('company-slack', {
87
+ operation: 'slack.user.lookup', resource: 'workspace', input: { email: 'jane@example.com' },
88
+ });
89
+ ```
90
+
91
+ Gmail runs as the signed-in person after their consent, on the fixed `inbox`
92
+ resource.
93
+
94
+ ```ts
95
+ // Search, then read a whole thread
96
+ const found = await isomorph.integrations.execute('company-gmail', {
97
+ operation: 'gmail.thread.list', resource: 'inbox', input: { q: 'from:jane newer_than:7d', limit: 50 },
98
+ });
99
+ const thread = await isomorph.integrations.execute('company-gmail', {
100
+ operation: 'gmail.thread.read', resource: 'inbox', input: { threadId: found.threads[0].id },
101
+ });
102
+ // found.nextPageToken, when hasMore, is the next read's pageToken
103
+
104
+ // An attachment: a message or thread read names them (id, filename, mimeType, size); this fetches the bytes
105
+ const file = thread.emails[0].attachments?.[0];
106
+ if (file) {
107
+ const { dataBase64 } = await isomorph.integrations.execute('company-gmail', {
108
+ operation: 'gmail.attachment.read', resource: 'inbox', input: { messageId: thread.emails[0].id, attachmentId: file.id },
109
+ });
110
+ }
111
+
112
+ // An HTML send: text stays the plain-text alternative; up to 5 attachments of 2 MiB each
113
+ await isomorph.integrations.execute('company-gmail', {
114
+ operation: 'gmail.message.send', resource: 'inbox',
115
+ input: {
116
+ to: ['jane@example.com'], subject: 'Weekly report', text: 'The report is attached.',
117
+ html: '<p>The report is <b>attached</b>.</p>',
118
+ attachments: [{ filename: 'report.csv', contentType: 'text/csv', dataBase64: csvBase64 }],
119
+ },
120
+ idempotencyKey: crypto.randomUUID(),
121
+ });
122
+ ```
123
+
124
+ (1.8.0; contracts 0.71.0)
125
+
39
126
  ## Browser read tools
40
127
 
41
128
  Install optional peers `ai` and `playwright-core@1.58.2`. Locally run `npx playwright-core install chromium --only-shell`; V2 packages Chromium in the existing app image. An existing system executable can be supplied as executablePath.
package/dist/index.d.ts CHANGED
@@ -24,47 +24,161 @@ export interface IsomorphResult<T> {
24
24
  data: T;
25
25
  count?: number;
26
26
  }
27
+ /** How Slack renders a message's `text`: plain text, or Slack mrkdwn (`*bold*`, `<url|label>`, `<@U123>`); plain when absent. */
28
+ export type SlackTextFormat = 'plain' | 'mrkdwn';
29
+ /**
30
+ * Which of the modes IT approved for this app a declared-mode Slack operation runs under: `app` (the company's
31
+ * Slack app, under the IT-approved per-app name and footer) or `user` (the signed-in person, after their consent).
32
+ * Leave it out when the app is approved for one mode; an app approved for both must say (MODE_REQUIRED), and a
33
+ * mode IT has not approved is refused with MODE_NOT_GRANTED — never served under the other mode.
34
+ */
35
+ export type SlackIdentityMode = 'app' | 'user';
36
+ /** Reads a channel's newest messages as the signed-in person (or as the app, when declared so); pages backwards with `cursor`. */
27
37
  export interface SlackChannelHistoryRequest {
28
38
  operation: 'slack.channel.history';
29
39
  resource: string;
30
40
  input?: {
31
- /** How many newest messages to read: an integer from 1 to 15 (the platform's ceiling); default 15. */
41
+ /** How many messages to read: an integer from 1 to 100 (the platform's ceiling); default 15. */
32
42
  limit?: number;
43
+ /** The `nextCursor` of the previous page; leave it out to read the newest page. */
44
+ cursor?: string;
45
+ /** Only messages after this Slack ts (a message id). */
46
+ oldest?: string;
47
+ /** Only messages before this Slack ts (a message id). */
48
+ latest?: string;
33
49
  };
50
+ mode?: SlackIdentityMode;
34
51
  }
52
+ /** Posts one message to a channel, as the app or as the signed-in person; `threadId` makes it a thread reply. */
35
53
  export interface SlackMessagePostRequest {
36
54
  operation: 'slack.message.post';
37
55
  resource: string;
38
56
  input: {
39
- /** Plain text, 1 to 4000 characters. Posted as the app (under the IT-approved per-app name) or as the signed-in person (their consent), the same text either way. */
57
+ /** The body, 1 to 4000 characters; plain text unless `format` is `mrkdwn`. Posted as the app (under the IT-approved per-app name) or as the signed-in person (their consent), the same text either way. */
40
58
  text: string;
59
+ /** The Slack ts of the message this one replies to (a history message id), so the post lands in that thread. */
60
+ threadId?: string;
61
+ format?: SlackTextFormat;
41
62
  };
42
63
  /** A fresh UUID per explicit Send press, reused only to retry that press. */
43
64
  idempotencyKey: string;
44
- /**
45
- * Which of the modes IT approved for this app the post runs under: `app` (the company's Slack app, under the
46
- * IT-approved per-app name and footer) or `user` (the signed-in person, after their consent). Leave it out when
47
- * the app is approved for one mode; an app approved for both must say (MODE_REQUIRED), and a mode IT has not
48
- * approved is refused with MODE_NOT_GRANTED — never served under the other mode.
49
- */
50
- mode?: 'app' | 'user';
65
+ mode?: SlackIdentityMode;
51
66
  }
67
+ /** Edits a message the app (or the signed-in person, in user mode) posted; the whole `text` is replaced. */
68
+ export interface SlackMessageUpdateRequest {
69
+ operation: 'slack.message.update';
70
+ resource: string;
71
+ input: {
72
+ /** The Slack ts a post or history result named as `messageId`. */
73
+ messageId: string;
74
+ /** The new body, 1 to 4000 characters. */
75
+ text: string;
76
+ format?: SlackTextFormat;
77
+ };
78
+ mode?: SlackIdentityMode;
79
+ }
80
+ /** Deletes a message the app (or the signed-in person, in user mode) posted. */
81
+ export interface SlackMessageDeleteRequest {
82
+ operation: 'slack.message.delete';
83
+ resource: string;
84
+ input: {
85
+ /** The Slack ts a post or history result named as `messageId`. */
86
+ messageId: string;
87
+ };
88
+ mode?: SlackIdentityMode;
89
+ }
90
+ /**
91
+ * Sends a direct message as the company's Slack app to workspace members named by email (always app mode; the
92
+ * resource is the fixed `workspace` alias). Several recipients open one group DM.
93
+ */
94
+ export interface SlackDmSendRequest {
95
+ operation: 'slack.dm.send';
96
+ resource: string;
97
+ input: {
98
+ /** 1 to 8 email addresses of workspace members, no repeats. */
99
+ to: string[];
100
+ /** The body, 1 to 4000 characters. */
101
+ text: string;
102
+ format?: SlackTextFormat;
103
+ };
104
+ /** A fresh UUID per explicit Send press, reused only to retry that press. */
105
+ idempotencyKey: string;
106
+ }
107
+ /** Resolves a workspace member's email to their Slack user id, as the app (the resource is the fixed `workspace` alias). */
108
+ export interface SlackUserLookupRequest {
109
+ operation: 'slack.user.lookup';
110
+ resource: string;
111
+ input: {
112
+ /** The member's email address. */
113
+ email: string;
114
+ };
115
+ }
116
+ /**
117
+ * One condition on a warehouse read; a read's conditions are AND-ed. `isNull`/`isNotNull` take no value, `in`/`notIn`
118
+ * a non-empty list of one type, every other op one value (a boolean only with `eq`/`ne`). The column and op are
119
+ * literals in the call; the value may be any expression.
120
+ */
121
+ export interface WarehouseFilter {
122
+ column: string;
123
+ op: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn' | 'isNull' | 'isNotNull';
124
+ value?: string | number | boolean | Array<string | number>;
125
+ }
126
+ /** One aggregate a warehouse read returns under `as`: `count` may count `'*'`; sum, avg, min and max need a column. */
127
+ export interface WarehouseAggregate {
128
+ fn: 'count' | 'sum' | 'avg' | 'min' | 'max';
129
+ column: string;
130
+ as: string;
131
+ }
132
+ /** One sort key: a returned column or an aggregate's alias; `direction` defaults to `'asc'`. */
133
+ export interface WarehouseOrder {
134
+ column: string;
135
+ direction?: 'asc' | 'desc';
136
+ }
137
+ /**
138
+ * Filter, group and sort in the warehouse, never by pulling raw rows to total in code. A read names `columns`
139
+ * (a plain read) or `aggregates` (grouped by `groupBy`, or one row for the whole view), never both. Every column
140
+ * the read names must be approved for the view.
141
+ */
52
142
  export interface WarehouseViewReadRequest {
53
143
  operation: 'warehouse.view.read';
54
144
  resource: string;
55
145
  input: {
56
- /** 1 to 32 approved column names, or `['*']` when IT exposes all current and future columns. */
57
- columns: string[];
146
+ /** A plain read: 1 to 32 approved column names, or `['*']` when IT exposes all current and future columns. */
147
+ columns?: string[];
148
+ /** 1 to 16 conditions, all of which a row must meet. */
149
+ where?: WarehouseFilter[];
150
+ /** 1 to 8 columns to group the aggregates by; needs `aggregates`. */
151
+ groupBy?: string[];
152
+ /** 1 to 16 aggregates; each row carries the `groupBy` columns, then each alias. */
153
+ aggregates?: WarehouseAggregate[];
154
+ /** 1 to 8 sort keys. A grouped read with none is sorted by its `groupBy` columns; a plain read with none is unsorted. */
155
+ orderBy?: WarehouseOrder[];
58
156
  /** How many rows to read: an integer from 1 to 1000; default 100. */
59
157
  limit?: number;
60
158
  };
61
159
  }
62
- /** Gmail runs as the signed-in person after their consent. Resources are approved mailbox aliases. */
160
+ /** Gmail runs as the signed-in person after their consent. Resources are approved mailbox aliases (`inbox`). */
63
161
  export interface GmailThreadListRequest {
64
162
  operation: 'gmail.thread.list';
65
163
  resource: string;
66
164
  input?: {
165
+ /** Newest matching threads, 1 to 50; default 15. */
67
166
  limit?: number;
167
+ /** A Gmail search query in the search-box syntax, e.g. `from:jane newer_than:7d`, 1 to 256 characters. */
168
+ q?: string;
169
+ /** 1 to 5 labels a thread must carry (all of them); `['INBOX']` when absent. */
170
+ labelIds?: string[];
171
+ /** The `nextPageToken` of the previous page; leave it out to read the first page. */
172
+ pageToken?: string;
173
+ };
174
+ }
175
+ /** Reads every message of one thread, oldest first, as the signed-in person. */
176
+ export interface GmailThreadReadRequest {
177
+ operation: 'gmail.thread.read';
178
+ resource: string;
179
+ input: {
180
+ /** The thread id a `gmail.thread.list` result named. */
181
+ threadId: string;
68
182
  };
69
183
  }
70
184
  export interface GmailMessageReadRequest {
@@ -74,24 +188,47 @@ export interface GmailMessageReadRequest {
74
188
  messageId: string;
75
189
  };
76
190
  }
191
+ /** Reads one attachment's bytes (at most 2 MiB) as the signed-in person; the ids come from a message or thread read. */
192
+ export interface GmailAttachmentReadRequest {
193
+ operation: 'gmail.attachment.read';
194
+ resource: string;
195
+ input: {
196
+ messageId: string;
197
+ /** The `id` of an entry in the message's `attachments`. */
198
+ attachmentId: string;
199
+ };
200
+ }
201
+ /** One file to attach to a send, carried inline as base64: at most 2 MiB of bytes (2,796,203 base64 characters). */
202
+ export interface GmailAttachmentInput {
203
+ /** The name the recipient sees, 1 to 255 characters. */
204
+ filename: string;
205
+ /** The MIME type, e.g. `application/pdf`. */
206
+ contentType: string;
207
+ /** The file bytes, standard base64. */
208
+ dataBase64: string;
209
+ }
77
210
  export interface GmailMessageSendRequest {
78
211
  operation: 'gmail.message.send';
79
212
  resource: string;
80
213
  input: {
81
- /** 1 to 50 email addresses per recipient list. */
214
+ /** 1 to 50 email addresses per recipient list (at most 50 across to, cc and bcc). */
82
215
  to: string[];
83
216
  cc?: string[];
84
217
  bcc?: string[];
85
218
  /** 1 to 256 characters. */
86
219
  subject: string;
87
- /** Plain text, 1 to 4000 characters. Attachments and HTML are not supported. */
220
+ /** Plain text, 1 to 4000 characters; the plain-text alternative when `html` is sent too. */
88
221
  text: string;
222
+ /** An HTML body sent beside `text`, at most 64 KiB. */
223
+ html?: string;
224
+ /** 1 to 5 files, each at most 2 MiB. */
225
+ attachments?: GmailAttachmentInput[];
89
226
  replyToMessageId?: string;
90
227
  };
91
228
  /** A fresh UUID per authorized Send action, reused only to retry that action. */
92
229
  idempotencyKey: string;
93
230
  }
94
- export type IntegrationRequest = SlackChannelHistoryRequest | SlackMessagePostRequest | WarehouseViewReadRequest | GmailThreadListRequest | GmailMessageReadRequest | GmailMessageSendRequest;
231
+ export type IntegrationRequest = SlackChannelHistoryRequest | SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest | SlackDmSendRequest | SlackUserLookupRequest | WarehouseViewReadRequest | GmailThreadListRequest | GmailThreadReadRequest | GmailMessageReadRequest | GmailAttachmentReadRequest | GmailMessageSendRequest;
95
232
  export type IntegrationOperation = IntegrationRequest['operation'];
96
233
  export interface SlackMessage {
97
234
  id: string;
@@ -106,12 +243,27 @@ export interface SlackMessage {
106
243
  export interface SlackChannelHistoryResult {
107
244
  messages: SlackMessage[];
108
245
  hasMore: boolean;
246
+ /** The cursor of the next (older) page, present only when `hasMore`; pass it as the next read's `cursor`. */
247
+ nextCursor?: string;
109
248
  }
249
+ /** The Slack ts of the message posted, edited or deleted. */
110
250
  export interface SlackMessagePostResult {
111
251
  messageId: string;
112
252
  }
253
+ export interface SlackDmSendResult {
254
+ messageId: string;
255
+ /** The DM channel the message went to. */
256
+ channelId: string;
257
+ }
258
+ export interface SlackUserLookupResult {
259
+ userId: string;
260
+ /** The member's display name, or real name when they set none; may be empty. */
261
+ displayName: string;
262
+ }
113
263
  export interface WarehouseViewReadResult {
114
264
  rows: Array<Record<string, unknown>>;
265
+ /** True when more rows matched than `limit`: the rows are incomplete, so never total or average them in code. */
266
+ truncated: boolean;
115
267
  }
116
268
  export interface GmailThreadSummary {
117
269
  id: string;
@@ -124,6 +276,16 @@ export interface GmailThreadSummary {
124
276
  export interface GmailThreadListResult {
125
277
  threads: GmailThreadSummary[];
126
278
  hasMore: boolean;
279
+ /** The token of the next page, present only when `hasMore`; pass it as the next read's `pageToken`. */
280
+ nextPageToken?: string;
281
+ }
282
+ /** One attachment of a message, named without its bytes; `gmail.attachment.read` fetches them by `id`. */
283
+ export interface GmailAttachment {
284
+ id: string;
285
+ filename: string;
286
+ mimeType: string;
287
+ /** Bytes. */
288
+ size: number;
127
289
  }
128
290
  export interface GmailMessageReadResult {
129
291
  id: string;
@@ -132,14 +294,27 @@ export interface GmailMessageReadResult {
132
294
  from: string;
133
295
  to: string;
134
296
  date: string;
297
+ /** The plain text with HTML stripped, cut at 16 KiB with `truncated` set. */
135
298
  text: string;
136
299
  truncated: boolean;
300
+ attachments?: GmailAttachment[];
301
+ }
302
+ /** Every message of the thread, oldest first, each in the `gmail.message.read` shape. */
303
+ export interface GmailThreadReadResult {
304
+ id: string;
305
+ emails: GmailMessageReadResult[];
306
+ }
307
+ export interface GmailAttachmentReadResult {
308
+ filename: string;
309
+ mimeType: string;
310
+ size: number;
311
+ dataBase64: string;
137
312
  }
138
313
  export interface GmailMessageSendResult {
139
314
  messageId: string;
140
315
  threadId: string;
141
316
  }
142
- export type IntegrationResult<R extends IntegrationRequest> = R extends SlackChannelHistoryRequest ? SlackChannelHistoryResult : R extends SlackMessagePostRequest ? SlackMessagePostResult : R extends WarehouseViewReadRequest ? WarehouseViewReadResult : R extends GmailThreadListRequest ? GmailThreadListResult : R extends GmailMessageReadRequest ? GmailMessageReadResult : R extends GmailMessageSendRequest ? GmailMessageSendResult : never;
317
+ export type IntegrationResult<R extends IntegrationRequest> = R extends SlackChannelHistoryRequest ? SlackChannelHistoryResult : R extends SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest ? SlackMessagePostResult : R extends SlackDmSendRequest ? SlackDmSendResult : R extends SlackUserLookupRequest ? SlackUserLookupResult : R extends WarehouseViewReadRequest ? WarehouseViewReadResult : R extends GmailThreadListRequest ? GmailThreadListResult : R extends GmailThreadReadRequest ? GmailThreadReadResult : R extends GmailMessageReadRequest ? GmailMessageReadResult : R extends GmailAttachmentReadRequest ? GmailAttachmentReadResult : R extends GmailMessageSendRequest ? GmailMessageSendResult : never;
143
318
  export type IntegrationConnectResult = {
144
319
  status: 'connected';
145
320
  accountLabel: string;
package/dist/index.js CHANGED
@@ -16,6 +16,25 @@ export class IsomorphError extends Error {
16
16
  this.name = 'IsomorphError';
17
17
  }
18
18
  }
19
+ // The warehouse rules the generated table cannot state, with governance's
20
+ // sentences: a read names columns or aggregates (one of them; it once needed
21
+ // columns), and "*" stands alone. Governance owns the query's other rules.
22
+ function warehouseReadProblem({ columns, aggregates }) {
23
+ if (columns != null && aggregates != null)
24
+ return { field: 'columns', message: 'a warehouse read names columns or aggregates, not both' };
25
+ if (columns == null && aggregates == null)
26
+ return { field: 'columns', message: 'a warehouse read needs columns or aggregates' };
27
+ if (columns?.includes('*') && columns.length !== 1)
28
+ return { field: 'columns', message: 'columns may use "*" only as the sole entry' };
29
+ return undefined;
30
+ }
31
+ // The operations whose request carries a `mode`, and the only ones it is sent
32
+ // for (every other operation's mode is fixed, and the platform refuses the
33
+ // field there). The key type is derived from the union, so adding or removing
34
+ // `mode` on a request without changing this table does not compile.
35
+ const declaredModeOperations = {
36
+ 'slack.channel.history': true, 'slack.message.post': true, 'slack.message.update': true, 'slack.message.delete': true,
37
+ };
19
38
  /** The AI reason code carried by an error, when it is a governed-AI refusal. */
20
39
  export function aiErrorCode(error) {
21
40
  const code = error instanceof IsomorphError ? error.details?.code : undefined;
@@ -659,18 +678,16 @@ export function createClient(options = {}) {
659
678
  // `isomorph check` (which runs retained journeys through this client)
660
679
  // and the deployed app fail the same way at build time, not at runtime.
661
680
  const checked = validateIntegrationExecuteInput(request.operation, request.input);
662
- if (!checked.ok) {
663
- return Promise.reject(new IsomorphError('VALIDATION_FAILED', checked.problem.message, undefined, { code: 'INPUT_INVALID', field: checked.problem.field }));
664
- }
665
- if (request.operation === 'warehouse.view.read' && Array.isArray(request.input?.columns) && request.input.columns.includes('*') && request.input.columns.length !== 1) {
666
- return Promise.reject(new IsomorphError('VALIDATION_FAILED', 'columns may use "*" only as the sole entry', undefined, { code: 'INPUT_INVALID', field: 'columns' }));
681
+ const problem = !checked.ok ? checked.problem
682
+ : request.operation === 'warehouse.view.read' ? warehouseReadProblem(request.input ?? {}) : undefined;
683
+ if (problem) {
684
+ return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
667
685
  }
668
- // Exactly the contract's fields go on the wire: `mode` is one of them
669
- // for a Slack post only (which approved mode it runs under); an
670
- // `identity` or any other stray field is not sent.
686
+ // Exactly the contract's fields go on the wire: `mode` only for the
687
+ // declaredModeOperations; an `identity` or any other stray field is not sent.
671
688
  const { operation, resource, input, idempotencyKey, mode } = request;
672
689
  return transport.json('/_harbour/integrations/execute', {
673
- method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation === 'slack.message.post' && mode !== undefined ? { mode } : {}) }),
690
+ method: 'POST', body: JSON.stringify({ connection, operation, resource, ...(input !== undefined ? { input } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), ...(operation in declaredModeOperations && mode !== undefined ? { mode } : {}) }),
674
691
  });
675
692
  },
676
693
  // In a browser the consent returns the person to this page (without its
@@ -28,7 +28,31 @@ export type IntegrationExecuteInputFieldRule = {
28
28
  maxLength?: number;
29
29
  pattern?: string;
30
30
  };
31
+ default?: readonly string[];
32
+ } | {
33
+ kind: 'enum';
34
+ required: boolean;
35
+ values: readonly string[];
36
+ default?: string;
37
+ } | {
38
+ kind: 'object[]';
39
+ required: boolean;
40
+ minItems: number;
41
+ maxItems: number;
42
+ fields: Readonly<Record<string, IntegrationExecuteInputFieldRule>>;
43
+ } | {
44
+ kind: 'value';
45
+ required: boolean;
46
+ types: readonly IntegrationExecuteInputValueType[];
47
+ maxLength?: number;
48
+ list?: {
49
+ minItems: number;
50
+ maxItems: number;
51
+ types: readonly IntegrationExecuteInputValueType[];
52
+ };
31
53
  };
54
+ /** The scalar types a `value` input accepts, alone or in its list form. */
55
+ export type IntegrationExecuteInputValueType = 'string' | 'number' | 'boolean';
32
56
  export type IntegrationExecuteOperationRule = {
33
57
  idempotencyKey: boolean;
34
58
  fields: Record<string, IntegrationExecuteInputFieldRule>;
@@ -41,9 +65,27 @@ export declare const IntegrationExecuteInputRules: {
41
65
  readonly kind: "integer";
42
66
  readonly required: false;
43
67
  readonly minimum: 1;
44
- readonly maximum: 15;
68
+ readonly maximum: 100;
45
69
  readonly default: 15;
46
70
  };
71
+ readonly cursor: {
72
+ readonly kind: "string";
73
+ readonly required: false;
74
+ readonly minLength: 1;
75
+ readonly maxLength: 256;
76
+ };
77
+ readonly oldest: {
78
+ readonly kind: "string";
79
+ readonly required: false;
80
+ readonly minLength: 1;
81
+ readonly maxLength: 64;
82
+ };
83
+ readonly latest: {
84
+ readonly kind: "string";
85
+ readonly required: false;
86
+ readonly minLength: 1;
87
+ readonly maxLength: 64;
88
+ };
47
89
  };
48
90
  };
49
91
  readonly "slack.message.post": {
@@ -55,6 +97,93 @@ export declare const IntegrationExecuteInputRules: {
55
97
  readonly minLength: 1;
56
98
  readonly maxLength: 4000;
57
99
  };
100
+ readonly threadId: {
101
+ readonly kind: "string";
102
+ readonly required: false;
103
+ readonly minLength: 0;
104
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
105
+ };
106
+ readonly format: {
107
+ readonly kind: "enum";
108
+ readonly required: false;
109
+ readonly values: readonly ["plain", "mrkdwn"];
110
+ readonly default: "plain";
111
+ };
112
+ };
113
+ };
114
+ readonly "slack.message.update": {
115
+ readonly idempotencyKey: false;
116
+ readonly fields: {
117
+ readonly messageId: {
118
+ readonly kind: "string";
119
+ readonly required: true;
120
+ readonly minLength: 0;
121
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
122
+ };
123
+ readonly text: {
124
+ readonly kind: "string";
125
+ readonly required: true;
126
+ readonly minLength: 1;
127
+ readonly maxLength: 4000;
128
+ };
129
+ readonly format: {
130
+ readonly kind: "enum";
131
+ readonly required: false;
132
+ readonly values: readonly ["plain", "mrkdwn"];
133
+ readonly default: "plain";
134
+ };
135
+ };
136
+ };
137
+ readonly "slack.message.delete": {
138
+ readonly idempotencyKey: false;
139
+ readonly fields: {
140
+ readonly messageId: {
141
+ readonly kind: "string";
142
+ readonly required: true;
143
+ readonly minLength: 0;
144
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
145
+ };
146
+ };
147
+ };
148
+ readonly "slack.dm.send": {
149
+ readonly idempotencyKey: true;
150
+ readonly fields: {
151
+ readonly to: {
152
+ readonly kind: "string[]";
153
+ readonly required: true;
154
+ readonly minItems: 1;
155
+ readonly maxItems: 8;
156
+ readonly uniqueItems: true;
157
+ readonly item: {
158
+ readonly minLength: 3;
159
+ readonly maxLength: 254;
160
+ readonly pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$";
161
+ };
162
+ };
163
+ readonly text: {
164
+ readonly kind: "string";
165
+ readonly required: true;
166
+ readonly minLength: 1;
167
+ readonly maxLength: 4000;
168
+ };
169
+ readonly format: {
170
+ readonly kind: "enum";
171
+ readonly required: false;
172
+ readonly values: readonly ["plain", "mrkdwn"];
173
+ readonly default: "plain";
174
+ };
175
+ };
176
+ };
177
+ readonly "slack.user.lookup": {
178
+ readonly idempotencyKey: false;
179
+ readonly fields: {
180
+ readonly email: {
181
+ readonly kind: "string";
182
+ readonly required: true;
183
+ readonly minLength: 3;
184
+ readonly maxLength: 254;
185
+ readonly pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$";
186
+ };
58
187
  };
59
188
  };
60
189
  readonly "gmail.thread.list": {
@@ -64,9 +193,44 @@ export declare const IntegrationExecuteInputRules: {
64
193
  readonly kind: "integer";
65
194
  readonly required: false;
66
195
  readonly minimum: 1;
67
- readonly maximum: 15;
196
+ readonly maximum: 50;
68
197
  readonly default: 15;
69
198
  };
199
+ readonly q: {
200
+ readonly kind: "string";
201
+ readonly required: false;
202
+ readonly minLength: 1;
203
+ readonly maxLength: 256;
204
+ };
205
+ readonly labelIds: {
206
+ readonly kind: "string[]";
207
+ readonly required: false;
208
+ readonly minItems: 1;
209
+ readonly maxItems: 5;
210
+ readonly uniqueItems: true;
211
+ readonly item: {
212
+ readonly minLength: 1;
213
+ readonly maxLength: 64;
214
+ };
215
+ readonly default: readonly ["INBOX"];
216
+ };
217
+ readonly pageToken: {
218
+ readonly kind: "string";
219
+ readonly required: false;
220
+ readonly minLength: 1;
221
+ readonly maxLength: 256;
222
+ };
223
+ };
224
+ };
225
+ readonly "gmail.thread.read": {
226
+ readonly idempotencyKey: false;
227
+ readonly fields: {
228
+ readonly threadId: {
229
+ readonly kind: "string";
230
+ readonly required: true;
231
+ readonly minLength: 0;
232
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
233
+ };
70
234
  };
71
235
  };
72
236
  readonly "gmail.message.read": {
@@ -76,7 +240,7 @@ export declare const IntegrationExecuteInputRules: {
76
240
  readonly kind: "string";
77
241
  readonly required: true;
78
242
  readonly minLength: 0;
79
- readonly pattern: "^[A-Za-z0-9_-]{1,64}$";
243
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
80
244
  };
81
245
  };
82
246
  };
@@ -131,6 +295,38 @@ export declare const IntegrationExecuteInputRules: {
131
295
  readonly minLength: 1;
132
296
  readonly maxLength: 4000;
133
297
  };
298
+ readonly html: {
299
+ readonly kind: "string";
300
+ readonly required: false;
301
+ readonly minLength: 1;
302
+ readonly maxLength: 65536;
303
+ };
304
+ readonly attachments: {
305
+ readonly kind: "object[]";
306
+ readonly required: false;
307
+ readonly minItems: 1;
308
+ readonly maxItems: 5;
309
+ readonly fields: {
310
+ readonly filename: {
311
+ readonly kind: "string";
312
+ readonly required: true;
313
+ readonly minLength: 1;
314
+ readonly maxLength: 255;
315
+ };
316
+ readonly contentType: {
317
+ readonly kind: "string";
318
+ readonly required: true;
319
+ readonly minLength: 1;
320
+ readonly maxLength: 128;
321
+ };
322
+ readonly dataBase64: {
323
+ readonly kind: "string";
324
+ readonly required: true;
325
+ readonly minLength: 1;
326
+ readonly maxLength: 2796203;
327
+ };
328
+ };
329
+ };
134
330
  readonly replyToMessageId: {
135
331
  readonly kind: "string";
136
332
  readonly required: false;
@@ -139,12 +335,29 @@ export declare const IntegrationExecuteInputRules: {
139
335
  };
140
336
  };
141
337
  };
338
+ readonly "gmail.attachment.read": {
339
+ readonly idempotencyKey: false;
340
+ readonly fields: {
341
+ readonly messageId: {
342
+ readonly kind: "string";
343
+ readonly required: true;
344
+ readonly minLength: 0;
345
+ readonly pattern: "^[A-Za-z0-9._-]{1,64}$";
346
+ };
347
+ readonly attachmentId: {
348
+ readonly kind: "string";
349
+ readonly required: true;
350
+ readonly minLength: 1;
351
+ readonly maxLength: 512;
352
+ };
353
+ };
354
+ };
142
355
  readonly "warehouse.view.read": {
143
356
  readonly idempotencyKey: false;
144
357
  readonly fields: {
145
358
  readonly columns: {
146
359
  readonly kind: "string[]";
147
- readonly required: true;
360
+ readonly required: false;
148
361
  readonly minItems: 1;
149
362
  readonly maxItems: 32;
150
363
  readonly uniqueItems: false;
@@ -153,6 +366,92 @@ export declare const IntegrationExecuteInputRules: {
153
366
  readonly pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$";
154
367
  };
155
368
  };
369
+ readonly where: {
370
+ readonly kind: "object[]";
371
+ readonly required: false;
372
+ readonly minItems: 1;
373
+ readonly maxItems: 16;
374
+ readonly fields: {
375
+ readonly column: {
376
+ readonly kind: "string";
377
+ readonly required: true;
378
+ readonly minLength: 0;
379
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$";
380
+ };
381
+ readonly op: {
382
+ readonly kind: "enum";
383
+ readonly required: true;
384
+ readonly values: readonly ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"];
385
+ };
386
+ readonly value: {
387
+ readonly kind: "value";
388
+ readonly required: false;
389
+ readonly types: readonly ["string", "number", "boolean"];
390
+ readonly maxLength: 256;
391
+ readonly list: {
392
+ readonly minItems: 1;
393
+ readonly maxItems: 100;
394
+ readonly types: readonly ["string", "number"];
395
+ };
396
+ };
397
+ };
398
+ };
399
+ readonly groupBy: {
400
+ readonly kind: "string[]";
401
+ readonly required: false;
402
+ readonly minItems: 1;
403
+ readonly maxItems: 8;
404
+ readonly uniqueItems: false;
405
+ readonly item: {
406
+ readonly minLength: 0;
407
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$";
408
+ };
409
+ };
410
+ readonly aggregates: {
411
+ readonly kind: "object[]";
412
+ readonly required: false;
413
+ readonly minItems: 1;
414
+ readonly maxItems: 16;
415
+ readonly fields: {
416
+ readonly fn: {
417
+ readonly kind: "enum";
418
+ readonly required: true;
419
+ readonly values: readonly ["count", "sum", "avg", "min", "max"];
420
+ };
421
+ readonly column: {
422
+ readonly kind: "string";
423
+ readonly required: true;
424
+ readonly minLength: 0;
425
+ readonly pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$";
426
+ };
427
+ readonly as: {
428
+ readonly kind: "string";
429
+ readonly required: true;
430
+ readonly minLength: 0;
431
+ readonly pattern: "^[a-z_][a-z0-9_]{0,62}$";
432
+ };
433
+ };
434
+ };
435
+ readonly orderBy: {
436
+ readonly kind: "object[]";
437
+ readonly required: false;
438
+ readonly minItems: 1;
439
+ readonly maxItems: 8;
440
+ readonly fields: {
441
+ readonly column: {
442
+ readonly kind: "string";
443
+ readonly required: true;
444
+ readonly minLength: 0;
445
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$";
446
+ };
447
+ readonly direction: {
448
+ readonly kind: "enum";
449
+ readonly required: false;
450
+ readonly values: readonly ["asc", "desc"];
451
+ readonly default: "asc";
452
+ };
453
+ };
454
+ };
156
455
  readonly limit: {
157
456
  readonly kind: "integer";
158
457
  readonly required: false;
@@ -4,25 +4,67 @@ export const IntegrationExecuteInputRules = {
4
4
  "slack.channel.history": {
5
5
  idempotencyKey: false,
6
6
  fields: {
7
- limit: { kind: "integer", required: false, minimum: 1, maximum: 15, default: 15 },
7
+ limit: { kind: "integer", required: false, minimum: 1, maximum: 100, default: 15 },
8
+ cursor: { kind: "string", required: false, minLength: 1, maxLength: 256 },
9
+ oldest: { kind: "string", required: false, minLength: 1, maxLength: 64 },
10
+ latest: { kind: "string", required: false, minLength: 1, maxLength: 64 },
8
11
  },
9
12
  },
10
13
  "slack.message.post": {
11
14
  idempotencyKey: true,
12
15
  fields: {
13
16
  text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
17
+ threadId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
18
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
19
+ },
20
+ },
21
+ "slack.message.update": {
22
+ idempotencyKey: false,
23
+ fields: {
24
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
25
+ text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
26
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
27
+ },
28
+ },
29
+ "slack.message.delete": {
30
+ idempotencyKey: false,
31
+ fields: {
32
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
33
+ },
34
+ },
35
+ "slack.dm.send": {
36
+ idempotencyKey: true,
37
+ fields: {
38
+ to: { kind: "string[]", required: true, minItems: 1, maxItems: 8, uniqueItems: true, item: { minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" } },
39
+ text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
40
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
41
+ },
42
+ },
43
+ "slack.user.lookup": {
44
+ idempotencyKey: false,
45
+ fields: {
46
+ email: { kind: "string", required: true, minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" },
14
47
  },
15
48
  },
16
49
  "gmail.thread.list": {
17
50
  idempotencyKey: false,
18
51
  fields: {
19
- limit: { kind: "integer", required: false, minimum: 1, maximum: 15, default: 15 },
52
+ limit: { kind: "integer", required: false, minimum: 1, maximum: 50, default: 15 },
53
+ q: { kind: "string", required: false, minLength: 1, maxLength: 256 },
54
+ labelIds: { kind: "string[]", required: false, minItems: 1, maxItems: 5, uniqueItems: true, item: { minLength: 1, maxLength: 64 }, default: ["INBOX"] },
55
+ pageToken: { kind: "string", required: false, minLength: 1, maxLength: 256 },
56
+ },
57
+ },
58
+ "gmail.thread.read": {
59
+ idempotencyKey: false,
60
+ fields: {
61
+ threadId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
20
62
  },
21
63
  },
22
64
  "gmail.message.read": {
23
65
  idempotencyKey: false,
24
66
  fields: {
25
- messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9_-]{1,64}$" },
67
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
26
68
  },
27
69
  },
28
70
  "gmail.message.send": {
@@ -33,13 +75,41 @@ export const IntegrationExecuteInputRules = {
33
75
  bcc: { kind: "string[]", required: false, minItems: 1, maxItems: 50, uniqueItems: true, item: { minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" } },
34
76
  subject: { kind: "string", required: true, minLength: 1, maxLength: 256 },
35
77
  text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
78
+ html: { kind: "string", required: false, minLength: 1, maxLength: 65536 },
79
+ attachments: { kind: "object[]", required: false, minItems: 1, maxItems: 5, fields: {
80
+ filename: { kind: "string", required: true, minLength: 1, maxLength: 255 },
81
+ contentType: { kind: "string", required: true, minLength: 1, maxLength: 128 },
82
+ dataBase64: { kind: "string", required: true, minLength: 1, maxLength: 2796203 },
83
+ } },
36
84
  replyToMessageId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9_-]{1,64}$" },
37
85
  },
38
86
  },
87
+ "gmail.attachment.read": {
88
+ idempotencyKey: false,
89
+ fields: {
90
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
91
+ attachmentId: { kind: "string", required: true, minLength: 1, maxLength: 512 },
92
+ },
93
+ },
39
94
  "warehouse.view.read": {
40
95
  idempotencyKey: false,
41
96
  fields: {
42
- columns: { kind: "string[]", required: true, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
97
+ columns: { kind: "string[]", required: false, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
98
+ where: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
99
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
100
+ op: { kind: "enum", required: true, values: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"] },
101
+ value: { kind: "value", required: false, types: ["string", "number", "boolean"], maxLength: 256, list: { minItems: 1, maxItems: 100, types: ["string", "number"] } },
102
+ } },
103
+ groupBy: { kind: "string[]", required: false, minItems: 1, maxItems: 8, uniqueItems: false, item: { minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" } },
104
+ aggregates: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
105
+ fn: { kind: "enum", required: true, values: ["count", "sum", "avg", "min", "max"] },
106
+ column: { kind: "string", required: true, minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" },
107
+ as: { kind: "string", required: true, minLength: 0, pattern: "^[a-z_][a-z0-9_]{0,62}$" },
108
+ } },
109
+ orderBy: { kind: "object[]", required: false, minItems: 1, maxItems: 8, fields: {
110
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
111
+ direction: { kind: "enum", required: false, values: ["asc", "desc"], default: "asc" },
112
+ } },
43
113
  limit: { kind: "integer", required: false, minimum: 1, maximum: 1000, default: 100 },
44
114
  },
45
115
  },
@@ -60,55 +130,113 @@ function matches(pattern, value) {
60
130
  }
61
131
  return compiled.test(value);
62
132
  }
133
+ function refuse(field, message) { return { ok: false, problem: { field, message } }; }
134
+ const valueNouns = { string: ['a string', 'strings'], number: ['a number', 'numbers'], boolean: ['a boolean', 'booleans'] };
135
+ function either(parts) {
136
+ return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}` : parts.join('');
137
+ }
138
+ function isScalar(types, maxLength, value) {
139
+ if (typeof value === 'string')
140
+ return types.includes('string') && (maxLength === undefined || value.length <= maxLength);
141
+ if (typeof value === 'number')
142
+ return types.includes('number') && Number.isFinite(value);
143
+ return typeof value === 'boolean' && types.includes('boolean');
144
+ }
63
145
  /**
64
- * Validates one operation's input against the contract and returns it with the
65
- * contract's defaults applied. The message names the field and its allowed
66
- * range; it is the sentence the platform answers with, so a caller may show it.
146
+ * Checks one object's fields in rule order and applies defaults: the input
147
+ * itself (where null counts as absent), or one entry of an object[] input
148
+ * (where only a missing key is absent, so null is refused like any wrong value).
67
149
  */
68
- export function validateIntegrationExecuteInput(operation, input) {
69
- const rule = IntegrationExecuteInputRules[operation];
70
- if (!rule)
71
- return { ok: false, problem: { field: 'operation', message: `${operation} is not a declared integration operation` } };
72
- const raw = input === undefined ? {} : input;
73
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
74
- return { ok: false, problem: { field: 'input', message: 'input must be an object' } };
75
- const given = raw;
150
+ function checkFields(operation, prefix, fields, given, nullIsAbsent) {
76
151
  for (const name of Object.keys(given))
77
- if (!Object.prototype.hasOwnProperty.call(rule.fields, name))
78
- return { ok: false, problem: { field: name, message: `${name} is not an input of ${operation}` } };
152
+ if (!Object.prototype.hasOwnProperty.call(fields, name))
153
+ return refuse(prefix + name, `${prefix}${name} is not an input of ${operation}`);
79
154
  const out = {};
80
- for (const [name, field] of Object.entries(rule.fields)) {
155
+ for (const [name, field] of Object.entries(fields)) {
156
+ const path = prefix + name;
81
157
  const value = given[name];
82
- if (value === undefined || value === null) {
158
+ if (value === undefined || (nullIsAbsent && value === null)) {
83
159
  if (field.required)
84
- return { ok: false, problem: { field: name, message: `${name} is required for ${operation}` } };
85
- if (field.kind === 'integer' && field.default !== undefined)
160
+ return refuse(path, `${path} is required for ${operation}`);
161
+ if ((field.kind === 'integer' || field.kind === 'enum') && field.default !== undefined)
86
162
  out[name] = field.default;
163
+ if (field.kind === 'string[]' && field.default !== undefined)
164
+ out[name] = [...field.default];
87
165
  continue;
88
166
  }
89
- if (field.kind === 'integer') {
167
+ const checked = checkField(operation, path, name, field, value);
168
+ if (!checked.ok)
169
+ return checked;
170
+ out[name] = checked.value;
171
+ }
172
+ return { ok: true, value: out };
173
+ }
174
+ function checkField(operation, path, name, field, value) {
175
+ switch (field.kind) {
176
+ case 'integer':
90
177
  if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum)
91
- return { ok: false, problem: { field: name, message: `${name} must be an integer between ${field.minimum} and ${field.maximum}` } };
92
- }
93
- else if (field.kind === 'string') {
178
+ return refuse(path, `${path} must be an integer between ${field.minimum} and ${field.maximum}`);
179
+ return { ok: true, value };
180
+ case 'string':
94
181
  if (typeof value !== 'string' || value.length < field.minLength || (field.maxLength !== undefined && value.length > field.maxLength))
95
- return { ok: false, problem: { field: name, message: field.maxLength !== undefined ? `${name} must be ${field.minLength} to ${field.maxLength} characters` : `${name} must be a string of at least ${field.minLength} characters` } };
182
+ return refuse(path, field.maxLength !== undefined ? `${path} must be ${field.minLength} to ${field.maxLength} characters` : `${path} must be a string of at least ${field.minLength} characters`);
96
183
  if (field.pattern && !matches(field.pattern, value))
97
- return { ok: false, problem: { field: name, message: `${name} must match ${field.pattern}` } };
98
- }
99
- else {
184
+ return refuse(path, `${path} must match ${field.pattern}`);
185
+ return { ok: true, value };
186
+ case 'enum':
187
+ if (typeof value !== 'string' || !field.values.includes(value))
188
+ return refuse(path, `${path} must be one of ${field.values.join(', ')}`);
189
+ return { ok: true, value };
190
+ case 'string[]': {
100
191
  if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems)
101
- return { ok: false, problem: { field: name, message: `${name} must list ${field.minItems} to ${field.maxItems} items` } };
192
+ return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
102
193
  const seen = new Set();
103
194
  for (const [index, item] of value.entries()) {
104
195
  if (typeof item !== 'string' || item.length < field.item.minLength || (field.item.maxLength !== undefined && item.length > field.item.maxLength) || (field.item.pattern !== undefined && !matches(field.item.pattern, item)))
105
- return { ok: false, problem: { field: name, message: `${name}[${index}] is not a valid ${name} entry` } };
196
+ return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
106
197
  if (field.uniqueItems && seen.has(item))
107
- return { ok: false, problem: { field: name, message: `${name} must not repeat an entry` } };
198
+ return refuse(path, `${path} must not repeat an entry`);
108
199
  seen.add(item);
109
200
  }
201
+ return { ok: true, value };
202
+ }
203
+ case 'object[]': {
204
+ if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems)
205
+ return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
206
+ const entries = [];
207
+ for (const [index, item] of value.entries()) {
208
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
209
+ return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
210
+ const entry = checkFields(operation, `${path}[${index}].`, field.fields, item, false);
211
+ if (!entry.ok)
212
+ return entry;
213
+ entries.push(entry.value);
214
+ }
215
+ return { ok: true, value: entries };
216
+ }
217
+ case 'value': {
218
+ const { types, maxLength, list } = field;
219
+ if (isScalar(types, maxLength, value) || (list !== undefined && Array.isArray(value) && value.length >= list.minItems && value.length <= list.maxItems && value.every(item => isScalar(list.types, maxLength, item))))
220
+ return { ok: true, value };
221
+ const shapes = types.map(type => (type === 'string' && maxLength !== undefined ? `a string of at most ${maxLength} characters` : valueNouns[type][0]));
222
+ if (list !== undefined)
223
+ shapes.push(`a list of ${list.minItems} to ${list.maxItems} ${either(list.types.map(type => valueNouns[type][1]))}`);
224
+ return refuse(path, `${path} must be ${either(shapes)}`);
110
225
  }
111
- out[name] = value;
112
226
  }
113
- return { ok: true, input: out };
227
+ }
228
+ /**
229
+ * Validates one operation's input against the contract and returns it with the
230
+ * contract's defaults applied. The message names the field and its allowed
231
+ * range; it is the sentence the platform answers with, so a caller may show it.
232
+ */
233
+ export function validateIntegrationExecuteInput(operation, input) {
234
+ const rule = IntegrationExecuteInputRules[operation];
235
+ if (!rule)
236
+ return refuse('operation', `${operation} is not a declared integration operation`);
237
+ const raw = input === undefined ? {} : input;
238
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
239
+ return refuse('input', 'input must be an object');
240
+ const checked = checkFields(operation, '', rule.fields, raw, true);
241
+ return checked.ok ? { ok: true, input: checked.value } : checked;
114
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.6.2",
3
+ "version": "1.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",