@keemakr/operator-sdk 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keemakr/operator-sdk",
3
- "version": "0.4.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):
@@ -100,6 +163,24 @@ export interface ConnectField {
100
163
  help?: string;
101
164
  }
102
165
 
166
+ // Outcome of a connect-time (or on-demand) health probe. `status` is the user-
167
+ // facing vocabulary the Hub renders: 'connected' (probe passed), 'needs_rescope'
168
+ // (OAuth completed but the granted scopes can't run this provider's operations —
169
+ // e.g. Google's granular-consent checkboxes were left unticked), 'no_mailbox'
170
+ // (the account exists but has no mailbox/service behind it — a Workspace account
171
+ // on a Microsoft-mail domain), 'unusable' (probe failed for another reason).
172
+ export interface ProbeResult {
173
+ status: 'connected' | 'needs_rescope' | 'no_mailbox' | 'unusable';
174
+ /** One human-readable sentence for the Hub's status chip. */
175
+ detail?: string;
176
+ /** Scopes the credential actually carries, when the probe can read them. */
177
+ grantedScopes?: string[];
178
+ /** Probed account identity (e.g. the mailbox email) — becomes account_label. */
179
+ accountLabel?: string | null;
180
+ /** Extra provider facts worth persisting under meta.probe. */
181
+ meta?: Record<string, unknown>;
182
+ }
183
+
103
184
  export interface ConnectionProvider {
104
185
  provider: string;
105
186
  displayName: string;
@@ -112,6 +193,29 @@ export interface ConnectionProvider {
112
193
  // Connect-path maturity. Omitted ⇒ 'ready'. See Maturity above.
113
194
  maturity?: Maturity;
114
195
 
196
+ // NOTE: a connection's scope (personal 'user' vs org-shared 'tenant') is NOT
197
+ // declared on the adapter. The live value is connections_registry.connection_mode
198
+ // (the platform-controlled overlay + rollback lever). Marketplace connectors
199
+ // seed it by declaring `connection_mode` in the MANIFEST (first publish only —
200
+ // the registry column is authoritative afterward, and reclassifying a live
201
+ // provider is a guarded staff console action). Built-ins still seed from
202
+ // registry-state's DEFAULT_USER_MODE_PROVIDERS, which stays the fail-closed
203
+ // fallback for unreadable registry reads.
204
+
205
+ // OAuth scopes this provider's operations actually NEED — unlike `scopes`
206
+ // (display-only), these are compared against the granted scope string by the
207
+ // connect-time probe; missing entries mark the connection 'needs_rescope'.
208
+ requiredScopes?: string[];
209
+
210
+ // Connect-time health probe: one cheap read (a profile fetch) that proves the
211
+ // credential can actually operate, run after the OAuth reports success and on
212
+ // demand from the Hub's "Check connection". Omitted ⇒ the connection is
213
+ // trusted as-is (status stays whatever the OAuth layer reported).
214
+ probe?(
215
+ cred: { accessToken: string; meta: Record<string, unknown> },
216
+ ctx?: OperationContext,
217
+ ): Promise<ProbeResult>;
218
+
115
219
  // Audience classification — see ConnectorSurface. Drives whether the end-user
116
220
  // client surfaces this connector and how. Optional in the type for now so
117
221
  // existing/external adapters compile through the SDK bump; defineProvider warns
@@ -204,6 +308,9 @@ export interface ConnectionProvider {
204
308
  // sees it. Keep `call` pure-ish: do the request, return JSON-serializable data.
205
309
  export interface ConnectionOperation {
206
310
  description?: string;
311
+ // Platform-only provider primitive. It remains available to trusted internal callers that
312
+ // invoke the adapter directly, but is omitted from catalogs and agent/user operation surfaces.
313
+ internal?: boolean;
207
314
  // Access class, for grant scoping + install consent. Omitted ⇒ 'write' (the
208
315
  // most restrictive assumption: unlabeled ops require the write scope and are
209
316
  // listed as writes on the consent screen).
@@ -224,6 +331,45 @@ export interface ConnectionOperation {
224
331
  ): Promise<unknown>;
225
332
  }
226
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
+
227
373
  // Machine-readable failure classes. The capability proxy maps these to HTTP
228
374
  // codes + health-machine outcomes; a provider outage must never read as a
229
375
  // broken connector, and an auth failure must revoke, not retry.
@@ -237,7 +383,16 @@ export type OperationErrorCode =
237
383
  | 'connector_error'
238
384
  | 'auth_revoked'
239
385
  | 'egress_blocked'
240
- | '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';
241
396
 
242
397
  // Throw from a ConnectionOperation.call to surface a typed, agent-safe failure
243
398
  // (maps to a 4xx with a clean message — never leaks the credential).
@@ -280,8 +435,20 @@ export class EgressViolationError extends OperationError {
280
435
  // - a provider key that isn't lowercase-kebab (must match the URL segment and
281
436
  // the connections_registry row)
282
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
+ //
283
445
  // Usage: see keemakr-core's lib/connections/providers/_template.ts.
284
- export function defineProvider(p: ConnectionProvider): ConnectionProvider {
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;
285
452
  if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p.provider)) {
286
453
  throw new Error(
287
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
@@ -46,6 +142,19 @@ export const connectorManifestSchema = z
46
142
  // publish gate. Optional (the adapter is authoritative); declaring it lets
47
143
  // the upload preview show it before any code runs.
48
144
  auth_kind: z.enum(['oauth2', 'api_key', 'app_password']).optional(),
145
+ // OWNERSHIP axis (orthogonal to `surface`): 'user' = each member connects
146
+ // their OWN account (one row per member, invisible to everyone else — a
147
+ // personal mailbox/drive/calendar); 'tenant' = one org-shared connection.
148
+ // Declare 'user' whenever the credential is one person's own account.
149
+ //
150
+ // Contract: this value SEEDS connections_registry.connection_mode at FIRST
151
+ // publish only — the platform column is authoritative afterward. Changing
152
+ // it in a later version raises a publish-diff warning and does NOT apply;
153
+ // live reclassification is a guarded staff console action (adopt/clean-cut).
154
+ // NEVER make this required, and never make the schema strict: stored
155
+ // manifests of already-published versions are re-parsed with the current
156
+ // schema at every publish, and only a defaulted optional field survives that.
157
+ connection_mode: z.enum(['tenant', 'user']).default('tenant'),
49
158
  // The operator-sdk contract version the connector targets (semver).
50
159
  sdk_version: z.string().regex(/^\d+\.\d+\.\d+$/, 'sdk_version must be exact semver'),
51
160
  // Module (relative path inside the bundle) whose named export is the
@@ -170,6 +279,7 @@ export const connectorManifestSchema = z
170
279
  });
171
280
 
172
281
  export type ConnectorOperationDeclaration = z.infer<typeof connectorOperationDeclaration>;
282
+ export type OperationInputJsonSchema = z.infer<typeof operationInputJsonSchema>;
173
283
  export type ConnectorManifest = z.infer<typeof connectorManifestSchema>;
174
284
 
175
285
  // Parse + normalize an entry.json object. Throws ZodError on invalid input;