@fourier-labs/harbour-contracts 0.69.0 → 0.71.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Slack carries an installation and a channel map; Gmail is user-consent only and read-only.
2
+ * Slack carries an installation and a channel map; Gmail is user-consent only.
3
3
  */
4
4
  export type SaasProvider = 'slack' | 'gmail';
5
5
  /**
@@ -7,6 +7,18 @@ export type SaasProvider = 'slack' | 'gmail';
7
7
  */
8
8
  export type SaasConnectionStatus = 'ACTIVE' | 'DISABLED';
9
9
  export type SaasInstallationStatus = 'installed' | 'reconnect_required' | 'revoked';
10
+ /**
11
+ * How a user-mode access request on this connection is approved (0.71.0): automatic — the person's own consent is the approval, since the app only ever acts as them; it — an IT admin reviews it like an app-mode request. Absent means it.
12
+ */
13
+ export type UserModeApproval = 'automatic' | 'it';
14
+ /**
15
+ * signed_in_person — only the person using the app; channel_members — the members of the named channel; anyone — any workspace member.
16
+ */
17
+ export type DirectMessageAudience = 'signed_in_person' | 'channel_members' | 'anyone';
18
+ /**
19
+ * block — every recipient must be internal (the default when absent); allowlist — internal or in `allowedDomains`; allow — any address.
20
+ */
21
+ export type ExternalRecipientMode = 'block' | 'allowlist' | 'allow';
10
22
  /**
11
23
  */
12
24
  export type InstallationName = string;
@@ -39,13 +51,17 @@ export interface SaasConnection {
39
51
  [k: string]: string;
40
52
  };
41
53
  /**
42
- * Operations IT allows apps to request on this connection: the provider's own SaaS operations (Slack: slack.channel.history, slack.message.post; Gmail: gmail.thread.list, gmail.message.read, gmail.message.send). Never warehouse.view.read.
54
+ * Operations IT allows apps to request on this connection: the provider's own SaaS operations (Slack: slack.channel.history, slack.message.post, slack.message.update, slack.message.delete, slack.dm.send, slack.user.lookup; Gmail: gmail.thread.list, gmail.thread.read, gmail.message.read, gmail.message.send, gmail.attachment.read). Never warehouse.view.read.
43
55
  *
44
56
  * @minItems 1
45
- * @maxItems 5
57
+ * @maxItems 11
46
58
  */
47
- operations: ('slack.channel.history' | 'slack.message.post' | 'gmail.thread.list' | 'gmail.message.read' | 'gmail.message.send' | 'warehouse.view.read')[];
59
+ operations: ('slack.channel.history' | 'slack.message.post' | 'slack.message.update' | 'slack.message.delete' | 'slack.dm.send' | 'slack.user.lookup' | 'gmail.thread.list' | 'gmail.thread.read' | 'gmail.message.read' | 'gmail.message.send' | 'gmail.attachment.read' | 'warehouse.view.read')[];
48
60
  developmentPreapproval: DevelopmentPreapproval;
61
+ oauthClient?: SaasOauthClient;
62
+ userModeApproval?: UserModeApproval;
63
+ directMessages?: DirectMessagePolicy;
64
+ externalRecipients?: ExternalRecipientPolicy;
49
65
  /**
50
66
  * Lower-cased builder emails excluded from development preapproval.
51
67
  *
@@ -114,6 +130,48 @@ export interface DevelopmentPreapproval {
114
130
  */
115
131
  hours: number;
116
132
  }
