@keemakr/operator-sdk 0.5.0 → 0.6.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 +125 -2
- package/src/manifest.ts +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keemakr/operator-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
@@ -52,6 +52,60 @@ export interface TestResult {
|
|
|
52
52
|
detail?: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// ─── Who a call acts for ─────────────────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// The VERIFIED answer to "which person is behind this call", handed to an operation on
|
|
58
|
+
// `ctx.actor`. Before this existed a connector could see only `connector:<keyId>` — the
|
|
59
|
+
// install, not the human — so a connector that had to record who resolved an identity or
|
|
60
|
+
// recorded an opt-out took the actor as a TOOL ARGUMENT, a value the model fills. Kee CRM
|
|
61
|
+
// stored exactly that and had to mark it unverified (COS-207).
|
|
62
|
+
//
|
|
63
|
+
// Core fills this from something it authenticated — claims it SIGNED at mint time
|
|
64
|
+
// (`on_behalf_of`, `actor_role`), a verified member session, or the recorded approver of a held
|
|
65
|
+
// write — never from the operation's args. It is a PRINCIPAL, not an authorization: it says
|
|
66
|
+
// who, never what they may do — the grant's scopes and the operation's access class decide
|
|
67
|
+
// that, before your code runs.
|
|
68
|
+
export interface OperationActor {
|
|
69
|
+
/** The keemakr org this call is scoped to. */
|
|
70
|
+
tenantId: string;
|
|
71
|
+
/**
|
|
72
|
+
* The human this call acts for, or null when nobody does.
|
|
73
|
+
*
|
|
74
|
+
* NULL IS MEANINGFUL, not missing: an unattended machine grant has no honest answer, and a
|
|
75
|
+
* connector that must attribute an action should refuse rather than invent one.
|
|
76
|
+
*
|
|
77
|
+
* NON-NULL DOES NOT MEAN PRESENT. A scheduled run carries the schedule's OWNER here (`via:
|
|
78
|
+
* 'schedule'`) — a real, named, accountable person who is not at the keyboard. Check `attended`
|
|
79
|
+
* for "is a person behind this call right now", never this field's truthiness.
|
|
80
|
+
*
|
|
81
|
+
* On `via: 'approval'` this is the person who RELEASED the write, never merely the one who
|
|
82
|
+
* asked for it. Where core cannot establish who approved, it is null.
|
|
83
|
+
*/
|
|
84
|
+
userId: string | null;
|
|
85
|
+
/**
|
|
86
|
+
* That person's org role, read when the call was authenticated, or null when none was proven — a machine grant, a
|
|
87
|
+
* principal who is no longer a member, or a grant minted before the claim existed. Fail
|
|
88
|
+
* closed on null; never default it to a role.
|
|
89
|
+
*/
|
|
90
|
+
role: 'owner' | 'admin' | 'member' | 'viewer' | null;
|
|
91
|
+
/**
|
|
92
|
+
* What the platform authenticated. `grant` is a marketplace install's capability grant,
|
|
93
|
+
* `schedule` a capability grant minted for a scheduled run (acting as the schedule's owner),
|
|
94
|
+
* `operator` a tenant member driving the Operator in the app, `mcp` a tenant member calling
|
|
95
|
+
* through the platform's MCP server (an AI client acting for them), `approval` a write a human
|
|
96
|
+
* released from an approval card.
|
|
97
|
+
*
|
|
98
|
+
* Treat this union as OPEN: switch on the values you accept and refuse the rest, so a lane
|
|
99
|
+
* added later fails closed in your connector instead of matching a default branch.
|
|
100
|
+
*/
|
|
101
|
+
via: 'grant' | 'schedule' | 'operator' | 'mcp' | 'approval';
|
|
102
|
+
/**
|
|
103
|
+
* True exactly when a person is behind this call AS IT RUNS: `userId` is non-null and the lane
|
|
104
|
+
* is not `schedule`. A scheduled run names its owner but is still unattended.
|
|
105
|
+
*/
|
|
106
|
+
attended: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
55
109
|
// ─── Execution context ───────────────────────────────────────────────────────
|
|
56
110
|
//
|
|
57
111
|
// Handed to an operation (and test()) by the platform at call time. Marketplace
|
|
@@ -71,6 +125,15 @@ export interface OperationContext {
|
|
|
71
125
|
config?: Record<string, string>;
|
|
72
126
|
// Trace-correlated logger. Never log credentials or response payloads.
|
|
73
127
|
log?: (msg: string) => void;
|
|
128
|
+
// WHO this call acts for, as the platform verified it — see OperationActor.
|
|
129
|
+
//
|
|
130
|
+
// Optional because not every lane has one: a connect-time probe and a built-in adapter run
|
|
131
|
+
// without a ctx at all, and a `remote` or `mcp` runtime connector is never handed the ctx core
|
|
132
|
+
// builds (it gets the same actor on its signed call grant instead, read with
|
|
133
|
+
// `verifyConnectorRequest` / `verifyMcpToolCall` from `@keemakr/agent-sdk`). An operation that
|
|
134
|
+
// needs attribution must treat an absent actor the same as an unattended one and refuse, rather
|
|
135
|
+
// than falling back to an args value. Frozen: read it, never write it.
|
|
136
|
+
actor?: Readonly<OperationActor>;
|
|
74
137
|
}
|
|
75
138
|
|
|
76
139
|
// Slim context for the OAuth lifecycle hooks (buildAuthUrl/exchangeCode/refresh):
|
|
@@ -268,6 +331,45 @@ export interface ConnectionOperation {
|
|
|
268
331
|
): Promise<unknown>;
|
|
269
332
|
}
|
|
270
333
|
|
|
334
|
+
/** An operation's input schema: anything with `safeParse` (zod, valibot, a hand-rolled check). */
|
|
335
|
+
export type OperationInputSchema = ConnectionOperation['inputSchema'];
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The `args` a `call` receives for an input schema: what the schema parses to, when it says (a
|
|
339
|
+
* Standard Schema — zod among them — declares its output type), intersected with an open record;
|
|
340
|
+
* an open record when it does not. The intersection is deliberate: known keys are typed, and any
|
|
341
|
+
* other key still reads as `unknown`, so code written against `Record<string, unknown>` keeps
|
|
342
|
+
* compiling.
|
|
343
|
+
*/
|
|
344
|
+
export type OperationArgs<S> = S extends { readonly '~standard': { readonly types?: infer T } }
|
|
345
|
+
? NonNullable<T> extends { readonly output: infer O }
|
|
346
|
+
? O extends Record<string, unknown>
|
|
347
|
+
? O & Record<string, unknown>
|
|
348
|
+
: Record<string, unknown>
|
|
349
|
+
: Record<string, unknown>
|
|
350
|
+
: Record<string, unknown>;
|
|
351
|
+
|
|
352
|
+
/** A ConnectionOperation whose `call` is typed by its own input schema (see defineProvider). */
|
|
353
|
+
export interface TypedConnectionOperation<S extends OperationInputSchema> extends Omit<
|
|
354
|
+
ConnectionOperation,
|
|
355
|
+
'inputSchema' | 'call'
|
|
356
|
+
> {
|
|
357
|
+
inputSchema: S;
|
|
358
|
+
call(
|
|
359
|
+
cred: { accessToken: string; meta: Record<string, unknown> },
|
|
360
|
+
args: OperationArgs<S>,
|
|
361
|
+
ctx?: OperationContext,
|
|
362
|
+
): Promise<unknown>;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** What `defineProvider` takes: a ConnectionProvider whose operations are typed by their schemas. */
|
|
366
|
+
export type ProviderDefinition<Ops extends Record<string, OperationInputSchema>> = Omit<
|
|
367
|
+
ConnectionProvider,
|
|
368
|
+
'operations'
|
|
369
|
+
> & {
|
|
370
|
+
operations?: { [K in keyof Ops]: TypedConnectionOperation<Ops[K]> };
|
|
371
|
+
};
|
|
372
|
+
|
|
271
373
|
// Machine-readable failure classes. The capability proxy maps these to HTTP
|
|
272
374
|
// codes + health-machine outcomes; a provider outage must never read as a
|
|
273
375
|
// broken connector, and an auth failure must revoke, not retry.
|
|
@@ -281,7 +383,16 @@ export type OperationErrorCode =
|
|
|
281
383
|
| 'connector_error'
|
|
282
384
|
| 'auth_revoked'
|
|
283
385
|
| 'egress_blocked'
|
|
284
|
-
| 'rate_limited'
|
|
386
|
+
| 'rate_limited'
|
|
387
|
+
// The CONNECTOR refused the call on policy — typically an action that must be attributable
|
|
388
|
+
// reached it unattended. A denial, not a failure: it does not count against the connector's
|
|
389
|
+
// health or the provider's circuit breaker, so refusing correctly cannot degrade you.
|
|
390
|
+
| 'forbidden'
|
|
391
|
+
// The provider ANSWERED and rejected what the caller sent — bad args, an unknown record, a
|
|
392
|
+
// failed validation. The caller's fault, not the provider's: like `forbidden` it does not count
|
|
393
|
+
// against health or the circuit breaker, so one client's bad input cannot shed the connector
|
|
394
|
+
// for every tenant.
|
|
395
|
+
| 'invalid_request';
|
|
285
396
|
|
|
286
397
|
// Throw from a ConnectionOperation.call to surface a typed, agent-safe failure
|
|
287
398
|
// (maps to a 4xx with a clean message — never leaks the credential).
|
|
@@ -324,8 +435,20 @@ export class EgressViolationError extends OperationError {
|
|
|
324
435
|
// - a provider key that isn't lowercase-kebab (must match the URL segment and
|
|
325
436
|
// the connections_registry row)
|
|
326
437
|
//
|
|
438
|
+
// It also TYPES each operation's `call`: `args` is what the operation's own
|
|
439
|
+
// inputSchema parses to (z.infer of a zod schema, or any Standard Schema's
|
|
440
|
+
// output), so `args.limit` is a number when the schema says so — no casts. Known
|
|
441
|
+
// keys are typed; any other key still reads as `unknown`, exactly as it did when
|
|
442
|
+
// `args` was a bare Record<string, unknown>, so existing connectors that annotate
|
|
443
|
+
// `args` or parse it themselves compile unchanged.
|
|
444
|
+
//
|
|
327
445
|
// Usage: see keemakr-core's lib/connections/providers/_template.ts.
|
|
328
|
-
export function defineProvider
|
|
446
|
+
export function defineProvider<
|
|
447
|
+
Ops extends Record<string, OperationInputSchema> = Record<string, OperationInputSchema>,
|
|
448
|
+
>(definition: ProviderDefinition<Ops>): ConnectionProvider {
|
|
449
|
+
// The typed operations are a narrowing of ConnectionOperation's, never a widening: each `call`
|
|
450
|
+
// accepts at least what its schema parses to, which is all the platform ever passes it.
|
|
451
|
+
const p = definition as unknown as ConnectionProvider;
|
|
329
452
|
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p.provider)) {
|
|
330
453
|
throw new Error(
|
|
331
454
|
`connections: provider key '${p.provider}' must be lowercase-kebab (e.g. 'mailchimp', 'google-ads')`,
|
package/src/manifest.ts
CHANGED
|
@@ -26,11 +26,107 @@ const domainPattern =
|
|
|
26
26
|
// ('contacts.search', 'weather.current'), hyphens allowed within a segment.
|
|
27
27
|
const opName = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
28
28
|
|
|
29
|
+
// Top-level JSON Schema keywords an operation's declared input may NOT use. Each one lets the
|
|
30
|
+
// input mean something its top-level `properties` do not say — a union or intersection of shapes,
|
|
31
|
+
// a negation or a condition, a pattern of names, a reference resolved elsewhere — and the declared
|
|
32
|
+
// input exists precisely so that the list of argument names a caller may send can be READ off it:
|
|
33
|
+
// by a reviewer, by the forwarder that refuses anything else, and by channel reach, which consents
|
|
34
|
+
// to an operation only when it can see that no argument lets the caller pick the resource.
|
|
35
|
+
const REFUSED_INPUT_KEYWORDS = [
|
|
36
|
+
'oneOf',
|
|
37
|
+
'anyOf',
|
|
38
|
+
'allOf',
|
|
39
|
+
'not',
|
|
40
|
+
'if',
|
|
41
|
+
'then',
|
|
42
|
+
'else',
|
|
43
|
+
'patternProperties',
|
|
44
|
+
'dependentSchemas',
|
|
45
|
+
'$ref',
|
|
46
|
+
'$dynamicRef',
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
/** The most arguments one operation may declare, and the longest a declared input may be. */
|
|
50
|
+
const MAX_INPUT_PROPERTIES = 100;
|
|
51
|
+
const MAX_INPUT_SCHEMA_BYTES = 16_384;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* An operation's declared INPUT, as a JSON Schema object: the arguments a caller may send and
|
|
55
|
+
* nothing else. Optional, and read by core only for the lanes that have no adapter code to carry a
|
|
56
|
+
* schema of their own — remote (Lane B) and MCP connectors. There, core builds the operation's
|
|
57
|
+
* input validator from it (unknown arguments refused, required ones and primitive types checked),
|
|
58
|
+
* and channel reach can name the operation only when it is declared.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately narrow: `type: 'object'`, `properties` listed, `additionalProperties: false`, and
|
|
61
|
+
* none of the keywords above at the top level. Property schemas are ordinary JSON Schema; core
|
|
62
|
+
* checks their `type` and `enum` and leaves the rest to the connector.
|
|
63
|
+
*/
|
|
64
|
+
export const operationInputJsonSchema = z
|
|
65
|
+
.looseObject({
|
|
66
|
+
type: z.literal('object'),
|
|
67
|
+
properties: z.record(
|
|
68
|
+
z.string().min(1).max(100),
|
|
69
|
+
z.record(z.string(), z.unknown(), 'each property must be a JSON Schema object'),
|
|
70
|
+
),
|
|
71
|
+
required: z.array(z.string()).max(MAX_INPUT_PROPERTIES).optional(),
|
|
72
|
+
additionalProperties: z.literal(
|
|
73
|
+
false,
|
|
74
|
+
'input_schema must set additionalProperties: false — an operation that accepts arguments it does not list cannot be reviewed by its arguments',
|
|
75
|
+
),
|
|
76
|
+
description: z.string().max(500).optional(),
|
|
77
|
+
})
|
|
78
|
+
.superRefine((schema, ctx) => {
|
|
79
|
+
for (const keyword of REFUSED_INPUT_KEYWORDS) {
|
|
80
|
+
if (keyword in schema) {
|
|
81
|
+
ctx.addIssue({
|
|
82
|
+
code: 'custom',
|
|
83
|
+
path: [keyword],
|
|
84
|
+
message: `input_schema may not use '${keyword}' at the top level — declare the arguments as plain properties`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const names = Object.keys(schema.properties);
|
|
89
|
+
if (names.length > MAX_INPUT_PROPERTIES) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: 'custom',
|
|
92
|
+
path: ['properties'],
|
|
93
|
+
message: `input_schema may declare at most ${MAX_INPUT_PROPERTIES} properties`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const seen = new Set<string>();
|
|
97
|
+
for (const [i, name] of (schema.required ?? []).entries()) {
|
|
98
|
+
if (!names.includes(name)) {
|
|
99
|
+
ctx.addIssue({
|
|
100
|
+
code: 'custom',
|
|
101
|
+
path: ['required', i],
|
|
102
|
+
message: `required names '${name}', which is not one of its properties`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (seen.has(name)) {
|
|
106
|
+
ctx.addIssue({
|
|
107
|
+
code: 'custom',
|
|
108
|
+
path: ['required', i],
|
|
109
|
+
message: `required names '${name}' twice`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
seen.add(name);
|
|
113
|
+
}
|
|
114
|
+
if (JSON.stringify(schema).length > MAX_INPUT_SCHEMA_BYTES) {
|
|
115
|
+
ctx.addIssue({
|
|
116
|
+
code: 'custom',
|
|
117
|
+
message: `input_schema must be at most ${MAX_INPUT_SCHEMA_BYTES} characters of JSON`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
29
122
|
export const connectorOperationDeclaration = z.object({
|
|
30
123
|
name: z.string().regex(opName, 'operation names are lowercase, dot/hyphen-scoped'),
|
|
31
124
|
readWrite: z.enum(['read', 'write']),
|
|
32
125
|
idempotent: z.boolean(),
|
|
33
126
|
description: z.string().max(500).optional(),
|
|
127
|
+
// OPTIONAL, and it must stay optional: stored manifests of already-published versions are
|
|
128
|
+
// re-parsed with the current schema at every publish, and they do not carry it.
|
|
129
|
+
input_schema: operationInputJsonSchema.optional(),
|
|
34
130
|
});
|
|
35
131
|
|
|
36
132
|
export const connectorManifestSchema = z
|
|
@@ -183,6 +279,7 @@ export const connectorManifestSchema = z
|
|
|
183
279
|
});
|
|
184
280
|
|
|
185
281
|
export type ConnectorOperationDeclaration = z.infer<typeof connectorOperationDeclaration>;
|
|
282
|
+
export type OperationInputJsonSchema = z.infer<typeof operationInputJsonSchema>;
|
|
186
283
|
export type ConnectorManifest = z.infer<typeof connectorManifestSchema>;
|
|
187
284
|
|
|
188
285
|
// Parse + normalize an entry.json object. Throws ZodError on invalid input;
|