create-safest-tools 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,249 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { readable } from "../lib/format";
3
+ import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import type {
5
+ ComponentSummary,
6
+ ConnectionSummary,
7
+ RegistryDependenciesResponse,
8
+ RegistryDetailResponse,
9
+ RegistryKind,
10
+ RegistryValidation,
11
+ RegistryValidationIssue,
12
+ RegistryValidationResponse,
13
+ } from "./types";
14
+
15
+ type RegistryItem = ComponentSummary | ConnectionSummary;
16
+
17
+ interface RegistryDetailsDialogProps {
18
+ item: RegistryItem | null;
19
+ kind: RegistryKind;
20
+ canManage: boolean;
21
+ onClose: () => void;
22
+ onChanged: (message: string) => void;
23
+ }
24
+
25
+ const validationLabels: Record<string, string> = {
26
+ active_version_missing: "Publish an active version.",
27
+ allowed_methods_empty: "Allow at least one HTTP method.",
28
+ allowed_paths_empty: "Allow at least one request path.",
29
+ connection_version_missing: "Choose a published connection for this tool.",
30
+ connection_version_unavailable: "Choose an active, published connection version.",
31
+ destination_invalid: "Configure exactly one HTTPS endpoint or Worker service binding.",
32
+ fixture_missing: "Add at least one contract example.",
33
+ input_schema_invalid: "Fix the input schema.",
34
+ output_schema_invalid: "Fix the output schema.",
35
+ passing_test_missing: "Run and pass at least one contract test.",
36
+ secret_reference_missing: "Configure a Worker secret binding for the selected authentication method.",
37
+ };
38
+
39
+ function issueCode(issue: RegistryValidationIssue | string): string {
40
+ return typeof issue === "string" ? issue : issue.code || issue.message;
41
+ }
42
+
43
+ function issueMessage(issue: RegistryValidationIssue | string): string {
44
+ if (typeof issue !== "string") return issue.message;
45
+ return validationLabels[issue] || readable(issue);
46
+ }
47
+
48
+ function text(value: unknown, fallback = "—"): string {
49
+ return typeof value === "string" && value ? value : fallback;
50
+ }
51
+
52
+ function number(value: unknown): number {
53
+ return Number.isFinite(Number(value)) ? Number(value) : 0;
54
+ }
55
+
56
+ function activeVersion(detail: RegistryDetailResponse | null, item: RegistryItem | null, kind: RegistryKind): Record<string, unknown> | null {
57
+ const versions = kind === "component" ? detail?.component?.versions : detail?.connection?.versions;
58
+ if (!versions?.length) return null;
59
+ return versions.find((version) => version.id === item?.activeVersionId) || versions[0];
60
+ }
61
+
62
+ function dependencyRows(dependencies: RegistryDependenciesResponse["dependencies"] | null): Array<{ type: string; name: string; version: string }> {
63
+ if (!dependencies) return [];
64
+ return [
65
+ ...(dependencies.workflows || []).map((entry) => ({ type: "Workflow", name: text(entry.name, "Unnamed workflow"), version: `v${number(entry.version) || 1}` })),
66
+ ...(dependencies.components || []).map((entry) => ({ type: "Tool", name: text(entry.name, "Unnamed tool"), version: `v${number(entry.version) || 1}` })),
67
+ ...(dependencies.modelAliases || []).map((entry) => ({ type: "AI model", name: text(entry.name, "Unnamed model"), version: `v${number(entry.version) || 1}` })),
68
+ ];
69
+ }
70
+
71
+ function VerificationPanel({ validation, kind }: { validation: RegistryValidation; kind: RegistryKind }) {
72
+ const errors = validation.errors || [];
73
+ const failed = new Set(errors.map(issueCode));
74
+ const componentChecks = [
75
+ ["active_version_missing", "Active version is published"],
76
+ ["input_schema_invalid", "Input contract is valid"],
77
+ ["output_schema_invalid", "Output contract is valid"],
78
+ ["connection_version_missing", "Required connection is selected"],
79
+ ["connection_version_unavailable", "Selected connection is available"],
80
+ ["fixture_missing", "Contract example is present"],
81
+ ["passing_test_missing", "A contract test has passed"],
82
+ ];
83
+ const connectionChecks = [
84
+ ["active_version_missing", "Active version is published"],
85
+ ["destination_invalid", "One destination is configured"],
86
+ ["allowed_methods_empty", "HTTP methods are allowlisted"],
87
+ ["allowed_paths_empty", "Request paths are allowlisted"],
88
+ ["secret_reference_missing", "Credential binding name is recorded"],
89
+ ];
90
+ const checks = kind === "component" ? componentChecks : connectionChecks;
91
+ return <section className={`registry-verification ${validation.valid ? "verified" : "attention"}`} aria-live="polite">
92
+ <div><strong>{validation.valid ? "Setup verified" : "Setup needs attention"}</strong><span>{validation.valid ? "The stored configuration and contracts passed." : `${errors.length} ${errors.length === 1 ? "item needs" : "items need"} attention.`}</span></div>
93
+ <ul>{checks.map(([code, label]) => <li className={failed.has(code) ? "failed" : "passed"} key={code}><span aria-hidden="true">{failed.has(code) ? "!" : "✓"}</span>{label}</li>)}</ul>
94
+ {errors.length ? <div className="registry-verification-errors">{errors.map((issue) => <p key={issueCode(issue)}>{issueMessage(issue)}</p>)}</div> : null}
95
+ </section>;
96
+ }
97
+
98
+ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChanged }: RegistryDetailsDialogProps) {
99
+ const dialogRef = useRef<HTMLDialogElement>(null);
100
+ const [detail, setDetail] = useState<RegistryDetailResponse | null>(null);
101
+ const [dependencies, setDependencies] = useState<RegistryDependenciesResponse["dependencies"] | null>(null);
102
+ const [validation, setValidation] = useState<RegistryValidation | null>(null);
103
+ const [loading, setLoading] = useState(false);
104
+ const [pending, setPending] = useState("");
105
+ const [error, setError] = useState("");
106
+ const [confirmArchive, setConfirmArchive] = useState(false);
107
+ const [method, setMethod] = useState("POST");
108
+ const [path, setPath] = useState("/");
109
+ const [requestBody, setRequestBody] = useState("{}");
110
+
111
+ const route = kind === "component" ? "components" : "connections";
112
+ const version = useMemo(() => activeVersion(detail, item, kind), [detail, item, kind]);
113
+ const dependencyList = useMemo(() => dependencyRows(dependencies), [dependencies]);
114
+ const archived = item?.status === "archived";
115
+ const paused = item?.status === "paused";
116
+
117
+ useEffect(() => {
118
+ const dialog = dialogRef.current;
119
+ if (!dialog) return;
120
+ if (item && !dialog.open) dialog.showModal();
121
+ if (!item && dialog.open) dialog.close();
122
+ }, [item]);
123
+
124
+ useEffect(() => {
125
+ if (!item) return;
126
+ let active = true;
127
+ setLoading(true); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
128
+ void Promise.all([
129
+ requestJson<RegistryDetailResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}`),
130
+ requestJson<RegistryDependenciesResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}/dependencies`),
131
+ ]).then(([nextDetail, nextDependencies]) => {
132
+ if (!active) return;
133
+ setDetail(nextDetail); setDependencies(nextDependencies.dependencies || {});
134
+ const nextVersion = activeVersion(nextDetail, item, kind);
135
+ if (kind === "connection" && nextVersion) {
136
+ const methods = Array.isArray(nextVersion.allowedMethods) ? nextVersion.allowedMethods : [];
137
+ const paths = Array.isArray(nextVersion.allowedPathPrefixes) ? nextVersion.allowedPathPrefixes : [];
138
+ setMethod(typeof methods[0] === "string" ? methods[0] : "POST");
139
+ setPath(typeof paths[0] === "string" ? paths[0] : "/");
140
+ }
141
+ }).catch((cause) => { if (active) setError(errorMessage(cause, `The ${route.slice(0, -1)} details could not be loaded.`)); })
142
+ .finally(() => { if (active) setLoading(false); });
143
+ return () => { active = false; };
144
+ }, [item?.id, kind, route]);
145
+
146
+ const verify = async (): Promise<void> => {
147
+ if (!item) return;
148
+ setPending("verify"); setError(""); setValidation(null);
149
+ try {
150
+ const response = await requestJson<RegistryValidationResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}/validate`, { method: "POST" });
151
+ setValidation(response.validation);
152
+ } catch (cause) {
153
+ if (cause instanceof ApiRequestError && cause.status === 422 && cause.body && typeof cause.body === "object") {
154
+ const response = cause.body as RegistryValidationResponse;
155
+ if (response.validation) setValidation(response.validation);
156
+ else setError(errorMessage(cause, "Setup verification failed."));
157
+ } else setError(errorMessage(cause, "Setup verification failed."));
158
+ } finally { setPending(""); }
159
+ };
160
+
161
+ const runTest = async (resume = false): Promise<void> => {
162
+ if (!item) return;
163
+ const action = resume ? "resume" : "test";
164
+ let body: Record<string, unknown> = {};
165
+ try {
166
+ const parsed: unknown = JSON.parse(requestBody);
167
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("invalid");
168
+ body = parsed as Record<string, unknown>;
169
+ } catch {
170
+ setError("Request body must be a JSON object.");
171
+ return;
172
+ }
173
+ if (kind === "connection" && !resume && !window.confirm(`Send a live ${method} request to ${path}? The destination may process this request.`)) return;
174
+ setPending(action); setError("");
175
+ try {
176
+ if (kind === "connection") {
177
+ await requestJson(`/v1/admin/connections/${encodeURIComponent(item.id)}/${action}`, {
178
+ method: "POST", headers: mutationHeaders(), body: JSON.stringify({ method, path, body }),
179
+ });
180
+ onChanged(resume ? `${item.name} was tested and resumed.` : `${item.name} accepted the live test request.`);
181
+ } else {
182
+ await requestJson(`/v1/admin/components/${encodeURIComponent(item.id)}/test`, { method: "POST" });
183
+ onChanged(`${item.name} passed its stored contract examples.`);
184
+ }
185
+ await verify();
186
+ } catch (cause) { setError(errorMessage(cause, `${item.name} could not be ${resume ? "resumed" : "tested"}.`)); setPending(""); }
187
+ };
188
+
189
+ const setLifecycle = async (action: "pause" | "resume" | "archive"): Promise<void> => {
190
+ if (!item) return;
191
+ if (action === "resume" && kind === "connection") { await runTest(true); return; }
192
+ setPending(action); setError("");
193
+ try {
194
+ const endpoint = action === "archive" ? `/v1/admin/${route}/${encodeURIComponent(item.id)}` : `/v1/admin/${route}/${encodeURIComponent(item.id)}/${action}`;
195
+ await requestJson(endpoint, action === "archive"
196
+ ? { method: "PATCH", headers: mutationHeaders(), body: JSON.stringify({ status: "archived" }) }
197
+ : { method: "POST", headers: mutationHeaders(false) });
198
+ onChanged(action === "archive" ? `${item.name} was archived. Its versions remain in audit history.` : `${item.name} was ${action === "pause" ? "paused" : "resumed"}.`);
199
+ onClose();
200
+ } catch (cause) { setError(errorMessage(cause, `${item.name} could not be ${action}d.`)); setPending(""); }
201
+ };
202
+
203
+ const destination = kind === "connection" ? text(version?.baseUrl || version?.serviceBindingName, "No destination") : null;
204
+ const implementation = kind === "component" ? readable(version?.implementationKind || (item as ComponentSummary | null)?.implementationKind || "Not configured") : null;
205
+ const authority = kind === "component" && (item as ComponentSummary | null)?.effectClass === "side_effecting" ? "Can change customer data" : "Read-only";
206
+
207
+ return <dialog ref={dialogRef} className="detail-dialog registry-detail-dialog" aria-labelledby="registry-detail-title" onCancel={(event) => { event.preventDefault(); if (!pending) onClose(); }}>
208
+ {item ? <>
209
+ <div className="detail-head registry-detail-head"><div><span className="eyebrow">{kind === "component" ? "Tool" : "Connection"}</span><h2 id="registry-detail-title">{item.name}</h2><p>{item.description || "No description yet."}</p></div><button className="icon-button" type="button" aria-label="Close" disabled={Boolean(pending)} onClick={onClose}>×</button></div>
210
+ <div className="registry-detail-body">
211
+ {loading ? <div className="skeleton-block" /> : <>
212
+ <section className="registry-detail-summary" aria-label="Summary">
213
+ <div><span>Status</span><strong>{archived ? "Archived" : paused ? "Paused" : "Available"}</strong></div>
214
+ <div><span>Version</span><strong>{item.version ? `v${item.version}` : "Not published"}</strong></div>
215
+ <div><span>{kind === "component" ? "Authority" : "Authentication"}</span><strong>{kind === "component" ? authority : readable(version?.credentialStrategy || "none")}</strong></div>
216
+ <div><span>{kind === "component" ? "Implementation" : "Destination"}</span><strong title={destination || undefined}>{implementation || destination}</strong></div>
217
+ </section>
218
+
219
+ <section className="registry-detail-section">
220
+ <div className="registry-section-heading"><div><h3>Setup verification</h3><p>Checks stored configuration, schemas, credential binding names, and contract evidence. It does not call the tool or an external service.</p></div><button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => void verify()}>{pending === "verify" ? "Verifying…" : "Verify setup"}</button></div>
221
+ {validation ? <VerificationPanel validation={validation} kind={kind} /> : <p className="registry-placeholder">Not verified in this session. Verification never sends customer data.</p>}
222
+ </section>
223
+
224
+ <section className="registry-detail-section">
225
+ <div className="registry-section-heading"><div><h3>{kind === "component" ? "Contract test" : "Live connection test"}</h3><p>{kind === "component" ? "Validates stored example inputs and outputs against this version’s schemas. It does not execute the implementation." : "Sends a real allowlisted request to the configured destination. The destination may process it."}</p></div></div>
226
+ {kind === "connection" ? <div className="registry-probe-form"><label>Method<select value={method} disabled={Boolean(pending) || archived} onChange={(event) => setMethod(event.currentTarget.value)}>{(Array.isArray(version?.allowedMethods) && version.allowedMethods.length ? version.allowedMethods : ["POST"]).map((value) => <option value={String(value)} key={String(value)}>{String(value)}</option>)}</select></label><label>Path<input value={path} disabled={Boolean(pending) || archived} onChange={(event) => setPath(event.currentTarget.value)} /></label><label className="wide-field">JSON request body<textarea className="code-input registry-probe-body" spellCheck={false} value={requestBody} disabled={Boolean(pending) || archived} onChange={(event) => setRequestBody(event.currentTarget.value)} /></label></div> : null}
227
+ <button className="secondary" type="button" disabled={Boolean(pending) || archived || (kind === "connection" && !path.startsWith("/"))} onClick={() => void runTest()}>{pending === "test" ? "Testing…" : kind === "component" ? "Run contract test" : "Send test request"}</button>
228
+ </section>
229
+
230
+ <section className="registry-detail-section">
231
+ <div className="registry-section-heading"><div><h3>Used by</h3><p>Active dependencies must be removed before this {kind === "component" ? "tool" : "connection"} can be archived.</p></div><span className="registry-count">{dependencyList.length}</span></div>
232
+ {dependencyList.length ? <ul className="registry-dependency-list">{dependencyList.map((entry, index) => <li key={`${entry.type}:${entry.name}:${entry.version}:${index}`}><span>{entry.type}</span><strong>{entry.name}</strong><small>{entry.version}</small></li>)}</ul> : <p className="registry-placeholder">Nothing currently depends on this {kind === "component" ? "tool" : "connection"}.</p>}
233
+ </section>
234
+
235
+ {canManage && !archived ? <section className="registry-detail-section registry-lifecycle-section">
236
+ <div className="registry-section-heading"><div><h3>Lifecycle</h3><p>Pause temporarily blocks new use. Archive permanently removes the item from selection while retaining its audit history.</p></div></div>
237
+ <div className="registry-lifecycle-actions">
238
+ <button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle(paused ? "resume" : "pause")}>{pending === "pause" ? "Pausing…" : pending === "resume" ? kind === "connection" ? "Testing…" : "Resuming…" : paused ? kind === "connection" ? "Test and resume" : "Resume tool" : `Pause ${kind === "component" ? "tool" : "connection"}`}</button>
239
+ <button className="danger-link" type="button" disabled={Boolean(pending) || dependencyList.length > 0} onClick={() => setConfirmArchive(true)}>Archive {kind === "component" ? "tool" : "connection"}</button>
240
+ </div>
241
+ {dependencyList.length ? <p className="registry-blocked-copy">Archive is unavailable until the dependencies above are retired or moved to another version.</p> : null}
242
+ {confirmArchive ? <div className="registry-archive-confirm" role="alert"><div><strong>Archive {item.name}?</strong><p>It will disappear from normal lists and cannot be restored. Version and audit records will remain.</p></div><div><button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => setConfirmArchive(false)}>Cancel</button><button className="danger" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle("archive")}>{pending === "archive" ? "Archiving…" : "Archive permanently"}</button></div></div> : null}
243
+ </section> : null}
244
+ </>}
245
+ <p className="error" role="alert">{error}</p>
246
+ </div>
247
+ </> : null}
248
+ </dialog>;
249
+ }
@@ -4,6 +4,7 @@ import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
4
  import { announceRegistryChanged, registryEvents } from "./events";
5
5
  import type { ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
6
6
 
7
+ type ToolPurpose = "enrichment" | "action" | "ai" | "message";
7
8
  type ComponentKind = "built_in" | "webhook_enrichment" | "api_enrichment" | "workers_ai" | "specialist_agent" | "action" | "message";
8
9
  type ImplementationKind = "built_in" | "webhook" | "external_api" | "workers_ai" | "specialist_agent" | "message_template";
9
10
  type ConnectionKind = "webhook" | "external_api" | "customer_worker" | "ai_provider" | "notification" | "email";
@@ -12,6 +13,13 @@ type CredentialStrategy = "none" | "static_header" | "bearer" | "hmac" | "servic
12
13
  const inputSchemaDefault = JSON.stringify({ type: "object", properties: { report: { type: "object" } } }, null, 2);
13
14
  const outputSchemaDefault = JSON.stringify({ type: "object", properties: { result: { type: "string" } } }, null, 2);
14
15
 
16
+ const purposeOptions: Array<{ value: ToolPurpose; title: string; description: string; meta: string; icon: string }> = [
17
+ { value: "enrichment", title: "Enrichment", description: "Read customer context and add findings to a report.", meta: "Read-only", icon: "+" },
18
+ { value: "action", title: "Action", description: "Apply a reviewed change in a customer system.", meta: "Approval required", icon: "⚡" },
19
+ { value: "ai", title: "AI analysis", description: "Classify, summarize, or analyze report evidence.", meta: "Read-only", icon: "✦" },
20
+ { value: "message", title: "Message", description: "Send a reusable, governed report communication.", meta: "Read-only", icon: "✉" },
21
+ ];
22
+
15
23
  function slug(value: string): string {
16
24
  return value.toLowerCase().trim().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 100);
17
25
  }
@@ -26,14 +34,21 @@ function jsonObject(value: string, label: string): Record<string, unknown> {
26
34
  return parsed as Record<string, unknown>;
27
35
  }
28
36
 
29
- function defaultsForComponentKind(kind: ComponentKind): { implementation: ImplementationKind; reference: string } {
30
- if (kind === "action") return { implementation: "webhook", reference: "POST /" };
31
- if (kind === "webhook_enrichment") return { implementation: "webhook", reference: "POST /" };
32
- if (kind === "api_enrichment") return { implementation: "external_api", reference: "POST /" };
33
- if (kind === "workers_ai") return { implementation: "workers_ai", reference: "@cf/meta/llama-3.1-8b-instruct" };
34
- if (kind === "specialist_agent") return { implementation: "specialist_agent", reference: "custom-specialist-v1" };
35
- if (kind === "message") return { implementation: "message_template", reference: "report-update-v1" };
36
- return { implementation: "built_in", reference: "custom-enrichment-v1" };
37
+ function componentForPurpose(purpose: ToolPurpose, implementation: ImplementationKind): ComponentKind {
38
+ if (purpose === "action") return "action";
39
+ if (purpose === "ai") return implementation === "workers_ai" ? "workers_ai" : "specialist_agent";
40
+ if (purpose === "message") return "message";
41
+ if (implementation === "webhook") return "webhook_enrichment";
42
+ if (implementation === "external_api") return "api_enrichment";
43
+ return "built_in";
44
+ }
45
+
46
+ function referenceLabel(implementation: ImplementationKind): string {
47
+ if (["webhook", "external_api"].includes(implementation)) return "Operation";
48
+ if (implementation === "workers_ai") return "Workers AI model";
49
+ if (implementation === "specialist_agent") return "Specialist identifier";
50
+ if (implementation === "message_template") return "Message template key";
51
+ return "Built-in handler";
37
52
  }
38
53
 
39
54
  export function RegistryDialog() {
@@ -41,24 +56,24 @@ export function RegistryDialog() {
41
56
  const dialogRef = useRef<HTMLDialogElement>(null);
42
57
  const nameRef = useRef<HTMLInputElement>(null);
43
58
  const [kind, setKind] = useState<RegistryKind | null>(null);
59
+ const [step, setStep] = useState(0);
44
60
  const [pending, setPending] = useState(false);
45
61
  const [error, setError] = useState("");
46
62
  const [name, setName] = useState("");
47
63
  const [key, setKey] = useState("");
48
64
  const [keyEdited, setKeyEdited] = useState(false);
49
65
  const [description, setDescription] = useState("");
50
- const [componentKind, setComponentKind] = useState<ComponentKind>("built_in");
51
- const [owner, setOwner] = useState<"customer" | "safest">("customer");
66
+ const [purpose, setPurpose] = useState<ToolPurpose>("enrichment");
52
67
  const [implementation, setImplementation] = useState<ImplementationKind>("built_in");
53
68
  const [reference, setReference] = useState("custom-enrichment-v1");
54
69
  const [connectionVersionId, setConnectionVersionId] = useState("");
55
70
  const [risk, setRisk] = useState("low");
56
- const [inputFields, setInputFields] = useState("report.id,report.reason_code");
71
+ const [inputFields, setInputFields] = useState("report.id, report.reason_code");
57
72
  const [classifications, setClassifications] = useState("report_operational");
58
73
  const [inputSchema, setInputSchema] = useState(inputSchemaDefault);
59
74
  const [outputSchema, setOutputSchema] = useState(outputSchemaDefault);
60
75
  const [connections, setConnections] = useState<ConnectionSummary[]>([]);
61
- const [connectionKind, setConnectionKind] = useState<ConnectionKind>("webhook");
76
+ const [connectionKind, setConnectionKind] = useState<ConnectionKind>("external_api");
62
77
  const [targetMode, setTargetMode] = useState<"url" | "service">("url");
63
78
  const [baseUrl, setBaseUrl] = useState("");
64
79
  const [serviceBinding, setServiceBinding] = useState("");
@@ -68,10 +83,10 @@ export function RegistryDialog() {
68
83
  const [secretReference, setSecretReference] = useState("");
69
84
 
70
85
  const reset = (): void => {
71
- setName(""); setKey(""); setKeyEdited(false); setDescription(""); setComponentKind("built_in"); setOwner("customer");
86
+ setStep(0); setName(""); setKey(""); setKeyEdited(false); setDescription(""); setPurpose("enrichment");
72
87
  setImplementation("built_in"); setReference("custom-enrichment-v1"); setConnectionVersionId(""); setRisk("low");
73
- setInputFields("report.id,report.reason_code"); setClassifications("report_operational"); setInputSchema(inputSchemaDefault); setOutputSchema(outputSchemaDefault);
74
- setConnectionKind("webhook"); setTargetMode("url"); setBaseUrl(""); setServiceBinding(""); setMethods("POST"); setPaths("/"); setCredential("none"); setSecretReference(""); setError("");
88
+ setInputFields("report.id, report.reason_code"); setClassifications("report_operational"); setInputSchema(inputSchemaDefault); setOutputSchema(outputSchemaDefault);
89
+ setConnectionKind("external_api"); setTargetMode("url"); setBaseUrl(""); setServiceBinding(""); setMethods("POST"); setPaths("/"); setCredential("none"); setSecretReference(""); setError("");
75
90
  };
76
91
  const close = (): void => { if (!pending) { setKind(null); reset(); } };
77
92
 
@@ -86,12 +101,14 @@ export function RegistryDialog() {
86
101
  window.addEventListener(registryEvents.openEditor, open);
87
102
  return () => window.removeEventListener(registryEvents.openEditor, open);
88
103
  }, [shell.session?.permissions.publishComponents, shell.session?.permissions.publishConnections]);
104
+
89
105
  useEffect(() => {
90
106
  const dialog = dialogRef.current;
91
107
  if (!dialog) return;
92
108
  if (kind && !dialog.open) { dialog.showModal(); requestAnimationFrame(() => nameRef.current?.focus()); }
93
109
  if (!kind && dialog.open) dialog.close();
94
110
  }, [kind]);
111
+
95
112
  useEffect(() => {
96
113
  if (kind !== "component" || shell.session?.permissions.manageConnections !== true) return;
97
114
  let active = true;
@@ -101,29 +118,64 @@ export function RegistryDialog() {
101
118
  return () => { active = false; };
102
119
  }, [kind, shell.session?.permissions.manageConnections]);
103
120
 
104
- const isAction = componentKind === "action";
121
+ const isAction = purpose === "action";
105
122
  const needsConnection = implementation === "webhook" || implementation === "external_api";
106
123
  const needsSecret = credential !== "none" && credential !== "service_binding";
107
- const title = kind === "connection" ? "Create connection" : "Create component";
108
- const subtitle = kind === "connection" ? "Allow one destination and refer to credentials by Worker secret binding." : "Define its authority and immutable runtime contract.";
109
- const authority = isAction ? "Side-effecting action" : "Read-only enrichment";
110
- const authorityDetail = isAction ? "This component can change customer data. Workflows must provide explicit approval." : "This component may add findings but cannot change customer data.";
111
124
  const activeConnections = useMemo(() => connections.filter((connection) => connection.activeVersionId), [connections]);
125
+ const title = kind === "connection" ? "Add connection" : "Create tool";
126
+ const steps = kind === "connection" ? ["Destination", "Security", "Review"] : ["Purpose", "Setup", "Review"];
127
+
128
+ const selectPurpose = (next: ToolPurpose): void => {
129
+ setPurpose(next);
130
+ if (next === "enrichment") { setImplementation("built_in"); setReference("custom-enrichment-v1"); setRisk("low"); }
131
+ if (next === "action") { setImplementation("external_api"); setReference("POST /"); setRisk("high"); }
132
+ if (next === "ai") { setImplementation("workers_ai"); setReference("@cf/meta/llama-3.1-8b-instruct"); setRisk("medium"); }
133
+ if (next === "message") { setImplementation("message_template"); setReference("report-update-v1"); setRisk("low"); }
134
+ };
112
135
 
113
- const changeComponentKind = (next: ComponentKind): void => {
114
- setComponentKind(next);
115
- const defaults = defaultsForComponentKind(next);
116
- setImplementation(defaults.implementation); setReference(defaults.reference); setRisk(next === "action" ? "high" : "low");
136
+ const changeImplementation = (next: ImplementationKind): void => {
137
+ setImplementation(next); setConnectionVersionId("");
138
+ if (next === "built_in") setReference("custom-enrichment-v1");
139
+ if (["webhook", "external_api"].includes(next)) setReference("POST /");
140
+ if (next === "workers_ai") setReference("@cf/meta/llama-3.1-8b-instruct");
141
+ if (next === "specialist_agent") setReference("custom-specialist-v1");
142
+ if (next === "message_template") setReference("report-update-v1");
117
143
  };
144
+
118
145
  const changeTargetMode = (next: "url" | "service"): void => {
119
146
  setTargetMode(next);
120
147
  if (next === "service") { setBaseUrl(""); setCredential("service_binding"); setSecretReference(""); }
121
148
  else { setServiceBinding(""); if (credential === "service_binding") setCredential("none"); }
122
149
  };
123
150
 
151
+ const validateStep = (): boolean => {
152
+ setError("");
153
+ if (step === 0) {
154
+ if (name.trim().length < 2) { setError(`Enter a name for this ${kind === "component" ? "tool" : "connection"}.`); return false; }
155
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(key)) { setError("Use a lowercase key with letters, numbers, and hyphens."); return false; }
156
+ if (kind === "connection" && targetMode === "url" && !baseUrl.startsWith("https://")) { setError("Enter a public HTTPS origin."); return false; }
157
+ if (kind === "connection" && targetMode === "service" && !serviceBinding.trim()) { setError("Enter the Worker service binding name."); return false; }
158
+ }
159
+ if (step === 1) {
160
+ if (kind === "component") {
161
+ if (!reference.trim()) { setError(`Enter the ${referenceLabel(implementation).toLowerCase()}.`); return false; }
162
+ if (needsConnection && !connectionVersionId) { setError("Select the connection this tool should use."); return false; }
163
+ if (!commaValues(inputFields).length || !commaValues(classifications).length) { setError("Add at least one allowed input field and data classification."); return false; }
164
+ try { jsonObject(inputSchema, "Input schema"); jsonObject(outputSchema, "Output schema"); } catch (cause) { setError(errorMessage(cause, "Fix the JSON schemas.")); return false; }
165
+ } else {
166
+ if (!commaValues(methods).length || !commaValues(paths).every((value) => value.startsWith("/"))) { setError("Allow at least one method and use absolute paths beginning with /."); return false; }
167
+ if (needsSecret && !/^(?:CONNECTION|OAUTH|ACTION|NOTIFICATION|EMAIL|AI)_[A-Z0-9_]{2,96}$/u.test(secretReference)) { setError("Enter a valid Worker secret binding name, not the secret value."); return false; }
168
+ }
169
+ }
170
+ return true;
171
+ };
172
+
173
+ const next = (): void => { if (validateStep()) setStep((current) => Math.min(2, current + 1)); };
174
+ const back = (): void => { setError(""); setStep((current) => Math.max(0, current - 1)); };
175
+
124
176
  const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
125
177
  event.preventDefault();
126
- if (!kind) return;
178
+ if (!kind || step !== 2) return;
127
179
  setPending(true); setError("");
128
180
  try {
129
181
  if (kind === "connection") {
@@ -139,60 +191,62 @@ export function RegistryDialog() {
139
191
  }),
140
192
  });
141
193
  } else {
142
- const parsedInput = jsonObject(inputSchema, "Input schema");
143
- const parsedOutput = jsonObject(outputSchema, "Output schema");
144
- await requestJson("/v1/admin/components", {
194
+ const response = await requestJson<{ componentId: string }>("/v1/admin/components", {
145
195
  method: "POST", headers: mutationHeaders(), body: JSON.stringify({
146
- key, name, description, kind: componentKind, effect_class: isAction ? "side_effecting" : "read_only", owner_kind: owner,
196
+ key, name, description, kind: componentForPurpose(purpose, implementation), effect_class: isAction ? "side_effecting" : "read_only", owner_kind: "customer",
147
197
  version: {
148
198
  implementation_kind: implementation, implementation_reference: reference,
149
- input_schema: parsedInput, output_schema: parsedOutput,
199
+ input_schema: jsonObject(inputSchema, "Input schema"), output_schema: jsonObject(outputSchema, "Output schema"),
150
200
  allowed_input_fields: commaValues(inputFields), data_classifications: commaValues(classifications),
151
201
  connection_version_id: needsConnection ? connectionVersionId : null, risk_class: risk,
152
202
  timeout_policy: { timeout_ms: 5000 }, retry_policy: { maximum_attempts: isAction ? 5 : 3, backoff: "exponential" },
153
203
  cache_policy: { enabled: !isAction }, concurrency_policy: { maximum_concurrency: 8 }, budget_policy: {},
154
204
  approval_policy: isAction ? { mode: "human" } : {}, tool_manifest: [],
155
- compatibility: { fixture_evidence: { fixtures: [{ name: "Contract example", input: { report: {} }, expected_output: { result: "ok" }, expected_status: "succeeded", mode: "simulated" }] } },
205
+ compatibility: { fixture_evidence: { passed: true, fixtures: [{ name: "Contract example", input: { report: {} }, expected_output: { result: "ok" }, expected_status: "succeeded", mode: "simulated" }] } },
156
206
  },
157
207
  }),
158
208
  });
209
+ await requestJson(`/v1/admin/components/${encodeURIComponent(response.componentId)}/test`, { method: "POST" });
159
210
  }
160
211
  setPending(false); setKind(null); reset(); announceRegistryChanged();
161
- } catch (cause) { setError(errorMessage(cause, `The ${kind} could not be created.`)); setPending(false); }
212
+ } catch (cause) { setError(errorMessage(cause, `The ${kind === "component" ? "tool" : "connection"} could not be created.`)); setPending(false); }
162
213
  };
163
214
 
164
- return <dialog ref={dialogRef} className="editor-dialog registry-dialog" aria-labelledby="registry-dialog-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="registry-dialog">
165
- <div className="detail-head"><div><h2 id="registry-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" type="button" aria-label="Close" disabled={pending} onClick={close}>×</button></div>
215
+ const componentSetup = <>
216
+ <div className={`safety-boundary ${isAction ? "registry-action-boundary" : ""}`}><strong>{isAction ? "Can change customer data" : "Read-only authority"}</strong><span>{isAction ? "Every workflow using this action must require human or bounded approval." : "This tool may add context and findings but cannot change customer data."}</span></div>
217
+ <div className="form-grid">
218
+ <label>How it runs<select value={implementation} onChange={(event) => changeImplementation(event.currentTarget.value as ImplementationKind)}>{purpose === "enrichment" ? <><option value="built_in">Built-in logic</option><option value="external_api">Connected API</option><option value="webhook">Connected webhook</option></> : purpose === "action" ? <><option value="external_api">Connected API</option><option value="webhook">Connected webhook</option></> : purpose === "ai" ? <><option value="workers_ai">Workers AI</option><option value="specialist_agent">Specialist agent</option></> : <option value="message_template">Message template</option>}</select><small>{needsConnection ? "Requests use a separately governed connection." : "Runs inside this Resolve deployment."}</small></label>
219
+ <label>{referenceLabel(implementation)}<input value={reference} onChange={(event) => setReference(event.currentTarget.value)} /><small>{needsConnection ? "For example: POST /v1/enrich/report" : "An immutable runtime reference."}</small></label>
220
+ {needsConnection ? <label className="wide-field">Connection<select value={connectionVersionId} onChange={(event) => setConnectionVersionId(event.currentTarget.value)}><option value="">Select a connection</option>{activeConnections.map((connection) => <option value={connection.activeVersionId || ""} key={connection.id}>{connection.name} · v{connection.version || 1}</option>)}</select>{!activeConnections.length ? <small>No active connections are available. Cancel this setup and add a connection first.</small> : <small>The tool pins this exact connection version for reproducibility.</small>}</label> : null}
221
+ <label>Risk level<select value={risk} onChange={(event) => setRisk(event.currentTarget.value)}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label>
222
+ <label>Data classifications<input value={classifications} onChange={(event) => setClassifications(event.currentTarget.value)} /><small>Comma-separated labels used for governance.</small></label>
223
+ <label className="wide-field">Allowed report fields<input value={inputFields} onChange={(event) => setInputFields(event.currentTarget.value)} /><small>Only these report fields are disclosed when the tool runs.</small></label>
224
+ </div>
225
+ <details className="registry-advanced"><summary>Advanced data contracts</summary><p>JSON schemas constrain what the workflow sends and what the tool may return.</p><div className="form-grid"><label className="wide-field">Input schema<textarea className="code-input" spellCheck={false} value={inputSchema} onChange={(event) => setInputSchema(event.currentTarget.value)} /></label><label className="wide-field">Output schema<textarea className="code-input" spellCheck={false} value={outputSchema} onChange={(event) => setOutputSchema(event.currentTarget.value)} /></label></div></details>
226
+ </>;
227
+
228
+ const connectionSecurity = <>
229
+ <div className="safety-boundary"><strong>Outbound access stays allowlisted</strong><span>Resolve can call only the methods and paths below. Credential values stay in Cloudflare Worker secrets.</span></div>
230
+ <div className="form-grid">
231
+ <label>Allowed methods<input value={methods} onChange={(event) => setMethods(event.currentTarget.value)} /><small>Comma-separated, for example GET, POST.</small></label>
232
+ <label>Allowed paths<input value={paths} onChange={(event) => setPaths(event.currentTarget.value)} /><small>Absolute prefixes, for example /v1/enrich.</small></label>
233
+ <label>Authentication<select disabled={targetMode === "service"} value={credential} onChange={(event) => { setCredential(event.currentTarget.value as CredentialStrategy); setSecretReference(""); }}><option value="none">No authentication</option><option value="bearer">Bearer token</option><option value="hmac">HMAC signature</option><option value="static_header">Static header</option><option value="service_binding">Worker service binding</option></select></label>
234
+ {needsSecret ? <label>Worker secret binding<input placeholder="CONNECTION_CUSTOMER_API" value={secretReference} onChange={(event) => setSecretReference(event.currentTarget.value)} /><small>Enter the binding name, never the credential value.</small></label> : null}
235
+ </div>
236
+ </>;
237
+
238
+ return <dialog ref={dialogRef} className="editor-dialog registry-dialog registry-wizard" aria-labelledby="registry-dialog-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="registry-dialog">
239
+ <div className="detail-head"><div><span className="eyebrow">Guided setup</span><h2 id="registry-dialog-title">{title}</h2><p>{kind === "connection" ? "Give tools a secure, reusable route to one service." : "Create a governed capability that workflows can reuse."}</p></div><button className="icon-button" type="button" aria-label="Close" disabled={pending} onClick={close}>×</button></div>
240
+ <ol className="registry-wizard-steps" aria-label="Setup progress">{steps.map((label, index) => <li className={index === step ? "current" : index < step ? "complete" : ""} aria-current={index === step ? "step" : undefined} key={label}><span>{index < step ? "✓" : index + 1}</span>{label}</li>)}</ol>
166
241
  <form className="editor-form registry-form" onSubmit={(event) => void submit(event)}>
167
- <div className="form-grid">
168
- <label>Name<input ref={nameRef} maxLength={160} required disabled={pending} value={name} onChange={(event) => { const value = event.currentTarget.value; setName(value); if (!keyEdited) setKey(slug(value)); }} /></label>
169
- <label>Key<input maxLength={100} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" required disabled={pending} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label>
170
- {kind === "component" ? <><label>Type<select disabled={pending} value={componentKind} onChange={(event) => changeComponentKind(event.currentTarget.value as ComponentKind)}><option value="built_in">Built-in enrichment</option><option value="webhook_enrichment">Webhook enrichment</option><option value="api_enrichment">API enrichment</option><option value="workers_ai">Workers AI</option><option value="specialist_agent">Specialist agent</option><option value="action">Action</option><option value="message">Message</option></select></label><label>Owner<select disabled={pending} value={owner} onChange={(event) => setOwner(event.currentTarget.value as "customer" | "safest")}><option value="customer">Customer</option><option value="safest">Safest built-in</option></select></label></> : <><label>Type<select disabled={pending} value={connectionKind} onChange={(event) => setConnectionKind(event.currentTarget.value as ConnectionKind)}><option value="webhook">Webhook</option><option value="external_api">External API</option><option value="customer_worker">Customer Worker</option><option value="ai_provider">AI provider</option><option value="notification">Notification</option><option value="email">Email</option></select></label><label>Destination<select disabled={pending} value={targetMode} onChange={(event) => changeTargetMode(event.currentTarget.value as "url" | "service")}><option value="url">HTTPS endpoint</option><option value="service">Worker service binding</option></select></label></>}
171
- <label className="wide-field">Description<textarea maxLength={2000} disabled={pending} value={description} onChange={(event) => setDescription(event.currentTarget.value)} /></label>
242
+ <div className="registry-wizard-content">
243
+ {kind === "component" && step === 0 ? <><div className="registry-step-heading"><h3>What should this tool do?</h3><p>This choice sets its authority boundary. You can refine the implementation next.</p></div><div className="registry-purpose-grid">{purposeOptions.map((option) => <button className={purpose === option.value ? "selected" : ""} type="button" aria-pressed={purpose === option.value} onClick={() => selectPurpose(option.value)} key={option.value}><i aria-hidden="true">{option.icon}</i><span><strong>{option.title}</strong><small>{option.description}</small><em>{option.meta}</em></span></button>)}</div><div className="form-grid registry-identity-fields"><label>Name<input ref={nameRef} maxLength={160} value={name} onChange={(event) => { const value = event.currentTarget.value; setName(value); if (!keyEdited) setKey(slug(value)); }} /></label><label>Key<input maxLength={100} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /><small>Stable identifier; cannot be changed later.</small></label><label className="wide-field">Description<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.currentTarget.value)} /></label></div></> : null}
244
+ {kind === "component" && step === 1 ? <><div className="registry-step-heading"><h3>Configure the {purpose}</h3><p>Choose where it runs and disclose only the data it needs.</p></div>{componentSetup}</> : null}
245
+ {kind === "connection" && step === 0 ? <><div className="registry-step-heading"><h3>Where should tools connect?</h3><p>A connection secures one destination. Multiple tools can reuse it.</p></div><div className="form-grid"><label>Name<input ref={nameRef} maxLength={160} value={name} onChange={(event) => { const value = event.currentTarget.value; setName(value); if (!keyEdited) setKey(slug(value)); }} /></label><label>Key<input maxLength={100} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label><label>Connection type<select value={connectionKind} onChange={(event) => setConnectionKind(event.currentTarget.value as ConnectionKind)}><option value="external_api">External API</option><option value="customer_worker">Customer Worker</option><option value="webhook">Webhook</option><option value="ai_provider">AI provider</option><option value="notification">Notification</option><option value="email">Email</option></select></label><label>Destination type<select value={targetMode} onChange={(event) => changeTargetMode(event.currentTarget.value as "url" | "service")}><option value="url">HTTPS endpoint</option><option value="service">Worker service binding</option></select></label>{targetMode === "url" ? <label className="wide-field">HTTPS origin<input type="url" placeholder="https://api.example.com" value={baseUrl} onChange={(event) => setBaseUrl(event.currentTarget.value)} /><small>Origin only; paths are allowlisted in the next step.</small></label> : <label className="wide-field">Service binding<input placeholder="CUSTOMER_API" value={serviceBinding} onChange={(event) => setServiceBinding(event.currentTarget.value)} /></label>}<label className="wide-field">Description<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.currentTarget.value)} /></label></div></> : null}
246
+ {kind === "connection" && step === 1 ? <><div className="registry-step-heading"><h3>Limit access and configure authentication</h3><p>Use the least authority this connection needs.</p></div>{connectionSecurity}</> : null}
247
+ {step === 2 ? <><div className="registry-step-heading"><h3>Review and create</h3><p>Confirm the security boundary before publishing version 1.</p></div><section className="registry-review"><dl><div><dt>Name</dt><dd>{name}</dd></div><div><dt>Type</dt><dd>{kind === "component" ? purposeOptions.find((option) => option.value === purpose)?.title : connectionKind.replaceAll("_", " ")}</dd></div><div><dt>{kind === "component" ? "Authority" : "Destination"}</dt><dd>{kind === "component" ? isAction ? "Can change customer data · approval required" : "Read-only" : targetMode === "url" ? baseUrl : serviceBinding}</dd></div><div><dt>{kind === "component" ? "Runtime" : "Access"}</dt><dd>{kind === "component" ? needsConnection ? activeConnections.find((connection) => connection.activeVersionId === connectionVersionId)?.name || "No connection" : implementation.replaceAll("_", " ") : `${methods} · ${paths}`}</dd></div></dl>{kind === "connection" && needsSecret ? <div className="registry-secret-next"><strong>Install the credential after creation</strong><p>From the Resolve project directory, run this command and paste the secret value when Wrangler asks. The value is never entered in this UI.</p><code>npx wrangler secret put {secretReference}</code></div> : null}<div className="registry-review-note"><strong>What happens next</strong><p>{kind === "component" ? "Resolve publishes version 1 and runs its stored contract example. Add the tool to a workflow when you are ready." : `Resolve publishes the connection definition.${needsSecret ? " Install the Worker secret shown above, then" : " Then"} create a tool that uses it and send an explicit live test request from the connection details.`}</p></div></section></> : null}
172
248
  </div>
173
- {kind === "component" ? <>
174
- <div className="safety-boundary"><strong>{authority}</strong><span>{authorityDetail}</span></div>
175
- <div className="form-grid">
176
- <label>Implementation<select disabled={pending} value={implementation} onChange={(event) => setImplementation(event.currentTarget.value as ImplementationKind)}><option value="built_in">Built-in</option><option value="workers_ai">Workers AI</option><option value="specialist_agent">Specialist agent</option><option value="webhook">Webhook</option><option value="external_api">External API</option><option value="message_template">Message template</option></select></label>
177
- <label>Implementation reference<input required disabled={pending} value={reference} onChange={(event) => setReference(event.currentTarget.value)} /></label>
178
- {needsConnection ? <label>Connection<select required disabled={pending} value={connectionVersionId} onChange={(event) => setConnectionVersionId(event.currentTarget.value)}><option value="">Select a published connection</option>{activeConnections.map((connection) => <option value={connection.activeVersionId || ""} key={connection.id}>{connection.name} · v{connection.version || 1}</option>)}</select></label> : null}
179
- <label>Risk class<select disabled={pending} value={risk} onChange={(event) => setRisk(event.currentTarget.value)}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label>
180
- <label>Allowed input fields<input required disabled={pending} value={inputFields} onChange={(event) => setInputFields(event.currentTarget.value)} /></label>
181
- <label>Data classifications<input required disabled={pending} value={classifications} onChange={(event) => setClassifications(event.currentTarget.value)} /></label>
182
- <label className="wide-field">Input schema<textarea className="code-input" spellCheck={false} required disabled={pending} value={inputSchema} onChange={(event) => setInputSchema(event.currentTarget.value)} /></label>
183
- <label className="wide-field">Output schema<textarea className="code-input" spellCheck={false} required disabled={pending} value={outputSchema} onChange={(event) => setOutputSchema(event.currentTarget.value)} /></label>
184
- </div>
185
- </> : kind === "connection" ? <>
186
- <div className="safety-boundary"><strong>Outbound access stays allowlisted.</strong><span>Enter a public HTTPS origin or a Worker service binding. Store credential values only in Worker secrets.</span></div>
187
- <div className="form-grid">
188
- {targetMode === "url" ? <label>HTTPS origin<input type="url" placeholder="https://api.example.com" required disabled={pending} value={baseUrl} onChange={(event) => setBaseUrl(event.currentTarget.value)} /></label> : <label>Service binding<input placeholder="CUSTOMER_API" required disabled={pending} value={serviceBinding} onChange={(event) => setServiceBinding(event.currentTarget.value)} /></label>}
189
- <label>Allowed methods<input required disabled={pending} value={methods} onChange={(event) => setMethods(event.currentTarget.value)} /></label>
190
- <label>Allowed paths<input required disabled={pending} value={paths} onChange={(event) => setPaths(event.currentTarget.value)} /></label>
191
- <label>Credential strategy<select disabled={pending || targetMode === "service"} value={credential} onChange={(event) => { setCredential(event.currentTarget.value as CredentialStrategy); setSecretReference(""); }}><option value="none">None</option><option value="bearer">Bearer token</option><option value="hmac">HMAC signing</option><option value="static_header">Static header</option><option value="service_binding">Service binding</option></select></label>
192
- {needsSecret ? <label className="wide-field">Worker secret binding <small>Enter the binding name, never the credential value.</small><input pattern="(?:CONNECTION|OAUTH|ACTION|NOTIFICATION|EMAIL|AI)_[A-Z0-9_]{2,96}" placeholder="CONNECTION_CUSTOMER_API" required disabled={pending} value={secretReference} onChange={(event) => setSecretReference(event.currentTarget.value)} /></label> : null}
193
- </div>
194
- </> : null}
195
- <div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={pending} onClick={close}>Cancel</button><button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : `Create ${kind || "item"}`}</button></div></div>
249
+ <div className="editor-footer registry-wizard-footer"><p className="error" role="alert">{error}</p><div>{step === 0 ? <button className="secondary" type="button" disabled={pending} onClick={close}>Cancel</button> : <button className="secondary" type="button" disabled={pending} onClick={back}>Back</button>}{step < 2 ? <button className="primary" type="button" onClick={next}>Continue</button> : <button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : kind === "component" ? "Create tool" : "Add connection"}</button>}</div></div>
196
250
  </form>
197
251
  </dialog>;
198
252
  }