133
+ /**
134
+ * The tenant's own OAuth client for this provider (0.71.0; the ADR 0068 follow-up): when present, installation and user consent run through the company's Slack app or Google Cloud project instead of the platform-owned one, so the tenant's IT sees and controls the app in their own admin console. A reference to the secret, never the secret.
135
+ */
136
+ export interface SaasOauthClient {
137
+ clientId: string;
138
+ /**
139
+ * Where the client secret lives.
140
+ */
141
+ secretRef: string;
142
+ setAt: string;
143
+ /**
144
+ * The IT admin who saved it (lower-cased email).
145
+ */
146
+ setBy: string;
147
+ }
148
+ /**
149
+ * Whom an app may direct-message through slack.dm.send (0.71.0), set by IT on the connection and enforced by governance on every send.
150
+ */
151
+ export interface DirectMessagePolicy {
152
+ allowed: DirectMessageAudience;
153
+ /**
154
+ * Tenant-wide logical alias of a connection (governance sourceId) or of a resource under it.
155
+ */
156
+ channel?: string;
157
+ /**
158
+ * Distinct recipients one app may message per UTC day on this connection; the cap refuses the send that would exceed it.
159
+ */
160
+ dailyRecipientCap: number;
161
+ }
162
+ /**
163
+ * Whether a gmail.message.send may address people outside the company (0.71.0). An external recipient is one whose email domain is not one of the tenant's verified domains.
164
+ */
165
+ export interface ExternalRecipientPolicy {
166
+ mode: ExternalRecipientMode;
167
+ /**
168
+ * allowlist only: the external domains a send may address; a recipient's domain must equal one of them.
169
+ *
170
+ * @minItems 1
171
+ * @maxItems 50
172
+ */
173
+ allowedDomains?: string[];
174
+ }
117
175
  /**
118
176
  * @deprecated
119
177
  */
@@ -2,6 +2,10 @@
2
2
  // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
3
 
4
4
  export type IntegrationConnectionKind = 'saas' | 'database';
5
+ /**
6
+ * Whose authority an operation runs under. Fixed per operation, except the declared-mode Slack operations (slack.message.post; since 0.71.0 also slack.channel.history, slack.message.update, slack.message.delete): declared per app for the access request, and named per call among the app's approved modes (execute request `mode`, 0.55.0).
7
+ */
8
+ export type IntegrationIdentityMode = 'app' | 'user';
5
9
  /**
6
10
  * Tenant-wide logical alias of a connection (governance sourceId) or of a resource under it.
7
11
  */
@@ -11,17 +15,13 @@ export type IntegrationAlias = string;
11
15
  * @maxItems 64
12
16
  */
13
17
  export type ResourceNames = IntegrationAlias[];
14
- /**
15
- * Whose authority an operation runs under. Fixed per operation, except slack.message.post: declared per app for the access request, and named per call among the app's approved modes (execute request `mode`, 0.55.0).
16
- */
17
- export type IntegrationIdentityMode = 'app' | 'user';
18
18
  /**
19
19
  * A plain warehouse column identifier, or `*` as the sole selection when IT exposed all current and future columns.
20
20
  */
21
21
  export type ColumnName = string;
22
22
 
