@keemakr/operator-sdk 0.1.0 → 0.3.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,12 +1,15 @@
1
1
  {
2
2
  "name": "@keemakr/operator-sdk",
3
- "version": "0.1.0",
4
- "description": "The keemakr connector contract: defineProvider + ConnectionProvider/ConnectionOperation types, shared by keemakr-core's built-in adapters and external connector repos. One shape, two consumers.",
3
+ "version": "0.3.0",
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",
7
7
  "types": "./src/index.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": "./src/index.ts",
10
+ "./manifest": "./src/manifest.ts",
11
+ "./conformance": "./src/conformance/index.ts",
12
+ "./conformance/runner": "./src/conformance/runner.ts"
10
13
  },
11
14
  "files": [
12
15
  "src"
@@ -0,0 +1,180 @@
1
+ // The golden conformance suite — ONE source of truth, two runners.
2
+ //
3
+ // keemakr-core's publish gate (validation queue, gate G4) and the
4
+ // kee-connector CLI's `certify` command both execute exactly these checks, so
5
+ // an author's local green can never disagree with the server's. The suite
6
+ // receives an ALREADY-IMPORTED provider module (the runners own how it was
7
+ // loaded/sandboxed) plus the parsed manifest, and returns a structured report.
8
+ //
9
+ // Check #1 (import-without-throwing) belongs to the RUNNER: defineProvider's
10
+ // invariants fire at import, before this suite ever sees the module.
11
+
12
+ import type { ConnectorManifest } from '../manifest';
13
+ import type { ConnectionProvider, OperationContext } from '../index';
14
+
15
+ export interface ConformanceCheck {
16
+ id: string;
17
+ title: string;
18
+ ok: boolean;
19
+ detail?: string;
20
+ }
21
+
22
+ export interface ConformanceReport {
23
+ ok: boolean;
24
+ checks: ConformanceCheck[];
25
+ }
26
+
27
+ const TEST_TIMEOUT_MS = 5_000;
28
+
29
+ // A ctx whose fetch always answers 401 — test() must SETTLE against it (a clean
30
+ // {ok:false} or a typed throw), never hang and never crash the runner.
31
+ function stubCtx(): OperationContext {
32
+ return {
33
+ fetch: (async () =>
34
+ new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })) as typeof fetch,
35
+ signal: AbortSignal.timeout(TEST_TIMEOUT_MS),
36
+ };
37
+ }
38
+
39
+ export async function runConformance(
40
+ provider: ConnectionProvider,
41
+ manifest: ConnectorManifest,
42
+ ): Promise<ConformanceReport> {
43
+ const checks: ConformanceCheck[] = [];
44
+ const add = (id: string, title: string, ok: boolean, detail?: string) =>
45
+ checks.push({ id, title, ok, ...(detail ? { detail } : {}) });
46
+
47
+ // 2 — the export is provider-shaped.
48
+ const shaped =
49
+ !!provider && typeof provider === 'object' && typeof provider.provider === 'string';
50
+ add('export-shape', 'export is a ConnectionProvider', shaped);
51
+ if (!shaped) return { ok: false, checks };
52
+
53
+ // 3/4/5 — identity matches the manifest.
54
+ add(
55
+ 'provider-key',
56
+ 'provider key matches the manifest',
57
+ provider.provider === manifest.provider,
58
+ `code '${provider.provider}' vs manifest '${manifest.provider}'`,
59
+ );
60
+ add(
61
+ 'publisher',
62
+ 'publisher matches the manifest (when set in code)',
63
+ !provider.publisher || provider.publisher === manifest.publisher,
64
+ );
65
+ add(
66
+ 'auth-kind',
67
+ 'auth kind matches the manifest declaration (when declared)',
68
+ !manifest.auth_kind || provider.authKind === manifest.auth_kind,
69
+ `code '${provider.authKind}' vs manifest '${manifest.auth_kind ?? '—'}'`,
70
+ );
71
+
72
+ // 6 — code may not claim MORE network than the reviewed manifest.
73
+ const undeclared = (provider.allowedDomains ?? []).filter(
74
+ (d) => !manifest.allowed_domains.includes(d),
75
+ );
76
+ add(
77
+ 'domains-subset',
78
+ 'code allowedDomains ⊆ manifest allowed_domains',
79
+ undeclared.length === 0,
80
+ undeclared.length ? `undeclared: ${undeclared.join(', ')}` : undefined,
81
+ );
82
+
83
+ // 7/8 — operation inventory matches, both directions.
84
+ const codeOps = Object.keys(provider.operations ?? {});
85
+ const manifestOps = manifest.operations.map((o) => o.name);
86
+ const missing = manifestOps.filter((o) => !codeOps.includes(o));
87
+ const phantom = codeOps.filter((o) => !manifestOps.includes(o));
88
+ add(
89
+ 'ops-declared-exist',
90
+ 'every manifest operation exists in code',
91
+ missing.length === 0,
92
+ missing.length ? `missing: ${missing.join(', ')}` : undefined,
93
+ );
94
+ add(
95
+ 'ops-no-phantoms',
96
+ 'every code operation is declared in the manifest',
97
+ phantom.length === 0,
98
+ phantom.length ? `undeclared: ${phantom.join(', ')}` : undefined,
99
+ );
100
+
101
+ // 9/10 — per-op access + idempotency match the reviewed declarations.
102
+ const accessMismatch: string[] = [];
103
+ const idemMismatch: string[] = [];
104
+ for (const decl of manifest.operations) {
105
+ const op = provider.operations?.[decl.name];
106
+ if (!op) continue;
107
+ if ((op.access ?? 'write') !== decl.readWrite) accessMismatch.push(decl.name);
108
+ if ((op.idempotent ?? false) !== decl.idempotent) idemMismatch.push(decl.name);
109
+ }
110
+ add(
111
+ 'ops-access',
112
+ 'op access classes match the manifest',
113
+ accessMismatch.length === 0,
114
+ accessMismatch.length ? accessMismatch.join(', ') : undefined,
115
+ );
116
+ add(
117
+ 'ops-idempotent',
118
+ 'op idempotency flags match the manifest',
119
+ idemMismatch.length === 0,
120
+ idemMismatch.length ? idemMismatch.join(', ') : undefined,
121
+ );
122
+
123
+ // 11/12 — schemas parse safely; call signatures accept (cred, args).
124
+ const badSchema: string[] = [];
125
+ const badArity: string[] = [];
126
+ for (const [name, op] of Object.entries(provider.operations ?? {})) {
127
+ try {
128
+ const r = op.inputSchema?.safeParse({});
129
+ if (!r || typeof r.success !== 'boolean') badSchema.push(name);
130
+ } catch {
131
+ badSchema.push(name);
132
+ }
133
+ if (typeof op.call !== 'function' || op.call.length < 2) badArity.push(name);
134
+ }
135
+ add(
136
+ 'schemas-safe',
137
+ 'every inputSchema.safeParse({}) returns without throwing',
138
+ badSchema.length === 0,
139
+ badSchema.length ? badSchema.join(', ') : undefined,
140
+ );
141
+ add(
142
+ 'call-arity',
143
+ 'every call accepts (cred, args)',
144
+ badArity.length === 0,
145
+ badArity.length ? badArity.join(', ') : undefined,
146
+ );
147
+
148
+ // 13 — test() settles cleanly against garbage credentials.
149
+ let testOk = false;
150
+ let testDetail: string | undefined;
151
+ try {
152
+ const result = await Promise.race([
153
+ provider.test({ accessToken: 'conformance-garbage-credential', meta: {} }, stubCtx()),
154
+ new Promise<never>((_, reject) =>
155
+ setTimeout(() => reject(new Error('test() did not settle within 5s')), TEST_TIMEOUT_MS),
156
+ ),
157
+ ]);
158
+ // A clean negative ({ok:false}) is the CORRECT answer to garbage creds; a
159
+ // positive is suspicious but not fatal (some stubs accept anything in dev).
160
+ testOk = typeof result === 'object' && result !== null && typeof result.ok === 'boolean';
161
+ if (result && (result as { ok?: boolean }).ok === true) {
162
+ testDetail = 'test() accepted garbage credentials — verify it actually validates';
163
+ }
164
+ } catch (e) {
165
+ // A THROW is acceptable only if it's a typed, message-bearing error.
166
+ testOk = e instanceof Error && e.message !== '' && !/did not settle/.test(e.message);
167
+ testDetail = e instanceof Error ? e.message : String(e);
168
+ }
169
+ add('test-settles', 'test() settles cleanly against bad credentials', testOk, testDetail);
170
+
171
+ // 14 — auth-shape completeness (re-asserting defineProvider's rules against
172
+ // the RUNTIME object, in case the module bypassed defineProvider).
173
+ const authShapeOk =
174
+ provider.authKind === 'oauth2'
175
+ ? typeof provider.buildAuthUrl === 'function' && typeof provider.exchangeCode === 'function'
176
+ : (provider.connectFields?.length ?? 0) > 0;
177
+ add('auth-shape', 'auth lifecycle / connect form is complete for the auth kind', authShapeOk);
178
+
179
+ return { ok: checks.every((c) => c.ok), checks };
180
+ }
@@ -0,0 +1,87 @@
1
+ // Standalone conformance runner — the entrypoint the publish gate bundles
2
+ // (esbuild, self-contained) and spawns as a sandboxed child:
3
+ //
4
+ // node --permission --allow-fs-read=<dir> runner.mjs <artifact.mjs> <manifest.json>
5
+ //
6
+ // with an EMPTY environment. Check #1 (the import itself — defineProvider's
7
+ // invariants fire here) happens in this process; everything after delegates to
8
+ // the shared suite. The report is printed as one JSON line on stdout; exit 0
9
+ // on pass, 1 on fail, 2 on harness error.
10
+
11
+ import { readFileSync } from 'node:fs';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { runConformance, type ConformanceReport } from './index';
14
+ import { connectorManifestSchema } from '../manifest';
15
+
16
+ async function main(): Promise<number> {
17
+ const [artifactPath, manifestPath, exportName = 'provider'] = process.argv.slice(2);
18
+ if (!artifactPath || !manifestPath) {
19
+ console.error('usage: runner <artifact.mjs> <manifest.json> [exportName]');
20
+ return 2;
21
+ }
22
+
23
+ const manifest = connectorManifestSchema.parse(JSON.parse(readFileSync(manifestPath, 'utf8')));
24
+
25
+ let report: ConformanceReport;
26
+ let mod: Record<string, unknown>;
27
+ try {
28
+ mod = (await import(pathToFileURL(artifactPath).href)) as Record<string, unknown>;
29
+ } catch (e) {
30
+ report = {
31
+ ok: false,
32
+ checks: [
33
+ {
34
+ id: 'imports',
35
+ title: 'artifact imports without throwing (defineProvider invariants)',
36
+ ok: false,
37
+ detail: e instanceof Error ? e.message : String(e),
38
+ },
39
+ ],
40
+ };
41
+ console.log(JSON.stringify(report));
42
+ return 1;
43
+ }
44
+
45
+ const provider = (mod[exportName] ?? (mod.default as Record<string, unknown>)?.[exportName]) as
46
+ | Parameters<typeof runConformance>[0]
47
+ | undefined;
48
+ if (!provider) {
49
+ report = {
50
+ ok: false,
51
+ checks: [
52
+ {
53
+ id: 'export-exists',
54
+ title: `named export '${exportName}' exists`,
55
+ ok: false,
56
+ detail: `exports found: ${Object.keys(mod).join(', ') || '(none)'}`,
57
+ },
58
+ ],
59
+ };
60
+ console.log(JSON.stringify(report));
61
+ return 1;
62
+ }
63
+
64
+ const suite = await runConformance(provider, manifest);
65
+ report = {
66
+ ok: suite.ok,
67
+ checks: [
68
+ {
69
+ id: 'imports',
70
+ title: 'artifact imports without throwing (defineProvider invariants)',
71
+ ok: true,
72
+ },
73
+ { id: 'export-exists', title: `named export '${exportName}' exists`, ok: true },
74
+ ...suite.checks,
75
+ ],
76
+ };
77
+ console.log(JSON.stringify(report));
78
+ return report.ok ? 0 : 1;
79
+ }
80
+
81
+ main().then(
82
+ (code) => process.exit(code),
83
+ (e) => {
84
+ console.error(e instanceof Error ? e.message : String(e));
85
+ process.exit(2);
86
+ },
87
+ );
package/src/index.ts CHANGED
@@ -22,6 +22,23 @@ export type AuthKind = 'oauth2' | 'api_key' | 'app_password';
22
22
  // to 'ready'), so legacy adapters stay connectable until told otherwise.
