@keemakr/operator-sdk 0.7.0 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keemakr/operator-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The keemakr connector contract: defineProvider + ConnectionProvider/ConnectionOperation types + OperationContext (governed fetch) + the connector manifest schema, shared by keemakr-core's built-in adapters and external connector repos. One shape, two consumers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -136,11 +136,12 @@ export interface OperationContext {
136
136
  actor?: Readonly<OperationActor>;
137
137
  }
138
138
 
139
- // Slim context for the OAuth lifecycle hooks (buildAuthUrl/exchangeCode/refresh):
140
- // connector-level config, plus the governed fetch for the token exchange —
141
- // marketplace connector code may not touch ambient fetch ANYWHERE (the publish
142
- // scan bans it), so the lifecycle's egress rides the same fenced client as
143
- // operations. There is no tenant credential yet at these steps.
139
+ // Slim context for the lifecycle hooks (buildAuthUrl/exchangeCode/refresh, and
140
+ // extractMeta at connect time): connector-level config, plus the governed fetch
141
+ // for the token exchange and any connect-time lookup — marketplace connector
142
+ // code may not touch ambient fetch ANYWHERE (the publish scan bans it), so the
143
+ // lifecycle's egress rides the same fenced client as operations. There is no
144
+ // stored tenant credential yet at these steps.
144
145
  export interface LifecycleContext {
145
146
  config?: Record<string, string>;
146
147
  fetch?: typeof fetch;
@@ -287,9 +288,15 @@ export interface ConnectionProvider {
287
288
  ): Promise<TestResult>;
288
289
 
289
290
  // Derive a human-readable account_label + durable handle
290
- // (stripe_user_id, store url, ...) to persist alongside the token.
291
+ // (stripe_user_id, store url, ad account id, ...) to persist alongside the
292
+ // token. Runs once per connect, right after exchangeCode (oauth2) or test()
293
+ // (api_key / app_password). `ctx` is the same lifecycle context the connect
294
+ // step used: look the account up with `ctx.fetch` (the governed client,
295
+ // fenced to your `allowed_domains`), never ambient fetch. Optional in the type
296
+ // so one-argument implementations compile unchanged.
291
297
  extractMeta?(
292
298
  token: TokenSet,
299
+ ctx?: LifecycleContext,
293
300
  ): Promise<{ accountLabel: string | null; meta: Record<string, unknown> }>;
294
301
 
295
302
  // Named proxy operations a marketplace agent may invoke through the Capability
@@ -382,7 +389,11 @@ export type ProviderDefinition<Ops extends Record<string, OperationInputSchema>>
382
389
  // broken connector, and an auth failure must revoke, not retry.
383
390
  // provider_unavailable → the upstream API is down/5xx/timing out
384
391
  // connector_error → the connector's own fault (bug, bad normalization)
385
- // auth_revoked → the credential is dead (401-class, invalid_grant)
392
+ // auth_revoked → the credential is dead (401-class). From refresh(),
393
+ // it revokes only alongside the token endpoint's
394
+ // `invalid_grant` (keep the word in the message);
395
+ // without it core degrades instead, since a 401 there
396
+ // can be the connector's own client credentials.
386
397
  // egress_blocked → the governed fetch refused a non-allowlisted host
387
398
  // rate_limited → upstream 429 (platform applies backoff)
388
399
  export type OperationErrorCode =
package/src/manifest.ts CHANGED
@@ -129,6 +129,119 @@ export const connectorOperationDeclaration = z.object({
129
129
  input_schema: operationInputJsonSchema.optional(),
130
130
  });
131
131
 
132
+ // A name inside a push scheme's envelope (`page`, `leadgen`): lowercase, underscores allowed.
133
+ const pushName = /^[a-z][a-z0-9_]{0,63}$/;
134
+
135
+ /** The op that subscribes (or unsubscribes) an account at the provider, and the arg naming it. */
136
+ const pushAccountOp = z.object({
137
+ op: z.string().regex(opName, 'operation names are lowercase, dot/hyphen-scoped'),
138
+ account_ref_arg: z.string().min(1).max(100),
139
+ });
140
+
141
+ /**
142
+ * PUSHED UPDATES: the provider calls core when something happens, instead of core asking. Core
143
+ * owns the door (`/api/webhooks/connections/<provider>`): it checks the provider's signature with
144
+ * the connector's own config secret, finds the workspaces from the account the update names, and
145
+ * hands each one to the installs that depend on this connector as `core.connection_event.received`.
146
+ *
147
+ * `scheme` is the provider's protocol, implemented once in core. `websub_graph` is the WebSub
148
+ * verification handshake (`hub.challenge`) plus an `X-Hub-Signature-256` HMAC of the raw body and
149
+ * the `{ object, entry: [{ id, changes: [{ field, value }] }] }` envelope.
150
+ *
151
+ * `fields` is the REVIEWED allowlist of what may come through: an update whose (object, field) is
152
+ * not listed is dropped at the door. What a listed field carries reaches a handler under the
153
+ * install's full grant, so a reviewer judges it here, at publish.
154
+ *
155
+ * `register` names the operation that subscribes an account at the provider and the argument that
156
+ * holds the account's id. When that operation runs, core first notes the account for the calling
157
+ * workspace as pending (a call core cannot note is refused), and turns it on only when the provider
158
+ * accepts: only accepted accounts are delivered, so naming an account the workspace's credential
159
+ * does not manage reaches nothing. `unregister`, when the connector has an operation to unsubscribe,
160
+ * turns the account off once the provider accepts. The secret and the verify token must be
161
+ * different keys: the token travels in the clear during the provider's handshake.
162
+ *
163
+ * OPTIONAL, and it must stay optional: stored manifests are re-parsed at every publish.
164
+ */
165
+ export const connectorPushDeclaration = z
166
+ .object({
167
+ scheme: z.enum(['websub_graph']),
168
+ secret_key: z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'config keys are SCREAMING_SNAKE'),
169
+ verify_token_key: z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'config keys are SCREAMING_SNAKE'),
170
+ fields: z
171
+ .array(
172
+ z.object({
173
+ object: z.string().regex(pushName, 'push objects are lowercase, underscores allowed'),
174
+ field: z.string().regex(pushName, 'push fields are lowercase, underscores allowed'),
175
+ }),
176
+ )
177
+ .min(1, 'declare at least one (object, field) the provider may push')
178
+ .max(50),
179
+ register: pushAccountOp,
180
+ unregister: pushAccountOp.optional(),
181
+ })
182
+ .superRefine((p, ctx) => {
183
+ // The verify token is sent in the clear (a URL query) during the provider's handshake; the secret
184
+ // signs every delivery for every customer. One key for both would put the secret in access logs.
185
+ if (p.secret_key === p.verify_token_key) {
186
+ ctx.addIssue({
187
+ code: 'custom',
188
+ path: ['verify_token_key'],
189
+ message: 'verify_token_key must be a different config key from secret_key',
190
+ });
191
+ }
192
+ if (p.unregister && p.unregister.op === p.register.op) {
193
+ ctx.addIssue({
194
+ code: 'custom',
195
+ path: ['unregister', 'op'],
196
+ message: 'push.unregister.op must differ from push.register.op',
197
+ });
198
+ }
199
+ const seen = new Set<string>();
200
+ for (const [i, f] of p.fields.entries()) {
201
+ const k = `${f.object}.${f.field}`;
202
+ if (seen.has(k)) {
203
+ ctx.addIssue({
204
+ code: 'custom',
205
+ path: ['fields', i],
206
+ message: `push.fields lists '${k}' twice`,
207
+ });
208
+ }
209
+ seen.add(k);
210
+ }
211
+ });
212
+
213
+ /**
214
+ * What a push declaration must agree on with the rest of its manifest: the secret and the verify
215
+ * token are declared config keys (staff can only set a declared key), and the register and
216
+ * unregister operations are declared operations. Paths are relative to `push`. Pure. Core runs it
217
+ * again when it reads a pinned manifest, so a block that no longer agrees opens no door.
218
+ */
219
+ export function pushCrossCheckIssues(
220
+ push: ConnectorPushDeclaration,
221
+ manifest: { env: readonly string[]; operations: ReadonlyArray<{ name: string }> },
222
+ ): Array<{ path: (string | number)[]; message: string }> {
223
+ const out: Array<{ path: (string | number)[]; message: string }> = [];
224
+ for (const key of ['secret_key', 'verify_token_key'] as const) {
225
+ if (!manifest.env.includes(push[key])) {
226
+ out.push({
227
+ path: [key],
228
+ message: `push.${key} '${push[key]}' must be one of the config keys declared in env`,
229
+ });
230
+ }
231
+ }
232
+ const ops = new Set(manifest.operations.map((o) => o.name));
233
+ for (const which of ['register', 'unregister'] as const) {
234
+ const decl = push[which];
235
+ if (decl && !ops.has(decl.op)) {
236
+ out.push({
237
+ path: [which, 'op'],
238
+ message: `push.${which}.op '${decl.op}' is not one of the declared operations`,
239
+ });
240
+ }
241
+ }
242
+ return out;
243
+ }
244
+
132
245
  export const connectorManifestSchema = z