23
23
  /**
24
- * Committed `.isomorph/integrations.json`: the app's declared use of tenant-wide integration connections. Keys are logical connection aliases (the governance sourceId); resources are logical names (Slack channel alias, warehouse view name), never IDs, tokens or URLs. The operation set is closed (six operations). Each operation carries a fixed identity mode — slack.channel.history=user, gmail.thread.list=user, gmail.message.read=user, gmail.message.send=user, warehouse.view.read=app — except slack.message.post, whose mode the app DECLARES: `app` posts as the company's Slack app (the one audited bot identity, under the IT-approved per-app display name when the connection declares `presentation`), `user` posts as the signed-in person after their own consent. The declared mode is the one the app requests access for; an app may hold approved grants for both modes after two requests, and each slack.message.post call names which approved mode it runs under (`mode` on the execute request, 0.55.0) — a mode IT did not approve is refused, never swapped for the other. A `saas` connection may declare only the Slack and Gmail operations and a `database` connection only warehouse.view.read; `presentation` is a `saas` connection's optional per-app display name and icon for app-mode Slack posts, shown to IT at approval time and applied only from the approved grant. Gmail is user-mode only (reads plus one send); its single resource is the fixed `inbox` alias. This file also owns the shared IntegrationOperation / IntegrationIdentityMode / IntegrationEnvironment / IntegrationConnectionKind / IntegrationPresentation definitions referenced by the other integration schemas.
24
+ * Committed `.isomorph/integrations.json`: the app's declared use of tenant-wide integration connections. Keys are logical connection aliases (the governance sourceId); resources are logical names (Slack channel alias, warehouse view name), never IDs, tokens or URLs. The operation set is closed (twelve operations since 0.71.0, ADR 0018). Each operation carries a fixed identity mode — slack.dm.send=app, slack.user.lookup=app, every gmail.* operation=user, warehouse.view.read=app — except the declared-mode Slack operations (slack.message.post, and since 0.71.0 slack.channel.history, slack.message.update and slack.message.delete), whose mode the app DECLARES: `app` acts as the company's Slack app (the one audited bot identity; a post goes out under the IT-approved per-app display name when the connection declares `presentation`), `user` acts as the signed-in person after their own consent (reads what they can read, edits what they wrote). The declared mode is the one the app requests access for; an app may hold approved grants for both modes after two requests, and each declared-mode call names which approved mode it runs under (`mode` on the execute request, 0.55.0) — a mode IT did not approve is refused, never swapped for the other. A `saas` connection may declare only the Slack and Gmail operations and a `database` connection only warehouse.view.read; `presentation` is a `saas` connection's optional per-app display name and icon for app-mode Slack posts, shown to IT at approval time and applied only from the approved grant. Gmail is user-mode only (reads, one send); its single resource is the fixed `inbox` alias. slack.dm.send and slack.user.lookup address people by email rather than a channel, so their single resource is the fixed `workspace` alias (the connection's installed workspace), the way Gmail's is `inbox`. This file also owns the shared IntegrationOperation / IntegrationIdentityMode / IntegrationEnvironment / IntegrationConnectionKind / IntegrationPresentation definitions referenced by the other integration schemas.
25
25
  */