23
23
  export type Maturity = 'ready' | 'coming_soon';
24
24
 
25
+ // Who a connector is FOR — the audience axis the end-user client uses to decide
26
+ // whether to surface it, and how. Classify EVERY connector (defineProvider will
27
+ // enforce this) so the client catalog never conflates the three kinds:
28
+ // 'user-account' → the tenant's OWN account; connect/disconnect is the point.
29
+ // Shown in the end-user connections hub (Gmail, Slack, Stripe,
30
+ // HubSpot, social channels).
31
+ // 'agent-tool' → infrastructure an AGENT uses to do a task, not a personal
32
+ // account. The platform may hold a shared key; a tenant may
33
+ // override with their own. NEVER a standalone hub card — it
34
+ // surfaces only via the install card of an agent that declares
35
+ // it (Apollo, Hunter, Clearbit — enrichment/analytics/scraping).
36
+ // 'internal' → platform plumbing, or a dormant connector with no operations
37
+ // and no consumer. Never shown to end users (e.g. agent-sessions;
38
+ // op-less stubs — also keep those at maturity:'coming_soon').
39
+ // See docs/architecture/05 + 15 and the AGENTS.md "Connectors" convention.
40
+ export type ConnectorSurface = 'user-account' | 'agent-tool' | 'internal';
41
+
25
42
  export interface TokenSet {
26
43
  accessToken: string;
27
44
  refreshToken?: string | null;
@@ -35,6 +52,37 @@ export interface TestResult {
35
52
  detail?: string;
36
53
  }
37
54
 
55
+ // ─── Execution context ───────────────────────────────────────────────────────
56
+ //
57
+ // Handed to an operation (and test()) by the platform at call time. Marketplace
58
+ // connectors MUST route all network egress through ctx.fetch — it enforces the
59
+ // manifest's allowed_domains at the egress layer (private/link-local IPs blocked,
60
+ // redirects re-validated, response size + wall-clock capped). Built-in adapters
61
+ // are grandfathered on ambient fetch and migrate opportunistically.
62
+ export interface OperationContext {
63
+ // Governed HTTP client: domain-allowlisted, SSRF-guarded, size/time-capped.
64
+ fetch: typeof fetch;
65
+ // One wall-clock budget for the whole operation call; combine with your own
66
+ // per-request signals via AbortSignal.any if needed.
67
+ signal: AbortSignal;
68
+ // Connector-level config resolved by the platform (e.g. an OAuth client id
69
+ // from the encrypted config store). Never tenant credentials — those arrive
70
+ // as `cred`.
71
+ config?: Record<string, string>;
72
+ // Trace-correlated logger. Never log credentials or response payloads.
73
+ log?: (msg: string) => void;
74
+ }
75
+
76
+ // Slim context for the OAuth lifecycle hooks (buildAuthUrl/exchangeCode/refresh):
77
+ // connector-level config, plus the governed fetch for the token exchange —
78
+ // marketplace connector code may not touch ambient fetch ANYWHERE (the publish
79
+ // scan bans it), so the lifecycle's egress rides the same fenced client as
80
+ // operations. There is no tenant credential yet at these steps.
81
+ export interface LifecycleContext {
82
+ config?: Record<string, string>;
83
+ fetch?: typeof fetch;
84
+ }
85
+
38
86
  // A single field in an inline connect form (api_key / app_password adapters).
39
87
  // The UI renders these generically; the adapter owns the spec so the form and
40
88
  // the route's expectations never drift. `target` says where the value goes in
@@ -64,6 +112,24 @@ export interface ConnectionProvider {
64
112
  // Connect-path maturity. Omitted ⇒ 'ready'. See Maturity above.
65
113
  maturity?: Maturity;
66
114
 
115
+ // Audience classification — see ConnectorSurface. Drives whether the end-user
116
+ // client surfaces this connector and how. Optional in the type for now so
117
+ // existing/external adapters compile through the SDK bump; defineProvider warns
118
+ // when omitted and this tightens to required once every built-in declares it.
119
+ surface?: ConnectorSurface;
120
+
121
+ // Egress allowlist: the ONLY hosts this connector's operations/test may reach
122
+ // through ctx.fetch. Bare hostname for an exact match ('api.acme.com') or a
123
+ // wildcard for subdomains ('*.acme.com' — does NOT match the apex; list it
124
+ // separately). For marketplace connectors the authoritative copy lives in the
125
+ // reviewed manifest and is enforced from the platform's registry row — this
126
+ // field documents intent and powers the local dev harness.
127
+ allowedDomains?: string[];
128
+
129
+ // Publisher handle (lowercase-kebab) for marketplace connectors. Built-ins
130
+ // omit it (first-party).
131
+ publisher?: string;
132
+
67
133
  // Inline connect-form spec for api_key / app_password shapes. Omitted by pure
68
134
  // oauth2 adapters (they render a Connect button instead). For a dual-mode
69
135
  // provider (WordPress), this describes the NON-oauth fallback form; the UI
@@ -81,13 +147,29 @@ export interface ConnectionProvider {
81
147
  // meta.mode='<fallbackMode>' so the route/adapter know which branch to take.
82
148
  dualMode?: { fallbackMode: string; oauthLabel?: string; formLabel?: string };
83
149
 
84
- // OAuth-only lifecycle. Omitted by api_key / app_password adapters.
85
- buildAuthUrl?(args: { tenantId: string; state: string; callbackUrl: string }): string;
86
- exchangeCode?(args: { code: string; callbackUrl: string }): Promise<TokenSet>;
87
- refresh?(args: { refreshToken: string }): Promise<TokenSet>;
150
+ // OAuth-only lifecycle. Omitted by api_key / app_password adapters. The
151
+ // optional trailing ctx carries connector-level config (encrypted config
152
+ // store) so client ids/secrets need not live in process.env.
153
+ buildAuthUrl?(
154
+ args: { tenantId: string; state: string; callbackUrl: string },
155
+ ctx?: LifecycleContext,
156
+ ): string;
157
+ exchangeCode?(
158
+ args: { code: string; callbackUrl: string },
159
+ ctx?: LifecycleContext,
160
+ ): Promise<TokenSet>;
161
+ refresh?(args: { refreshToken: string }, ctx?: LifecycleContext): Promise<TokenSet>;
88
162
 
89
- // All kinds — validate a credential at save time.
90
- test(args: { accessToken: string; meta: Record<string, unknown> }): Promise<TestResult>;
163
+ // Best-effort revocation at the provider, called when a tenant disconnects
164
+ // (or an install is removed). Optional; failures are audited, never fatal.
165
+ revoke?(tokenSet: { accessToken: string; refreshToken?: string | null }): Promise<void>;
166
+
167
+ // All kinds — validate a credential at save time. ctx is passed by the
168
+ // platform (optional in the type so existing adapters compile unchanged).
169
+ test(
170
+ args: { accessToken: string; meta: Record<string, unknown> },
171
+ ctx?: OperationContext,
172
+ ): Promise<TestResult>;
91
173
 
92
174
  // Derive a human-readable account_label + durable handle
93
175
  // (stripe_user_id, store url, ...) to persist alongside the token.
@@ -111,6 +193,13 @@ export interface ConnectionProvider {
111
193
  // sees it. Keep `call` pure-ish: do the request, return JSON-serializable data.
112
194
  export interface ConnectionOperation {
113
195
  description?: string;
196
+ // Access class, for grant scoping + install consent. Omitted ⇒ 'write' (the
197
+ // most restrictive assumption: unlabeled ops require the write scope and are
198
+ // listed as writes on the consent screen).
199
+ access?: 'read' | 'write';
200
+ // Safe-to-retry marker. Omitted ⇒ false. The platform NEVER auto-retries a
201
+ // non-idempotent operation; callers (agents) read this to know what is safe.
202
+ idempotent?: boolean;
114
203
  // A Standard Schema (zod/valibot/…) validating the agent-supplied args. Typed
115
204
  // loosely here so the connector contract doesn't hard-depend on zod; adapters
116
205
  // pass a zod schema and the route validates with `.parse`/`.safeParse`.
@@ -120,17 +209,49 @@ export interface ConnectionOperation {
120
209
  call(
121
210
  cred: { accessToken: string; meta: Record<string, unknown> },
122
211
  args: Record<string, unknown>,
212
+ ctx?: OperationContext,
123
213
  ): Promise<unknown>;
124
214
  }
125
215
 
216
+ // Machine-readable failure classes. The capability proxy maps these to HTTP
217
+ // codes + health-machine outcomes; a provider outage must never read as a
218
+ // broken connector, and an auth failure must revoke, not retry.
219
+ // provider_unavailable → the upstream API is down/5xx/timing out
220
+ // connector_error → the connector's own fault (bug, bad normalization)
221
+ // auth_revoked → the credential is dead (401-class, invalid_grant)
222
+ // egress_blocked → the governed fetch refused a non-allowlisted host
223
+ // rate_limited → upstream 429 (platform applies backoff)
224
+ export type OperationErrorCode =
225
+ | 'provider_unavailable'
226
+ | 'connector_error'
227
+ | 'auth_revoked'
228
+ | 'egress_blocked'
229
+ | 'rate_limited';
230
+
126
231
  // Throw from a ConnectionOperation.call to surface a typed, agent-safe failure
127
232
  // (maps to a 4xx with a clean message — never leaks the credential).
128
233
  export class OperationError extends Error {
129
234
  status: number;
130
- constructor(message: string, status = 400) {
235
+ code?: OperationErrorCode;
236
+ constructor(message: string, status = 400, code?: OperationErrorCode) {
131
237
  super(message);
132
238
  this.name = 'OperationError';
133
239
  this.status = status;
240
+ this.code = code;
241
+ }
242
+ }
243
+
244
+ // Thrown by the governed fetch when an operation tries to reach a host outside
245
+ // its allowlist (or a private/link-local address). Carries the offending host
246
+ // so audit + the author's dev harness can say exactly what was refused.
247
+ export class EgressViolationError extends OperationError {
248
+ host: string;
249
+ reason: string;
250
+ constructor(host: string, reason: string) {
251
+ super(`egress blocked: ${reason} (${host})`, 502, 'egress_blocked');
252
+ this.name = 'EgressViolationError';
253
+ this.host = host;
254
+ this.reason = reason;
134
255
  }
135
256
  }
136
257
 
@@ -169,5 +290,24 @@ export function defineProvider(p: ConnectionProvider): ConnectionProvider {
169
290
  if (typeof p.test !== 'function') {
170
291
  throw new Error(`connections: provider '${p.provider}' must implement test()`);
171
292
  }
293
+ if (!p.surface) {
294
+ // Nudge, not throw (yet): the built-ins all declare `surface`, but external/marketplace
295
+ // connectors (separate repos) haven't adopted it. Tightening to a hard throw + a required
296
+ // field is gated on adding `surface` to the connector manifest schema.
297
+ console.warn(
298
+ `connections: provider '${p.provider}' does not declare surface ` +
299
+ `('user-account' | 'agent-tool' | 'internal') — classify it so the client catalog knows ` +
300
+ `whether and how to show it. See the AGENTS.md "Connectors" convention.`,
301
+ );
302
+ }
303
+ if (p.operations && Object.keys(p.operations).length > 0 && !p.allowedDomains?.length) {
304
+ // Warn, don't throw: built-ins are grandfathered on ambient fetch. The
305
+ // marketplace publish gate REQUIRES allowed_domains in the manifest, so
306
+ // third-party connectors can't ship without one.
307
+ console.warn(
308
+ `connections: provider '${p.provider}' declares operations but no allowedDomains — ` +
309
+ `its egress cannot be fenced. Declare the hosts its operations reach.`,
310
+ );
311
+ }
172
312
  return p;
173
313
  }
@@ -0,0 +1,171 @@
1
+ // @keemakr/operator-sdk/manifest — the connector entry.json schema.
2
+ //
3
+ // One schema, two consumers: keemakr-core's marketplace upload validation and
4
+ // the kee-connector CLI's `lint` both parse the author's entry.json with this.
5
+ // The manifest is the DECLARED, reviewable half of a connector (identity,
6
+ // egress allowlist, operation inventory); the code half (connectFields, the
7
+ // auth lifecycle, the operation bodies) lives in the adapter itself.
8
+ //
9
+ // Notable rule: `allowed_domains` here is what reviewers approve and what the
10
+ // platform persists to its registry at publish — the runtime enforces THAT
11
+ // copy, never what the code claims.
12
+
13
+ import { z } from 'zod';
14
+
15
+ // Same shape defineProvider enforces for provider keys — and reused for
16
+ // publisher handles. Must be a valid URL path segment (it is one, in
17
+ // /api/capability/conn/[provider]/[op]) and a valid scope-string atom.
18
+ const kebab = /^[a-z0-9]+(-[a-z0-9]+)*$/;
19
+
20
+ // 'api.acme.com' (exact), '*.acme.com' (subdomains only — the apex must be
21
+ // listed separately), optional ':port' pin (443 implied otherwise).
22
+ const domainPattern =
23
+ /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:\d{1,5})?$/i;
24
+
25
+ // Operation names follow the adapter's key convention: lowercase, dot-scoped
26
+ // ('contacts.search', 'weather.current'), hyphens allowed within a segment.
27
+ const opName = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
28
+
29
+ export const connectorOperationDeclaration = z.object({
30
+ name: z.string().regex(opName, 'operation names are lowercase, dot/hyphen-scoped'),
31
+ readWrite: z.enum(['read', 'write']),
32
+ idempotent: z.boolean(),
33
+ description: z.string().max(500).optional(),
34
+ });
35
+
36
+ export const connectorManifestSchema = z
37
+ .object({
38
+ kind: z.literal('connection'),
39
+ publisher: z.string().regex(kebab, 'publisher handle must be lowercase-kebab'),
40
+ provider: z.string().regex(kebab, 'provider key must be lowercase-kebab'),
41
+ display_name: z.string().min(1).max(120),
42
+ description: z.string().max(2000).optional(),
43
+ category: z.string().regex(kebab, 'category must be lowercase-kebab'),
44
+ icon: z.string().max(2000).optional(),
45
+ // Declared auth kind — cross-checked against the compiled adapter at the
46
+ // publish gate. Optional (the adapter is authoritative); declaring it lets
47
+ // the upload preview show it before any code runs.
48
+ auth_kind: z.enum(['oauth2', 'api_key', 'app_password']).optional(),
49
+ // The operator-sdk contract version the connector targets (semver).
50
+ sdk_version: z.string().regex(/^\d+\.\d+\.\d+$/, 'sdk_version must be exact semver'),
51
+ // Module (relative path inside the bundle) whose named export is the
52
+ // defineProvider(...) result. Lane A (vendored) connectors require it; a
53
+ // pure Lane B (remote) connector may omit it and declare runtime_url instead.
54
+ entrypoint: z.string().min(1).max(300).optional(),
55
+ export: z.string().min(1).max(100).default('provider'),
56
+ // Lane B: the author-hosted service implementing the wire protocol
57
+ // (POST {runtime_url}/connector/v1/{op|test|auth-url|exchange|refresh|revoke}).
58
+ // https only; core forwards calls with a short-TTL signed grant and the
59
+ // tenant's credential — which is why remote connectors require explicit
60
+ // per-connection consent before any call.
61
+ runtime_url: z.string().url().max(500).optional(),
62
+ // Lane 'mcp': the author points at a remote Model Context Protocol server
63
+ // instead of writing operation bodies. Each declared operation forwards to
64
+ // that server's `tools/call` over Streamable HTTP, driven through the same
65
+ // governed fetch as Lane B. `url` is https-only and author-declared (never
66
+ // tenant-supplied); `auth` is 'injected' only in v1 — core vaults the
67
+ // tenant's credential and rides it on a transport header, never in the tool
68
+ // args. ('server_managed', where the MCP server holds its own upstream
69
+ // OAuth, is reserved for a later, higher-tier release.)
70
+ mcp: z
71
+ .object({
72
+ url: z.string().url().max(500),
73
+ auth: z.enum(['injected']),
74
+ })
75
+ .optional(),
76
+ // Remote connectors have no adapter code in core, so their connect form is
77
+ // declared HERE (vendored connectors keep connectFields in code — the
78
+ // adapter stays the single source of truth for Lane A). MCP connectors are
79
+ // the same: no adapter code, so the form lives here too.
80
+ connect_fields: z
81
+ .array(
82
+ z.object({
83
+ target: z.string().min(1),
84
+ label: z.string().min(1),
85
+ secret: z.boolean().optional(),
86
+ optional: z.boolean().optional(),
87
+ help: z.string().max(500).optional(),
88
+ }),
89
+ )
90
+ .optional(),
91
+ // The reviewable egress allowlist — the ONLY hosts ctx.fetch may reach.
92
+ allowed_domains: z
93
+ .array(z.string().regex(domainPattern, 'each entry is a bare host or *.host, optional :port'))
94
+ .min(1, 'declare at least one allowed domain'),
95
+ // Connector-level config keys the platform must supply (resolved from the
96
+ // encrypted config store — values are set by staff at publish, never bundled).
97
+ env: z
98
+ .array(z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'config keys are SCREAMING_SNAKE'))
99
+ .default([]),
100
+ // Human-readable data-handling summary, surfaced on connect/install consent.
101
+ data_handling: z.string().min(1).max(2000),
102
+ // Static operation inventory — cross-checked against the compiled adapter at
103
+ // the publish gate (both directions: no undeclared ops, no phantom ops). For
104
+ // remote connectors this list IS the contract core builds the forwarder from.
105
+ operations: z.array(connectorOperationDeclaration).min(1),
106
+ changelog: z.string().max(5000).optional(),
107
+ })
108
+ .superRefine((m, ctx) => {
109
+ const lanes = [m.entrypoint, m.runtime_url, m.mcp].filter((v) => v != null).length;
110
+ if (lanes === 0) {
111
+ ctx.addIssue({
112
+ code: 'custom',
113
+ message:
114
+ 'declare exactly one lane: an entrypoint (vendored, Lane A), a runtime_url (remote, Lane B), or an mcp endpoint (remote MCP)',
115
+ });
116
+ }
117
+ if (lanes > 1) {
118
+ ctx.addIssue({
119
+ code: 'custom',
120
+ message:
121
+ 'declare exactly one lane — entrypoint, runtime_url, and mcp are mutually exclusive',
122
+ });
123
+ }
124
+ if (m.runtime_url && !m.runtime_url.startsWith('https://')) {
125
+ ctx.addIssue({ code: 'custom', path: ['runtime_url'], message: 'runtime_url must be https' });
126
+ }
127
+ if (m.runtime_url && m.auth_kind === 'oauth2') {
128
+ ctx.addIssue({
129
+ code: 'custom',
130
+ path: ['auth_kind'],
131
+ message:
132
+ 'remote (Lane B) connectors are api_key/app_password only — core will not broker an author-owned OAuth dance',
133
+ });
134
+ }
135
+ if (m.runtime_url && (!m.connect_fields || m.connect_fields.length === 0)) {
136
+ ctx.addIssue({
137
+ code: 'custom',
138
+ path: ['connect_fields'],
139
+ message:
140
+ 'remote connectors must declare connect_fields (there is no adapter code to own the form)',
141
+ });
142
+ }
143
+ if (m.mcp && !m.mcp.url.startsWith('https://')) {
144
+ ctx.addIssue({ code: 'custom', path: ['mcp', 'url'], message: 'mcp.url must be https' });
145
+ }
146
+ if (m.mcp && m.auth_kind === 'oauth2') {
147
+ ctx.addIssue({
148
+ code: 'custom',
149
+ path: ['auth_kind'],
150
+ message:
151
+ 'mcp connectors are api_key/app_password only — core injects the vaulted credential on a transport header, it does not broker an author-owned OAuth dance',
152
+ });
153
+ }
154
+ if (m.mcp && (!m.connect_fields || m.connect_fields.length === 0)) {
155
+ ctx.addIssue({
156
+ code: 'custom',
157
+ path: ['connect_fields'],
158
+ message:
159
+ 'mcp connectors must declare connect_fields (there is no adapter code to own the form)',
160
+ });
161
+ }
162
+ });
163
+
164
+ export type ConnectorOperationDeclaration = z.infer<typeof connectorOperationDeclaration>;
165
+ export type ConnectorManifest = z.infer<typeof connectorManifestSchema>;
166
+
167
+ // Parse + normalize an entry.json object. Throws ZodError on invalid input;
168
+ // callers wanting a report use connectorManifestSchema.safeParse directly.
169
+ export function parseConnectorManifest(raw: unknown): ConnectorManifest {
170
+ return connectorManifestSchema.parse(raw);
171
+ }