@keemakr/operator-sdk 0.1.0 → 0.2.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.2.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
@@ -35,6 +35,37 @@ export interface TestResult {
35
35
  detail?: string;
36
36
  }
37
37
 
38
+ // ─── Execution context ───────────────────────────────────────────────────────
39
+ //
40
+ // Handed to an operation (and test()) by the platform at call time. Marketplace
41
+ // connectors MUST route all network egress through ctx.fetch — it enforces the
42
+ // manifest's allowed_domains at the egress layer (private/link-local IPs blocked,
43
+ // redirects re-validated, response size + wall-clock capped). Built-in adapters
44
+ // are grandfathered on ambient fetch and migrate opportunistically.
45
+ export interface OperationContext {
46
+ // Governed HTTP client: domain-allowlisted, SSRF-guarded, size/time-capped.
47
+ fetch: typeof fetch;
48
+ // One wall-clock budget for the whole operation call; combine with your own
49
+ // per-request signals via AbortSignal.any if needed.
50
+ signal: AbortSignal;
51
+ // Connector-level config resolved by the platform (e.g. an OAuth client id
52
+ // from the encrypted config store). Never tenant credentials — those arrive
53
+ // as `cred`.
54
+ config?: Record<string, string>;
55
+ // Trace-correlated logger. Never log credentials or response payloads.
56
+ log?: (msg: string) => void;
57
+ }
58
+
59
+ // Slim context for the OAuth lifecycle hooks (buildAuthUrl/exchangeCode/refresh):
60
+ // connector-level config, plus the governed fetch for the token exchange —
61
+ // marketplace connector code may not touch ambient fetch ANYWHERE (the publish
62
+ // scan bans it), so the lifecycle's egress rides the same fenced client as
63
+ // operations. There is no tenant credential yet at these steps.
64
+ export interface LifecycleContext {
65
+ config?: Record<string, string>;
66
+ fetch?: typeof fetch;
67
+ }
68
+
38
69
  // A single field in an inline connect form (api_key / app_password adapters).
39
70
  // The UI renders these generically; the adapter owns the spec so the form and
40
71
  // the route's expectations never drift. `target` says where the value goes in