26
26
  export interface AppIntegrationsDeclaration {
27
27
  schema: 'isomorph.app-integrations/2.0';
@@ -33,11 +33,17 @@ export interface DeclaredConnection {
33
33
  kind: IntegrationConnectionKind;
34
34
  presentation?: IntegrationPresentation;
35
35
  operations: {
36
- 'slack.channel.history'?: UserModeChannelOperation;
36
+ 'slack.channel.history'?: DeclaredModeChannelOperation;
37
37
  'slack.message.post'?: DeclaredModeChannelOperation;
38
+ 'slack.message.update'?: DeclaredModeChannelOperation;
39
+ 'slack.message.delete'?: DeclaredModeChannelOperation;
40
+ 'slack.dm.send'?: AppModeChannelOperation;
41
+ 'slack.user.lookup'?: AppModeChannelOperation;
38
42
  'gmail.thread.list'?: UserModeChannelOperation;
43
+ 'gmail.thread.read'?: UserModeChannelOperation;
39
44
  'gmail.message.read'?: UserModeChannelOperation;
40
45
  'gmail.message.send'?: UserModeChannelOperation;
46
+ 'gmail.attachment.read'?: UserModeChannelOperation;
41
47
  'warehouse.view.read'?: AppModeViewOperation;
42
48
  };
43
49
  }
@@ -54,17 +60,24 @@ export interface IntegrationPresentation {
54
60
  */
55
61
  iconEmoji?: string;
56
62
  }
57
- export interface UserModeChannelOperation {
58
- identity: 'user';
59
- resources: ResourceNames;
60
- }
61
63
  /**
62
- * slack.message.post: the app declares the mode — app (posts as the company's Slack app) or user (posts as the signed-in person).
64
+ * slack.message.post, slack.channel.history, slack.message.update, slack.message.delete: the app declares the mode — app (acts as the company's Slack app) or user (acts as the signed-in person).
63
65
  */
64
66
  export interface DeclaredModeChannelOperation {
65
67
  identity: IntegrationIdentityMode;
66
68
  resources: ResourceNames;
67
69
  }
70
+ /**
71
+ * slack.dm.send, slack.user.lookup (0.71.0): app mode only — the company's Slack app messages people or resolves them by email; the resource is the fixed `workspace` alias.
72
+ */
73
+ export interface AppModeChannelOperation {
74
+ identity: 'app';
75
+ resources: ResourceNames;
76
+ }
77
+ export interface UserModeChannelOperation {
78
+ identity: 'user';
79
+ resources: ResourceNames;
80
+ }
68
81
  export interface AppModeViewOperation {
69
82
  identity: 'app';
70
83
  resources: {
@@ -1,10 +1,10 @@
1
1
  /* eslint-disable */
2
2
  // Code generated by tools/gen/gen.mjs from schema/*.json. DO NOT EDIT.
3
3
 
4
- export const ContractRepositoryVersion = "0.69.0.0" as const;
5
- export const ContractPublishVersion = "0.69.0" as const;
6
- export const ContractPublishTag = "v0.69.0" as const;
7
- export const ContractSchemasDigest = "sha256:e21f310517adb289db8b8233fd3c742a711fed8226892591f6581afb8510e30c" as const;
4
+ export const ContractRepositoryVersion = "0.71.0.0" as const;
5
+ export const ContractPublishVersion = "0.71.0" as const;
6
+ export const ContractPublishTag = "v0.71.0" as const;
7
+ export const ContractSchemasDigest = "sha256:25c5de2ee9586c6280dca7c733a7063f46bd5d400112fe60f928cf6db1a627c3" as const;
8
8
  export const ContractAiFixturesDigest = "sha256:edac31c92e146ca1c40130f2fb5649db82e24b8cd1ac758153b90612052fddb6" as const;
9
- export const ContractGeneratedGoDigest = "sha256:1f793a0d039976e5a9b65a72753928ab68de66d6878c96a6ea242e48a16ca24e" as const;
10
- export const ContractGeneratedTypeScriptDigest = "sha256:6bdb0271dae8bf7df75f72e61addda29ebb778a054b59c8538d05112000ac0e4" as const;
9
+ export const ContractGeneratedGoDigest = "sha256:09bd95f079e733af9bf594a157d7396a645cc6c9866d48b7aee6b00a9762ac62" as const;
10
+ export const ContractGeneratedTypeScriptDigest = "sha256:7fa5c1e4d8fdb68855b5e10208ee6c0a210f3595f7e74174efbf6bfa4c3673ce" as const;
@@ -27,22 +27,28 @@ export interface DataGrantV2 {
27
27
  */
28
28
  sourceId: string;
29
29
  /**
30
- * Whose authority an operation runs under. Fixed per operation, except slack.message.post: declared per app for the access request, and named per call among the app's approved modes (execute request `mode`, 0.55.0).
30
+ * Whose authority an operation runs under. Fixed per operation, except the declared-mode Slack operations (slack.message.post; since 0.71.0 also slack.channel.history, slack.message.update, slack.message.delete): declared per app for the access request, and named per call among the app's approved modes (execute request `mode`, 0.55.0).
31
31
  */
32
32
  mode: 'app' | 'user';
33
33
  kind: 'saas' | 'database';
34
34
  status: DataGrantV2Status;
35
35
  /**
36
- * Approved operations; each must match this grant's mode. Empty until the first approval.
36
+ * Approved operations; each must match this grant's mode. Empty until the first approval. At most the eleven SaaS operations (0.71.0); a database grant holds one.
37
37
  *
38
- * @maxItems 5
38
+ * @maxItems 11
39
39
  */
40
40
  operations: (
41
41
  | 'slack.channel.history'
42
42
  | 'slack.message.post'
43
+ | 'slack.message.update'
44
+ | 'slack.message.delete'
45
+ | 'slack.dm.send'
46
+ | 'slack.user.lookup'
43
47
  | 'gmail.thread.list'
48
+ | 'gmail.thread.read'
44
49
  | 'gmail.message.read'
45
50
  | 'gmail.message.send'
51
+ | 'gmail.attachment.read'
46
52
  | 'warehouse.view.read'
47
53
  )[];
48
54
  /**
@@ -83,7 +89,7 @@ export interface DataGrantV2 {
83
89
  updatedAt: string;
84
90
  /**
85
91
  * @deprecated
86
- * Whose authority an operation runs under. Fixed per operation, except slack.message.post: declared per app for the access request, and named per call among the app's approved modes (execute request `mode`, 0.55.0).
92
+ * Whose authority an operation runs under. Fixed per operation, except the declared-mode Slack operations (slack.message.post; since 0.71.0 also slack.channel.history, slack.message.update, slack.message.delete): declared per app for the access request, and named per call among the app's approved modes (execute request `mode`, 0.55.0).
87
93
  */
88
94
  identityMode?: 'app' | 'user';
89
95
  /**
@@ -11,7 +11,13 @@
11
11
  export type IntegrationExecuteInputFieldRule =
12
12
  | { kind: 'integer'; required: boolean; minimum: number; maximum: number; default?: number }
13
13
  | { kind: 'string'; required: boolean; minLength: number; maxLength?: number; pattern?: string }
14
- | { kind: 'string[]'; required: boolean; minItems: number; maxItems: number; uniqueItems: boolean; item: { minLength: number; maxLength?: number; pattern?: string } };
14
+ | { kind: 'string[]'; required: boolean; minItems: number; maxItems: number; uniqueItems: boolean; item: { minLength: number; maxLength?: number; pattern?: string }; default?: readonly string[] }
15
+ | { kind: 'enum'; required: boolean; values: readonly string[]; default?: string }
16
+ | { kind: 'object[]'; required: boolean; minItems: number; maxItems: number; fields: Readonly<Record<string, IntegrationExecuteInputFieldRule>> }
17
+ | { kind: 'value'; required: boolean; types: readonly IntegrationExecuteInputValueType[]; maxLength?: number; list?: { minItems: number; maxItems: number; types: readonly IntegrationExecuteInputValueType[] } };
18
+
19
+ /** The scalar types a `value` input accepts, alone or in its list form. */
20
+ export type IntegrationExecuteInputValueType = 'string' | 'number' | 'boolean';
15
21
 
16
22
  export type IntegrationExecuteOperationRule = { idempotencyKey: boolean; fields: Record<string, IntegrationExecuteInputFieldRule> };
17
23
 
@@ -19,25 +25,67 @@ export const IntegrationExecuteInputRules = {
19
25
  "slack.channel.history": {
20
26
  idempotencyKey: false,
21
27
  fields: {
22
- limit: { kind: "integer", required: false, minimum: 1, maximum: 15, default: 15 },
28
+ limit: { kind: "integer", required: false, minimum: 1, maximum: 100, default: 15 },
29
+ cursor: { kind: "string", required: false, minLength: 1, maxLength: 256 },
30
+ oldest: { kind: "string", required: false, minLength: 1, maxLength: 64 },
31
+ latest: { kind: "string", required: false, minLength: 1, maxLength: 64 },
23
32
  },
24
33
  },
25
34
  "slack.message.post": {
26
35
  idempotencyKey: true,
27
36
  fields: {
28
37
  text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
38
+ threadId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
39
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
40
+ },
41
+ },
42
+ "slack.message.update": {
43
+ idempotencyKey: false,
44
+ fields: {
45
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
46
+ text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
47
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
48
+ },
49
+ },
50
+ "slack.message.delete": {
51
+ idempotencyKey: false,
52
+ fields: {
53
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
54
+ },
55
+ },
56
+ "slack.dm.send": {
57
+ idempotencyKey: true,
58
+ fields: {
59
+ to: { kind: "string[]", required: true, minItems: 1, maxItems: 8, uniqueItems: true, item: { minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" } },
60
+ text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
61
+ format: { kind: "enum", required: false, values: ["plain", "mrkdwn"], default: "plain" },
62
+ },
63
+ },
64
+ "slack.user.lookup": {
65
+ idempotencyKey: false,
66
+ fields: {
67
+ email: { kind: "string", required: true, minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" },
29
68
  },
30
69
  },
31
70
  "gmail.thread.list": {
32
71
  idempotencyKey: false,
33
72
  fields: {
34
- limit: { kind: "integer", required: false, minimum: 1, maximum: 15, default: 15 },
73
+ limit: { kind: "integer", required: false, minimum: 1, maximum: 50, default: 15 },
74
+ q: { kind: "string", required: false, minLength: 1, maxLength: 256 },
75
+ labelIds: { kind: "string[]", required: false, minItems: 1, maxItems: 5, uniqueItems: true, item: { minLength: 1, maxLength: 64 }, default: ["INBOX"] },
76
+ pageToken: { kind: "string", required: false, minLength: 1, maxLength: 256 },
77
+ },
78
+ },
79
+ "gmail.thread.read": {
80
+ idempotencyKey: false,
81
+ fields: {
82
+ threadId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
35
83
  },
36
84
  },
37
85
  "gmail.message.read": {
38
86
  idempotencyKey: false,
39
87
  fields: {
40
- messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9_-]{1,64}$" },
88
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
41
89
  },
42
90
  },
43
91
  "gmail.message.send": {
@@ -48,13 +96,41 @@ export const IntegrationExecuteInputRules = {
48
96
  bcc: { kind: "string[]", required: false, minItems: 1, maxItems: 50, uniqueItems: true, item: { minLength: 3, maxLength: 254, pattern: "^[^\\s@<>,;\"]+@[^\\s@<>,;\"]+\\.[^\\s@<>,;\"]+$" } },
49
97
  subject: { kind: "string", required: true, minLength: 1, maxLength: 256 },
50
98
  text: { kind: "string", required: true, minLength: 1, maxLength: 4000 },
99
+ html: { kind: "string", required: false, minLength: 1, maxLength: 65536 },
100
+ attachments: { kind: "object[]", required: false, minItems: 1, maxItems: 5, fields: {
101
+ filename: { kind: "string", required: true, minLength: 1, maxLength: 255 },
102
+ contentType: { kind: "string", required: true, minLength: 1, maxLength: 128 },
103
+ dataBase64: { kind: "string", required: true, minLength: 1, maxLength: 2796203 },
104
+ } },
51
105
  replyToMessageId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9_-]{1,64}$" },
52
106
  },
53
107
  },
108
+ "gmail.attachment.read": {
109
+ idempotencyKey: false,
110
+ fields: {
111
+ messageId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9._-]{1,64}$" },
112
+ attachmentId: { kind: "string", required: true, minLength: 1, maxLength: 512 },
113
+ },
114
+ },
54
115
  "warehouse.view.read": {
55
116
  idempotencyKey: false,
56
117
  fields: {
57
- columns: { kind: "string[]", required: true, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
118
+ columns: { kind: "string[]", required: false, minItems: 1, maxItems: 32, uniqueItems: false, item: { minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" } },
119
+ where: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
120
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
121
+ op: { kind: "enum", required: true, values: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "notIn", "isNull", "isNotNull"] },
122
+ value: { kind: "value", required: false, types: ["string", "number", "boolean"], maxLength: 256, list: { minItems: 1, maxItems: 100, types: ["string", "number"] } },
123
+ } },
124
+ groupBy: { kind: "string[]", required: false, minItems: 1, maxItems: 8, uniqueItems: false, item: { minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" } },
125
+ aggregates: { kind: "object[]", required: false, minItems: 1, maxItems: 16, fields: {
126
+ fn: { kind: "enum", required: true, values: ["count", "sum", "avg", "min", "max"] },
127
+ column: { kind: "string", required: true, minLength: 0, pattern: "^(?:\\*|[A-Za-z_][A-Za-z0-9_]{0,62})$" },
128
+ as: { kind: "string", required: true, minLength: 0, pattern: "^[a-z_][a-z0-9_]{0,62}$" },
129
+ } },
130
+ orderBy: { kind: "object[]", required: false, minItems: 1, maxItems: 8, fields: {
131
+ column: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z_][A-Za-z0-9_]{0,62}$" },
132
+ direction: { kind: "enum", required: false, values: ["asc", "desc"], default: "asc" },
133
+ } },
58
134
  limit: { kind: "integer", required: false, minimum: 1, maximum: 1000, default: 100 },
59
135
  },
60
136
  },
@@ -79,41 +155,96 @@ function matches(pattern: string, value: string): boolean {
79
155
  return compiled.test(value);
80
156
  }
81
157
 
158
+ type Refusal = { ok: false; problem: IntegrationExecuteInputProblem };
159
+ function refuse(field: string, message: string): Refusal { return { ok: false, problem: { field, message } }; }
160
+
161
+ const valueNouns = { string: ['a string', 'strings'], number: ['a number', 'numbers'], boolean: ['a boolean', 'booleans'] } as const;
162
+ function either(parts: readonly string[]): string {
163
+ return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}` : parts.join('');
164
+ }
165
+ function isScalar(types: readonly IntegrationExecuteInputValueType[], maxLength: number | undefined, value: unknown): boolean {
166
+ if (typeof value === 'string') return types.includes('string') && (maxLength === undefined || value.length <= maxLength);
167
+ if (typeof value === 'number') return types.includes('number') && Number.isFinite(value);
168
+ return typeof value === 'boolean' && types.includes('boolean');
169
+ }
170
+
82
171
  /**
83
- * Validates one operation's input against the contract and returns it with the
84
- * contract's defaults applied. The message names the field and its allowed
85
- * range; it is the sentence the platform answers with, so a caller may show it.
172
+ * Checks one object's fields in rule order and applies defaults: the input
173
+ * itself (where null counts as absent), or one entry of an object[] input
174
+ * (where only a missing key is absent, so null is refused like any wrong value).
86
175
  */
87
- export function validateIntegrationExecuteInput(operation: string, input: unknown): IntegrationExecuteInputValidation {
88
- const rule = (IntegrationExecuteInputRules as Record<string, IntegrationExecuteOperationRule>)[operation];
89
- if (!rule) return { ok: false, problem: { field: 'operation', message: `${operation} is not a declared integration operation` } };
90
- const raw = input === undefined ? {} : input;
91
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, problem: { field: 'input', message: 'input must be an object' } };
92
- const given = raw as Record<string, unknown>;
93
- for (const name of Object.keys(given)) if (!Object.prototype.hasOwnProperty.call(rule.fields, name)) return { ok: false, problem: { field: name, message: `${name} is not an input of ${operation}` } };
176
+ function checkFields(operation: string, prefix: string, fields: Readonly<Record<string, IntegrationExecuteInputFieldRule>>, given: Record<string, unknown>, nullIsAbsent: boolean): { ok: true; value: Record<string, unknown> } | Refusal {
177
+ for (const name of Object.keys(given)) if (!Object.prototype.hasOwnProperty.call(fields, name)) return refuse(prefix + name, `${prefix}${name} is not an input of ${operation}`);
94
178
  const out: Record<string, unknown> = {};
95
- for (const [name, field] of Object.entries(rule.fields)) {
179
+ for (const [name, field] of Object.entries(fields)) {
180
+ const path = prefix + name;
96
181
  const value = given[name];
97
- if (value === undefined || value === null) {
98
- if (field.required) return { ok: false, problem: { field: name, message: `${name} is required for ${operation}` } };
99
- if (field.kind === 'integer' && field.default !== undefined) out[name] = field.default;
182
+ if (value === undefined || (nullIsAbsent && value === null)) {
183
+ if (field.required) return refuse(path, `${path} is required for ${operation}`);
184
+ if ((field.kind === 'integer' || field.kind === 'enum') && field.default !== undefined) out[name] = field.default;
185
+ if (field.kind === 'string[]' && field.default !== undefined) out[name] = [...field.default];
100
186
  continue;
101
187
  }
102
- if (field.kind === 'integer') {
103
- if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum) return { ok: false, problem: { field: name, message: `${name} must be an integer between ${field.minimum} and ${field.maximum}` } };
104
- } else if (field.kind === 'string') {
105
- if (typeof value !== 'string' || value.length < field.minLength || (field.maxLength !== undefined && value.length > field.maxLength)) 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` } };
106
- if (field.pattern && !matches(field.pattern, value)) return { ok: false, problem: { field: name, message: `${name} must match ${field.pattern}` } };
107
- } else {
108
- if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems) return { ok: false, problem: { field: name, message: `${name} must list ${field.minItems} to ${field.maxItems} items` } };
188
+ const checked = checkField(operation, path, name, field, value);
189
+ if (!checked.ok) return checked;
190
+ out[name] = checked.value;
191
+ }
192
+ return { ok: true, value: out };
193
+ }
194
+
195
+ function checkField(operation: string, path: string, name: string, field: IntegrationExecuteInputFieldRule, value: unknown): { ok: true; value: unknown } | Refusal {
196
+ switch (field.kind) {
197
+ case 'integer':
198
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum) return refuse(path, `${path} must be an integer between ${field.minimum} and ${field.maximum}`);
199
+ return { ok: true, value };
200
+ case 'string':
201
+ if (typeof value !== 'string' || value.length < field.minLength || (field.maxLength !== undefined && value.length > field.maxLength)) 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`);
202
+ if (field.pattern && !matches(field.pattern, value)) return refuse(path, `${path} must match ${field.pattern}`);
203
+ return { ok: true, value };
204
+ case 'enum':
205
+ if (typeof value !== 'string' || !field.values.includes(value)) return refuse(path, `${path} must be one of ${field.values.join(', ')}`);
206
+ return { ok: true, value };
207
+ case 'string[]': {
208
+ if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems) return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
109
209
  const seen = new Set<string>();
110
210
  for (const [index, item] of value.entries()) {
111
- 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))) return { ok: false, problem: { field: name, message: `${name}[${index}] is not a valid ${name} entry` } };
112
- if (field.uniqueItems && seen.has(item)) return { ok: false, problem: { field: name, message: `${name} must not repeat an entry` } };
211
+ 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))) return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
212
+ if (field.uniqueItems && seen.has(item)) return refuse(path, `${path} must not repeat an entry`);
113
213
  seen.add(item);
114
214
  }
215
+ return { ok: true, value };
216
+ }
217
+ case 'object[]': {
218
+ if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems) return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
219
+ const entries: Record<string, unknown>[] = [];
220
+ for (const [index, item] of value.entries()) {
221
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
222
+ const entry = checkFields(operation, `${path}[${index}].`, field.fields, item as Record<string, unknown>, false);
223
+ if (!entry.ok) return entry;
224
+ entries.push(entry.value);
225
+ }
226
+ return { ok: true, value: entries };
227
+ }
228
+ case 'value': {
229
+ const { types, maxLength, list } = field;
230
+ 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)))) return { ok: true, value };
231
+ const shapes = types.map(type => (type === 'string' && maxLength !== undefined ? `a string of at most ${maxLength} characters` : valueNouns[type][0]));
232
+ if (list !== undefined) shapes.push(`a list of ${list.minItems} to ${list.maxItems} ${either(list.types.map(type => valueNouns[type][1]))}`);
233
+ return refuse(path, `${path} must be ${either(shapes)}`);
115
234
  }
116
- out[name] = value;
117
235
  }
118
- return { ok: true, input: out };
236
+ }
237
+
238
+ /**
239
+ * Validates one operation's input against the contract and returns it with the
240
+ * contract's defaults applied. The message names the field and its allowed
241
+ * range; it is the sentence the platform answers with, so a caller may show it.
242
+ */
243
+ export function validateIntegrationExecuteInput(operation: string, input: unknown): IntegrationExecuteInputValidation {
244
+ const rule = (IntegrationExecuteInputRules as Record<string, IntegrationExecuteOperationRule>)[operation];
245
+ if (!rule) return refuse('operation', `${operation} is not a declared integration operation`);
246
+ const raw = input === undefined ? {} : input;
247
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return refuse('input', 'input must be an object');
248
+ const checked = checkFields(operation, '', rule.fields, raw as Record<string, unknown>, true);
249
+ return checked.ok ? { ok: true, input: checked.value } : checked;
119
250
  }