@keemakr/operator-sdk 0.5.0 → 0.7.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.5.0",
3
+ "version": "0.7.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):
@@ -255,6 +318,13 @@ export interface ConnectionOperation {
255
318
  // Safe-to-retry marker. Omitted ⇒ false. The platform NEVER auto-retries a
256
319
  // non-idempotent operation; callers (agents) read this to know what is safe.
257
320
  idempotent?: boolean;
321
+ // The fields of the RESULT that are files a person should see: generated
322
+ // images, synthesized audio. `path` names them with dots and `[]` for every
323
+ // item of a list, e.g. `images[].url` or `audio.url`. When the Operator calls
324
+ // the operation, each URL becomes an image or audio card in the chat, and
325
+ // core keeps its own copy, so the card still works after the provider's link
326
+ // expires. Omitted ⇒ the result holds no media.
327
+ media?: ReadonlyArray<{ kind: 'image' | 'audio'; path: string }>;
258
328
  // A Standard Schema (zod/valibot/…) validating the agent-supplied args. Typed
259
329
  // loosely here so the connector contract doesn't hard-depend on zod; adapters
260
330
  // pass a zod schema and the route validates with `.parse`/`.safeParse`.
@@ -268,6 +338,45 @@ export interface ConnectionOperation {
268
338
  ): Promise<unknown>;
269
339
  }
270
340
 
341
+ /** An operation's input schema: anything with `safeParse` (zod, valibot, a hand-rolled check). */
342
+ export type OperationInputSchema = ConnectionOperation['inputSchema'];
343
+
344
+ /**
345
+ * The `args` a `call` receives for an input schema: what the schema parses to, when it says (a
346
+ * Standard Schema — zod among them — declares its output type), intersected with an open record;
347
+ * an open record when it does not. The intersection is deliberate: known keys are typed, and any
348
+ * other key still reads as `unknown`, so code written against `Record<string, unknown>` keeps
349
+ * compiling.
350
+ */
351
+ export type OperationArgs<S> = S extends { readonly '~standard': { readonly types?: infer T } }
352
+ ? NonNullable<T> extends { readonly output: infer O }
353
+ ? O extends Record<string, unknown>
354
+ ? O & Record<string, unknown>
355
+ : Record<string, unknown>
356
+ : Record<string, unknown>
357
+ : Record<string, unknown>;
358
+
359
+ /** A ConnectionOperation whose `call` is typed by its own input schema (see defineProvider). */
360
+ export interface TypedConnectionOperation<S extends OperationInputSchema> extends Omit<
361
+ ConnectionOperation,
362
+ 'inputSchema' | 'call'
363
+ > {
364
+ inputSchema: S;
365
+ call(
366
+ cred: { accessToken: string; meta: Record<string, unknown> },
367
+ args: OperationArgs<S>,
368
+ ctx?: OperationContext,
369
+ ): Promise<unknown>;
370
+ }
371
+
372
+ /** What `defineProvider` takes: a ConnectionProvider whose operations are typed by their schemas. */
373
+ export type ProviderDefinition<Ops extends Record<string, OperationInputSchema>> = Omit<
374
+ ConnectionProvider,
375
+ 'operations'
376
+ > & {
377
+ operations?: { [K in keyof Ops]: TypedConnectionOperation<Ops[K]> };
378
+ };
379
+
271
380
  // Machine-readable failure classes. The capability proxy maps these to HTTP
272
381
  // codes + health-machine outcomes; a provider outage must never read as a
273
382
  // broken connector, and an auth failure must revoke, not retry.
@@ -281,7 +390,16 @@ export type OperationErrorCode =
281
390
  | 'connector_error'
282
391
  | 'auth_revoked'
283
392
  | 'egress_blocked'
284
- | 'rate_limited';
393
+ | 'rate_limited'
394
+ // The CONNECTOR refused the call on policy — typically an action that must be attributable
395
+ // reached it unattended. A denial, not a failure: it does not count against the connector's
396
+ // health or the provider's circuit breaker, so refusing correctly cannot degrade you.
397
+ | 'forbidden'
398
+ // The provider ANSWERED and rejected what the caller sent — bad args, an unknown record, a
399
+ // failed validation. The caller's fault, not the provider's: like `forbidden` it does not count
400
+ // against health or the circuit breaker, so one client's bad input cannot shed the connector
401
+ // for every tenant.
402
+ | 'invalid_request';
285
403
 
286
404
  // Throw from a ConnectionOperation.call to surface a typed, agent-safe failure
287
405
  // (maps to a 4xx with a clean message — never leaks the credential).
@@ -324,8 +442,20 @@ export class EgressViolationError extends OperationError {
324
442
  // - a provider key that isn't lowercase-kebab (must match the URL segment and
325
443
  // the connections_registry row)
326
444
  //
445
+ // It also TYPES each operation's `call`: `args` is what the operation's own
446
+ // inputSchema parses to (z.infer of a zod schema, or any Standard Schema's
447
+ // output), so `args.limit` is a number when the schema says so — no casts. Known
448
+ // keys are typed; any other key still reads as `unknown`, exactly as it did when
449
+ // `args` was a bare Record<string, unknown>, so existing connectors that annotate
450
+ // `args` or parse it themselves compile unchanged.
451
+ //
327
452
  // Usage: see keemakr-core's lib/connections/providers/_template.ts.
328
- export function defineProvider(p: ConnectionProvider): ConnectionProvider {
453
+ export function defineProvider<
454
+ Ops extends Record<string, OperationInputSchema> = Record<string, OperationInputSchema>,
455
+ >(definition: ProviderDefinition<Ops>): ConnectionProvider {
456
+ // The typed operations are a narrowing of ConnectionOperation's, never a widening: each `call`
457
+ // accepts at least what its schema parses to, which is all the platform ever passes it.
458
+ const p = definition as unknown as ConnectionProvider;
329
459
  if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p.provider)) {
330
460
  throw new Error(
331
461
  `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;