133
246
  .object({
134
247
  kind: z.literal('connection'),
@@ -220,6 +333,8 @@ export const connectorManifestSchema = z
220
333
  // the publish gate (both directions: no undeclared ops, no phantom ops). For
221
334
  // remote connectors this list IS the contract core builds the forwarder from.
222
335
  operations: z.array(connectorOperationDeclaration).min(1),
336
+ // Updates the provider pushes to core (see connectorPushDeclaration). Optional.
337
+ push: connectorPushDeclaration.optional(),
223
338
  changelog: z.string().max(5000).optional(),
224
339
  })
225
340
  .superRefine((m, ctx) => {
@@ -276,11 +391,17 @@ export const connectorManifestSchema = z
276
391
  'mcp connectors must declare connect_fields (there is no adapter code to own the form)',
277
392
  });
278
393
  }
394
+ if (m.push) {
395
+ for (const issue of pushCrossCheckIssues(m.push, m)) {
396
+ ctx.addIssue({ code: 'custom', path: ['push', ...issue.path], message: issue.message });
397
+ }
398
+ }
279
399
  });
280
400
 
281
401
  export type ConnectorOperationDeclaration = z.infer<typeof connectorOperationDeclaration>;
282
402
  export type OperationInputJsonSchema = z.infer<typeof operationInputJsonSchema>;
283
403
  export type ConnectorManifest = z.infer<typeof connectorManifestSchema>;
404
+ export type ConnectorPushDeclaration = z.infer<typeof connectorPushDeclaration>;
284
405
 
285
406
  // Parse + normalize an entry.json object. Throws ZodError on invalid input;
286
407
  // callers wanting a report use connectorManifestSchema.safeParse directly.