@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 +1 -1
- package/src/index.ts +18 -7
- package/src/manifest.ts +121 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keemakr/operator-sdk",
|
|
3
|
-
"version": "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
|
|
140
|
-
// connector-level config, plus the governed fetch
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
|
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
|
|
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.
|