@@ -64,6 +95,18 @@ export interface ConnectionProvider {
64
95
  // Connect-path maturity. Omitted ⇒ 'ready'. See Maturity above.
65
96
  maturity?: Maturity;
66
97
 
98
+ // Egress allowlist: the ONLY hosts this connector's operations/test may reach
99
+ // through ctx.fetch. Bare hostname for an exact match ('api.acme.com') or a
100
+ // wildcard for subdomains ('*.acme.com' — does NOT match the apex; list it
101
+ // separately). For marketplace connectors the authoritative copy lives in the
102
+ // reviewed manifest and is enforced from the platform's registry row — this
103
+ // field documents intent and powers the local dev harness.
104
+ allowedDomains?: string[];
105
+
106
+ // Publisher handle (lowercase-kebab) for marketplace connectors. Built-ins
107
+ // omit it (first-party).
108
+ publisher?: string;
109
+
67
110
  // Inline connect-form spec for api_key / app_password shapes. Omitted by pure
68
111
  // oauth2 adapters (they render a Connect button instead). For a dual-mode
69
112
  // provider (WordPress), this describes the NON-oauth fallback form; the UI
@@ -81,13 +124,29 @@ export interface ConnectionProvider {
81
124
  // meta.mode='<fallbackMode>' so the route/adapter know which branch to take.
82
125
  dualMode?: { fallbackMode: string; oauthLabel?: string; formLabel?: string };
83
126
 
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>;
127
+ // OAuth-only lifecycle. Omitted by api_key / app_password adapters. The
128
+ // optional trailing ctx carries connector-level config (encrypted config
129
+ // store) so client ids/secrets need not live in process.env.
130
+ buildAuthUrl?(
131
+ args: { tenantId: string; state: string; callbackUrl: string },
132
+ ctx?: LifecycleContext,
133
+ ): string;
134
+ exchangeCode?(
135
+ args: { code: string; callbackUrl: string },
136
+ ctx?: LifecycleContext,
137
+ ): Promise<TokenSet>;
138
+ refresh?(args: { refreshToken: string }, ctx?: LifecycleContext): Promise<TokenSet>;
88
139
 
89
- // All kinds — validate a credential at save time.
90
- test(args: { accessToken: string; meta: Record<string, unknown> }): Promise<TestResult>;
140
+ // Best-effort revocation at the provider, called when a tenant disconnects
141
+ // (or an install is removed). Optional; failures are audited, never fatal.
142
+ revoke?(tokenSet: { accessToken: string; refreshToken?: string | null }): Promise<void>;
143
+
144
+ // All kinds — validate a credential at save time. ctx is passed by the
145
+ // platform (optional in the type so existing adapters compile unchanged).
146
+ test(
147
+ args: { accessToken: string; meta: Record<string, unknown> },
148
+ ctx?: OperationContext,
149
+ ): Promise<TestResult>;
91
150
 
92
151
  // Derive a human-readable account_label + durable handle
93
152
  // (stripe_user_id, store url, ...) to persist alongside the token.
@@ -111,6 +170,13 @@ export interface ConnectionProvider {
111
170
  // sees it. Keep `call` pure-ish: do the request, return JSON-serializable data.
112
171
  export interface ConnectionOperation {
113
172
  description?: string;
173
+ // Access class, for grant scoping + install consent. Omitted ⇒ 'write' (the
174
+ // most restrictive assumption: unlabeled ops require the write scope and are
175
+ // listed as writes on the consent screen).
176
+ access?: 'read' | 'write';
177
+ // Safe-to-retry marker. Omitted ⇒ false. The platform NEVER auto-retries a
178
+ // non-idempotent operation; callers (agents) read this to know what is safe.
179
+ idempotent?: boolean;
114
180
  // A Standard Schema (zod/valibot/…) validating the agent-supplied args. Typed
115
181
  // loosely here so the connector contract doesn't hard-depend on zod; adapters
116
182
  // pass a zod schema and the route validates with `.parse`/`.safeParse`.
@@ -120,17 +186,49 @@ export interface ConnectionOperation {
120
186
  call(
121
187
  cred: { accessToken: string; meta: Record<string, unknown> },
122
188
  args: Record<string, unknown>,
189
+ ctx?: OperationContext,
123
190
  ): Promise<unknown>;
124
191
  }
125
192
 
193
+ // Machine-readable failure classes. The capability proxy maps these to HTTP
194
+ // codes + health-machine outcomes; a provider outage must never read as a
195
+ // broken connector, and an auth failure must revoke, not retry.
196
+ // provider_unavailable → the upstream API is down/5xx/timing out
197
+ // connector_error → the connector's own fault (bug, bad normalization)
198
+ // auth_revoked → the credential is dead (401-class, invalid_grant)
199
+ // egress_blocked → the governed fetch refused a non-allowlisted host
200
+ // rate_limited → upstream 429 (platform applies backoff)
201
+ export type OperationErrorCode =
202
+ | 'provider_unavailable'
203
+ | 'connector_error'
204
+ | 'auth_revoked'
205
+ | 'egress_blocked'
206
+ | 'rate_limited';
207
+
126
208
  // Throw from a ConnectionOperation.call to surface a typed, agent-safe failure
127
209
  // (maps to a 4xx with a clean message — never leaks the credential).
128
210
  export class OperationError extends Error {
129
211
  status: number;
130
- constructor(message: string, status = 400) {
212
+ code?: OperationErrorCode;
213
+ constructor(message: string, status = 400, code?: OperationErrorCode) {
131
214
  super(message);
132
215
  this.name = 'OperationError';
133
216
  this.status = status;
217
+ this.code = code;
218
+ }
219
+ }
220
+
221
+ // Thrown by the governed fetch when an operation tries to reach a host outside
222
+ // its allowlist (or a private/link-local address). Carries the offending host
223
+ // so audit + the author's dev harness can say exactly what was refused.
224
+ export class EgressViolationError extends OperationError {
225
+ host: string;
226
+ reason: string;
227
+ constructor(host: string, reason: string) {
228
+ super(`egress blocked: ${reason} (${host})`, 502, 'egress_blocked');
229
+ this.name = 'EgressViolationError';
230
+ this.host = host;
231
+ this.reason = reason;
134
232
  }
135
233
  }
136
234
 
@@ -169,5 +267,14 @@ export function defineProvider(p: ConnectionProvider): ConnectionProvider {
169
267
  if (typeof p.test !== 'function') {
170
268
  throw new Error(`connections: provider '${p.provider}' must implement test()`);
171
269
  }
270
+ if (p.operations && Object.keys(p.operations).length > 0 && !p.allowedDomains?.length) {
271
+ // Warn, don't throw: built-ins are grandfathered on ambient fetch. The
272
+ // marketplace publish gate REQUIRES allowed_domains in the manifest, so
273
+ // third-party connectors can't ship without one.
274
+ console.warn(
275
+ `connections: provider '${p.provider}' declares operations but no allowedDomains — ` +
276
+ `its egress cannot be fenced. Declare the hosts its operations reach.`,
277
+ );
278
+ }
172
279
  return p;
173
280
  }
@@ -0,0 +1,128 @@
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
+ // Remote connectors have no adapter code in core, so their connect form is
63
+ // declared HERE (vendored connectors keep connectFields in code — the
64
+ // adapter stays the single source of truth for Lane A).
65
+ connect_fields: z
66
+ .array(
67
+ z.object({
68
+ target: z.string().min(1),
69
+ label: z.string().min(1),
70
+ secret: z.boolean().optional(),
71
+ optional: z.boolean().optional(),
72
+ help: z.string().max(500).optional(),
73
+ }),
74
+ )
75
+ .optional(),
76
+ // The reviewable egress allowlist — the ONLY hosts ctx.fetch may reach.
77
+ allowed_domains: z
78
+ .array(z.string().regex(domainPattern, 'each entry is a bare host or *.host, optional :port'))
79
+ .min(1, 'declare at least one allowed domain'),
80
+ // Connector-level config keys the platform must supply (resolved from the
81
+ // encrypted config store — values are set by staff at publish, never bundled).
82
+ env: z
83
+ .array(z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'config keys are SCREAMING_SNAKE'))
84
+ .default([]),
85
+ // Human-readable data-handling summary, surfaced on connect/install consent.
86
+ data_handling: z.string().min(1).max(2000),
87
+ // Static operation inventory — cross-checked against the compiled adapter at
88
+ // the publish gate (both directions: no undeclared ops, no phantom ops). For
89
+ // remote connectors this list IS the contract core builds the forwarder from.
90
+ operations: z.array(connectorOperationDeclaration).min(1),
91
+ changelog: z.string().max(5000).optional(),
92
+ })
93
+ .superRefine((m, ctx) => {
94
+ if (!m.entrypoint && !m.runtime_url) {
95
+ ctx.addIssue({
96
+ code: 'custom',
97
+ message: 'declare an entrypoint (vendored, Lane A) or a runtime_url (remote, Lane B)',
98
+ });
99
+ }
100
+ if (m.runtime_url && !m.runtime_url.startsWith('https://')) {
101
+ ctx.addIssue({ code: 'custom', path: ['runtime_url'], message: 'runtime_url must be https' });
102
+ }
103
+ if (m.runtime_url && m.auth_kind === 'oauth2') {
104
+ ctx.addIssue({
105
+ code: 'custom',
106
+ path: ['auth_kind'],
107
+ message:
108
+ 'remote (Lane B) connectors are api_key/app_password only — core will not broker an author-owned OAuth dance',
109
+ });
110
+ }
111
+ if (m.runtime_url && (!m.connect_fields || m.connect_fields.length === 0)) {
112
+ ctx.addIssue({
113
+ code: 'custom',
114
+ path: ['connect_fields'],
115
+ message:
116
+ 'remote connectors must declare connect_fields (there is no adapter code to own the form)',
117
+ });
118
+ }
119
+ });
120
+
121
+ export type ConnectorOperationDeclaration = z.infer<typeof connectorOperationDeclaration>;
122
+ export type ConnectorManifest = z.infer<typeof connectorManifestSchema>;
123
+
124
+ // Parse + normalize an entry.json object. Throws ZodError on invalid input;
125
+ // callers wanting a report use connectorManifestSchema.safeParse directly.
126
+ export function parseConnectorManifest(raw: unknown): ConnectorManifest {
127
+ return connectorManifestSchema.parse(raw);
128
+ }