@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 +6 -3
- package/src/conformance/index.ts +180 -0
- package/src/conformance/runner.ts +87 -0
- package/src/index.ts +147 -7
- package/src/manifest.ts +171 -0
package/package.json
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keemakr/operator-sdk",
|
|
3
|
-
"version": "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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
//
|
|
90
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
}
|