create-safest-tools 0.6.0 → 0.7.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.
Files changed (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. package/template/worker-configuration.d.ts +3 -2
@@ -4,6 +4,7 @@ import { PageHeader } from "../components/PageHeader";
4
4
  import { errorMessage, postJson, requestJson } from "../lib/http";
5
5
  import { relativeTime } from "../lib/format";
6
6
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
7
+ import { PersonAccessDialog } from "./PersonAccessDialog";
7
8
  import type { InvitationResponse, PasswordResetResponse, PeopleResponse, WorkspaceInvitation, WorkspacePerson } from "./types";
8
9
 
9
10
  const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
@@ -24,6 +25,8 @@ export function PeopleWorkspace() {
24
25
  const [error, setError] = useState("");
25
26
  const [inviteUrl, setInviteUrl] = useState("");
26
27
  const [resetMessage, setResetMessage] = useState("");
28
+ const [editing, setEditing] = useState<WorkspacePerson | null>(null);
29
+ const [copied, setCopied] = useState(false);
27
30
  const canManage = shell.session?.permissions.managePeople === true;
28
31
  const isOwner = shell.session?.roles.includes("owner") === true;
29
32
 
@@ -38,10 +41,10 @@ export function PeopleWorkspace() {
38
41
  finally { setLoading(false); }
39
42
  };
40
43
 
41
- useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); void load(); }, [shell.session?.actor.id, canManage]);
44
+ useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); setEditing(null); void load(); }, [shell.session?.actor.id, canManage]);
42
45
 
43
46
  const invite = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
44
- event.preventDefault(); setPending("invite"); setError(""); setInviteUrl("");
47
+ event.preventDefault(); setPending("invite"); setError(""); setInviteUrl(""); setCopied(false);
45
48
  try {
46
49
  const result = await postJson<InvitationResponse>("/v1/admin/people", { email, role });
47
50
  setInviteUrl(result.invitation.inviteUrl); setEmail(""); await load();
@@ -71,11 +74,12 @@ export function PeopleWorkspace() {
71
74
  </PageHeader>
72
75
  <section className="surface people-surface" aria-labelledby="people-title">
73
76
  <form className="invite-form" onSubmit={invite}><label>Email<input type="email" autoComplete="off" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></label><label>Role<select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="reviewer">Analyst</option><option value="appeal_reviewer">Appeal analyst</option><option value="auditor">Auditor</option>{isOwner ? <option value="administrator">Administrator</option> : null}</select></label><button className="primary" type="submit" disabled={pending === "invite"}>{pending === "invite" ? "Sending…" : "Send invitation"}</button></form>
74
- {inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => void copy(inviteUrl)}>Copy link</button></div> : null}
77
+ {inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => { void copy(inviteUrl).then(() => setCopied(true)).catch(() => setError("Copy failed. Select the invitation link and copy it manually.")); }}>{copied ? "Copied" : "Copy link"}</button></div> : null}
75
78
  {resetMessage ? <p className="form-message" role="status">{resetMessage}</p> : null}
76
- <div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
79
+ <div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}{person.status === "suspended" ? " · Suspended" : ""}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <div className="people-actions"><button className="secondary compact-button" type="button" onClick={() => setEditing(person)}>Edit access</button><button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button></div> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
77
80
  <p className="error surface-error" role="alert">{error}</p>
78
81
  </section>
82
+ <PersonAccessDialog person={editing} owner={isOwner} onClose={() => setEditing(null)} onSaved={() => { setResetMessage("Workspace access updated."); void load(); }} />
79
83
  </div>
80
84
  );
81
85
  }
@@ -0,0 +1,40 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { errorMessage, requestJson } from "../lib/http";
3
+ import type { WorkspacePerson } from "./types";
4
+
5
+ const roles = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", auditor: "Auditor", administrator: "Administrator", owner: "Infrastructure owner" };
6
+
7
+ export function PersonAccessDialog({ person, owner, onClose, onSaved }: { person: WorkspacePerson | null; owner: boolean; onClose: () => void; onSaved: () => void }) {
8
+ const ref = useRef<HTMLDialogElement>(null);
9
+ const [selected, setSelected] = useState<string[]>([]);
10
+ const [status, setStatus] = useState("active");
11
+ const [pending, setPending] = useState(false);
12
+ const [error, setError] = useState("");
13
+ useEffect(() => {
14
+ setSelected(person?.roles || []); setStatus(person?.status || "active"); setError("");
15
+ if (person && !ref.current?.open) ref.current?.showModal();
16
+ if (!person && ref.current?.open) ref.current.close();
17
+ }, [person]);
18
+ const close = () => { if (!pending) onClose(); };
19
+ const save = async (event: FormEvent<HTMLFormElement>) => {
20
+ event.preventDefault();
21
+ if (!person || pending) return;
22
+ if (!selected.length) { setError("Choose at least one role."); return; }
23
+ if (status === "suspended" && person.status !== "suspended" && !window.confirm(`Suspend ${person.displayName}? Their active sessions will be signed out.`)) return;
24
+ setPending(true); setError("");
25
+ try {
26
+ await requestJson(`/v1/admin/people/${encodeURIComponent(person.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ roles: selected, status }) });
27
+ onSaved(); onClose();
28
+ } catch (cause) { setError(errorMessage(cause, "Access could not be updated.")); }
29
+ finally { setPending(false); }
30
+ };
31
+ return <dialog ref={ref} className="editor-dialog" aria-labelledby="person-access-title" onCancel={(event) => { event.preventDefault(); close(); }}>
32
+ <div className="detail-head"><div><h2 id="person-access-title">Edit access</h2><p>{person?.displayName} · {person?.email}</p></div><button type="button" className="icon-button" aria-label="Close access editor" disabled={pending} onClick={close}>×</button></div>
33
+ <form className="editor-form" onSubmit={(event) => void save(event)}>
34
+ <fieldset disabled={pending}><legend>Workspace roles</legend>{Object.entries(roles).filter(([role]) => owner || !["owner", "administrator"].includes(role)).map(([role, label]) => <label className="checkbox" key={role}><input type="checkbox" checked={selected.includes(role)} onChange={(event) => { const checked = event.currentTarget.checked; setSelected((current) => checked ? [...current, role] : current.filter((value) => value !== role)); }} />{label}</label>)}</fieldset>
35
+ <label>Account status<select disabled={pending} value={status} onChange={(event) => setStatus(event.currentTarget.value)}><option value="active">Active</option><option value="suspended">Suspended</option></select></label>
36
+ <p className="form-help">Suspending access signs out active sessions. Reactivating access lets this teammate sign in again.</p>
37
+ <p role="alert" className="error">{error}</p><div className="editor-footer"><button type="button" className="secondary" disabled={pending} onClick={close}>Cancel</button><button type="submit" className="primary" disabled={pending}>{pending ? "Saving…" : "Save access"}</button></div>
38
+ </form>
39
+ </dialog>;
40
+ }
@@ -4,6 +4,7 @@ export interface WorkspacePerson {
4
4
  email: string;
5
5
  avatarUrl?: string | null;
6
6
  roles: string[];
7
+ status: "active" | "suspended";
7
8
  lastLoginAt?: string | null;
8
9
  }
9
10
 
@@ -116,7 +116,7 @@ export function ProfileWorkspace() {
116
116
  <section className="surface profile-surface" aria-labelledby="profile-title">
117
117
  {profile ? <div className="profile-layout">
118
118
  <div className="profile-photo-card">
119
- <div className="profile-avatar-preview" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
119
+ <div className="profile-avatar-preview" role="img" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
120
120
  <label className={`secondary upload-button${pending ? " disabled" : ""}`} aria-disabled={Boolean(pending)}>{pending === "upload" ? "Uploading…" : "Choose picture"}<input type="file" accept="image/jpeg,image/png,image/webp" disabled={Boolean(pending)} onChange={(event) => void upload(event)} /></label>
121
121
  {profile.avatarUrl ? <button className="text-button" type="button" disabled={Boolean(pending)} onClick={() => void remove()}>{pending === "remove" ? "Removing…" : "Remove picture"}</button> : null}
122
122
  <small>JPEG, PNG, or WebP. Maximum 2 MB.</small>
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { dateTime } from "../lib/format";
3
+ import { errorMessage, requestJson } from "../lib/http";
4
+ import { dispatchOpenReport } from "../reports/events";
5
+ import { openChannelDialog, settingsEvents } from "../settings/events";
6
+ import { dispatchNavigation } from "../shell/events";
7
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
8
+ import { registryEvents } from "./events";
9
+ import { IntegrationVerification } from "./IntegrationVerification";
10
+ import type { IntegrationProgress } from "../../shared/integration-progress";
11
+
12
+ export function IntegrationSetup({onTools}:{onTools:(kind:"enrichment"|"action")=>void}) {
13
+ const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
14
+ const permissions=shell.session?.permissions??{};
15
+ const [progress,setProgress]=useState<IntegrationProgress|null>(null),[error,setError]=useState("");
16
+ const [loading,setLoading]=useState(false);const version=useRef(0);
17
+ const access=JSON.stringify({actor:shell.session?.actor.id,permissions,scope:shell.session?.queueScope});
18
+ const load=useCallback(async()=>{const request=++version.current;setLoading(true);setError("");try{const result=await requestJson<IntegrationProgress>("/v1/admin/integration-progress");if(request===version.current)setProgress(result);}catch(cause){if(request===version.current)setError(errorMessage(cause,"Setup evidence could not refresh."));}finally{if(request===version.current)setLoading(false);}},[]);
19
+ useEffect(()=>{version.current++;setProgress(null);if(permissions.manageComponents)void load();return()=>{version.current++;};},[access,load]);
20
+ useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);};},[load]);
21
+ const [expanded,setExpanded]=useState<string|null>("intake");
22
+ const config=progress?.configuration;
23
+ const configured=(count:number|null|undefined,label:string)=>count==null?"Configuration access required":`${count} ${label}`;
24
+ const stages=[
25
+ {key:"intake" as const,title:"Receive a report",description:"Start with a hosted form or product widget. Signed customer context keeps identity and content references tied to your application.",configuration:configured(config?.reportingRoutes,"configured reporting routes"),action:"Set up reporting",allowed:permissions.manageIntegrations,run:openChannelDialog},
26
+ {key:"context" as const,title:"Bring in the right context",description:"Publish read-only customer tools for the evidence your reviewers need. Declare the fields each playbook may request.",configuration:configured(config?.customerReads,"published customer reads"),action:"Choose read tools",allowed:true,run:()=>onTools("enrichment")},
27
+ {key:"action" as const,title:"Confirm the customer action",description:"Choose exact targets and approval rules. The case waits for your application to confirm the approved change.",configuration:configured(config?.customerActions,"published customer actions"),action:"Choose action tools",allowed:true,run:()=>onTools("action")},
28
+ {key:"notice" as const,title:"Reach each participant",description:"Write separate public outcomes for the reporter and affected person. A delivery handoff stays distinct from someone reading the notice.",configuration:config?.noticeChannel==null?"Configuration access required":config.noticeChannel?"Participant channel configured":"Participant channel needed",action:"Review notice setup",allowed:permissions.manageInstallation||permissions.manageIntegrations,run:()=>dispatchNavigation("settings")},
29
+ {key:"playbook" as const,title:"Run a complete case",description:"Publish and activate a tested playbook, then submit a fresh report through your customer channel. Follow it from review to the confirmed outcome.",configuration:configured(config?.activePlaybooks,"active playbook queue assignments"),action:"Open playbooks",allowed:permissions.manageWorkflows,run:()=>dispatchNavigation("workflows")},
30
+ ];
31
+ return <>{permissions.manageIntegrations&&permissions.manageWorkflows&&permissions.readReports&&permissions.readEvidence&&progress?.canReadCases===true?<IntegrationVerification key={access}/>:null}<section className="integration-setup surface" aria-label="Customer connection setup">
32
+ <div className="surface-head"><div><h2>Customer connection setup reference</h2><p>Start with reporting. Add customer context, approved actions and participant delivery as your playbook needs them.</p></div><button className="secondary" type="button" disabled={loading} onClick={()=>void load()}>{loading?"Checking records…":"Refresh historical evidence"}</button></div>
33
+ {progress?.localSandbox?<p className="case-guidance">Local sandbox records. Model fixtures and local customer receivers do not verify production model judgement or external delivery.</p>:null}
34
+ <ol className="integration-stages" aria-busy={loading}>{stages.map((stage,index)=>{const evidence=progress?.evidence[stage.key];const open=expanded===stage.key;return <li key={stage.key}>
35
+ <h3><button type="button" className="integration-stage-toggle" aria-expanded={open} aria-controls={`integration-stage-${stage.key}`} onClick={()=>setExpanded(open?null:stage.key)}><span className="integration-stage-number" aria-hidden="true">{index+1}</span><span>{stage.title}<small>{progress?stage.configuration:"Loading configuration…"}</small></span><span aria-hidden="true">{open?"−":"+"}</span></button></h3>
36
+ <div id={`integration-stage-${stage.key}`} hidden={!open} className="integration-stage-detail"><p>{stage.description}</p><div className="integration-stage-evidence">{evidence?<><strong>Historical case evidence</strong><button className="text-button" type="button" onClick={()=>dispatchOpenReport(evidence.reportId)}>{evidence.reference}</button><small>{dateTime(evidence.observedAt)}</small></>:<span>{progress?.canReadCases===false?"Case evidence is restricted for this role":progress?"No matching case evidence in your access yet":"Checking saved evidence…"}</span>}</div>{stage.allowed?<button className="secondary" type="button" onClick={stage.run}>{stage.action}</button>:<small>Ask your workspace administrator to complete this step.</small>}</div>
37
+ </li>;})}</ol>
38
+ <p className="form-help">These are saved configuration and case records, not a live health check. Run a fresh case after changes. Contract examples alone do not complete this journey.</p>
39
+ {error?<p className="error" role="alert">{error} {progress?"Previously loaded records remain visible.":""}</p>:null}
40
+ </section></>;
41
+ }
@@ -0,0 +1,54 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
3
+ import { workflowEvents } from "../workflows/events";
4
+ import type { IntegrationVerificationList, IntegrationVerification as Check } from "../../shared/integration-progress";
5
+ import { dateTime } from "../lib/format";
6
+ import { errorMessage, postJson, requestJson } from "../lib/http";
7
+ import { dispatchOpenReport } from "../reports/events";
8
+ import { settingsEvents } from "../settings/events";
9
+ import { registryEvents } from "./events";
10
+ import "./integration-verification.css";
11
+
12
+ const stateLabel: Record<Check['state'],string> = { awaiting_case:"Waiting for a fresh report",in_progress:"Check in progress",blocked:"Needs attention",stale:"Configuration changed",verified:"Selected outcome verified" };
13
+ export function IntegrationVerification() {
14
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
15
+ const [data,setData] = useState<IntegrationVerificationList|null>(null), [error,setError] = useState(''), [busy,setBusy] = useState(false);
16
+ const [integration,setIntegration] = useState(''), [form,setForm] = useState(''), [queue,setQueue] = useState(''), [decision,setDecision] = useState('');
17
+ const [activeId,setActiveId] = useState(''), [candidate,setCandidate] = useState('');
18
+ const generation = useRef(0);
19
+ const load = useCallback(async()=>{const version=++generation.current;setBusy(true);setError('');try {const result=await requestJson<IntegrationVerificationList>('/v1/admin/integration-verifications');if(version===generation.current)setData(result);}catch(cause){if(version===generation.current)setError(errorMessage(cause,'Verification could not refresh.'));}finally{if(version===generation.current)setBusy(false);}},[]);
20
+ useEffect(()=>{if(shell.currentView==='components')void load();return()=>{generation.current++;};},[load,shell.currentView]);
21
+ useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);window.addEventListener(workflowEvents.changed,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);window.removeEventListener(workflowEvents.changed,refresh);};},[load]);
22
+ const choices=data?.choices;
23
+ const selectedIntegration=choices?.integrations.find(item=>item.id===integration);
24
+ const selectedPlaybook=choices?.playbooks.find(item=>item.queueId===queue);
25
+ const selectedForm=selectedIntegration?.formVersionId||form;
26
+ const active=data?.runs.find(item=>item.id===activeId)||data?.runs[0];
27
+ useEffect(()=>{setCandidate('');},[active?.id]);
28
+ useEffect(()=>{if(choices?.integrations.length===1&&!integration)setIntegration(choices.integrations[0]!.id);if(choices?.playbooks.length===1&&!queue)setQueue(choices.playbooks[0]!.queueId);},[choices,integration,queue]);
29
+ const save = async(path:string,body:unknown)=>{const version=++generation.current;setBusy(true);setError('');try {const result=await postJson<Check>(path,body);if(version!==generation.current)return;setActiveId(result.id);setData(previous=>previous?{...previous,runs:[result,...previous.runs.filter(item=>item.id!==result.id)].slice(0,5)}:previous);}catch(cause){if(version===generation.current)setError(errorMessage(cause,'The check could not be saved.'));}finally{if(version===generation.current)setBusy(false);}};
30
+ return <section className="integration-verification surface" aria-label="Verify a customer connection">
31
+ <div className="surface-head"><div><h2>Verify one complete customer journey</h2><p>Follow one fresh case from your reporting channel to a confirmed outcome using the current configuration.</p></div><button type="button" className="secondary" disabled={busy} onClick={()=>void load()}>{busy?'Checking…':'Refresh verification'}</button></div>
32
+ <form className="verification-start" onSubmit={event=>{event.preventDefault();void save('/v1/admin/integration-verifications',{integration_id:integration,form_version_id:selectedForm,queue_id:queue,decision_code:decision});}}>
33
+ <label>Reporting integration<select value={integration} onChange={event=>{setIntegration(event.target.value);setForm('');}} required disabled={busy}><option value="">Choose an integration</option>{choices?.integrations.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
34
+ <label>Reporting form<select value={selectedForm} onChange={event=>setForm(event.target.value)} required disabled={busy||Boolean(selectedIntegration?.formVersionId)}><option value="">Choose a form</option>{choices?.forms.filter(item=>!selectedIntegration?.formVersionId||item.id===selectedIntegration.formVersionId).map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
35
+ <label>Queue and active playbook<select value={queue} onChange={event=>{setQueue(event.target.value);setDecision('');}} required disabled={busy}><option value="">Choose a queue</option>{choices?.playbooks.map(item=><option key={item.queueId} value={item.queueId}>{item.queueName} · {item.name}</option>)}</select></label>
36
+ <label>Outcome to verify<select value={decision} onChange={event=>setDecision(event.target.value)} required disabled={busy||!selectedPlaybook}><option value="">Choose an outcome</option>{selectedPlaybook?.decisionCodes.map(code=><option key={code} value={code}>{code.replaceAll('_',' ')}</option>)}</select></label>
37
+ <p className="form-help">Use a safe test account and target. Starting a check changes no customer data. The case you submit afterward follows normal action approvals and participant delivery.</p>
38
+ <button className="primary" type="submit" disabled={busy||!integration||!selectedForm||!queue||!decision}>Start a new verification</button>
39
+ </form>
40
+ {data&&!choices?.integrations.length?<p className="case-guidance">Set up a reporting integration before starting a check.</p>:null}
41
+ {data&&!choices?.playbooks.length?<p className="case-guidance">Publish and activate a primary playbook for an accessible queue first.</p>:null}
42
+ {data&&data.runs.length>1?<label className="verification-history">Recent checks<select value={active?.id||''} onChange={event=>setActiveId(event.target.value)}>{data.runs.map(run=><option key={run.id} value={run.id}>{run.integrationName} · {run.decisionCode.replaceAll('_',' ')} · {dateTime(run.startedAt)}</option>)}</select></label>:null}
43
+ {active?<article className="verification-run" aria-label="Current verification" aria-busy={busy}>
44
+ <div className="verification-summary"><h3>{active.integrationName} · {active.decisionCode.replaceAll('_',' ')}</h3><strong className={`verification-state verification-state-${error?'blocked':active.state}`} role="status">{error?'Verification unavailable':stateLabel[active.state]}</strong><p>{active.formName} → {active.queueName} → {active.playbookName}</p><small>Started {dateTime(active.startedAt)}</small></div>
45
+ {active.localSandbox?<p className="case-guidance">Local sandbox check. These records do not verify production providers or delivery.</p>:null}
46
+ {active.state==='stale'?<p className="case-guidance">The configuration changed since this check started. Start a new verification, then submit another fresh report. These results cannot verify the current setup.</p>:null}
47
+ {active.report?<button className="secondary" type="button" onClick={()=>dispatchOpenReport(active.report!.id)}>Open case {active.report.reference}</button>:null}
48
+ {active.state==='awaiting_case'?<div className="verification-bind"><p>Submit a report through <strong>{active.integrationName}</strong> using <strong>{active.formName}</strong>, with an example that should reach <strong>{active.queueName}</strong> and the selected outcome. Refresh after the report reaches its playbook.</p>{active.candidates.length?<><label>Fresh report to follow<select value={candidate} onChange={event=>setCandidate(event.target.value)} disabled={busy}><option value="">Choose the report you submitted</option>{active.candidates.map(item=><option key={item.id} value={item.id}>{item.reference} · {dateTime(item.receivedAt)}</option>)}</select></label><button type="button" disabled={busy||!candidate} onClick={()=>void save(`/v1/admin/integration-verifications/${active.id}/bind`,{report_id:candidate})}>Follow this case</button></>:<p className="form-help">No matching fresh case has reached this queue yet. If a report is missing, check its routing in Cases. Earlier reports cannot verify this check.</p>}</div>:null}
49
+ <ol className="verification-evidence">{active.stages.map(item=><li key={item.key} data-state={item.state}><div><strong>{item.title}</strong><span>{item.state==='complete'?'Confirmed':item.state==='not_required'?'Not required':item.state==='blocked'?'Needs attention':'Waiting'}</span></div><p>{item.detail}</p></li>)}</ol>
50
+ <p className="form-help">This check covers this integration, form, queue, playbook and selected outcome only. Verify other customer action paths separately. Status is rechecked against current configuration whenever you refresh.</p>
51
+ </article>:null}
52
+ {error?<p role="alert" className="error">{error} {data?'Refresh before relying on previously displayed results.':''}</p>:null}
53
+ </section>;
54
+ }
@@ -124,7 +124,7 @@ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChange
124
124
  useEffect(() => {
125
125
  if (!item) return;
126
126
  let active = true;
127
- setLoading(true); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
127
+ setLoading(true); setPending(""); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
128
128
  void Promise.all([
129
129
  requestJson<RegistryDetailResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}`),
130
130
  requestJson<RegistryDependenciesResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}/dependencies`),
@@ -239,7 +239,7 @@ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChange
239
239
  <button className="danger-link" type="button" disabled={Boolean(pending) || dependencyList.length > 0} onClick={() => setConfirmArchive(true)}>Archive {kind === "component" ? "tool" : "connection"}</button>
240
240
  </div>
241
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}
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-action" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle("archive")}>{pending === "archive" ? "Archiving…" : "Archive permanently"}</button></div></div> : null}
243
243
  </section> : null}
244
244
  </>}
245
245
  <p className="error" role="alert">{error}</p>
@@ -1,23 +1,24 @@
1
- import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent, type MouseEvent } from "react";
2
2
  import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
3
3
  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";
8
- type ComponentKind = "built_in" | "webhook_enrichment" | "api_enrichment" | "workers_ai" | "specialist_agent" | "action" | "message";
9
- type ImplementationKind = "built_in" | "webhook" | "external_api" | "workers_ai" | "specialist_agent" | "message_template";
7
+ type ToolPurpose = "enrichment" | "action" | "ai";
8
+ type ComponentKind = "webhook_enrichment" | "api_enrichment" | "workers_ai" | "action";
9
+ type ImplementationKind = "webhook" | "external_api" | "workers_ai";
10
10
  type ConnectionKind = "webhook" | "external_api" | "customer_worker" | "ai_provider" | "notification" | "email";
11
11
  type CredentialStrategy = "none" | "static_header" | "bearer" | "hmac" | "service_binding";
12
12
 
13
13
  const inputSchemaDefault = JSON.stringify({ type: "object", properties: { report: { type: "object" } } }, null, 2);
14
14
  const outputSchemaDefault = JSON.stringify({ type: "object", properties: { result: { type: "string" } } }, null, 2);
15
+ const fixtureInputDefault = JSON.stringify({ report: {} }, null, 2);
16
+ const fixtureOutputDefault = JSON.stringify({ result: "ok" }, null, 2);
15
17
 
16
18
  const purposeOptions: Array<{ value: ToolPurpose; title: string; description: string; meta: string; icon: string }> = [
17
19
  { value: "enrichment", title: "Enrichment", description: "Read customer context and add findings to a report.", meta: "Read-only", icon: "+" },
18
20
  { value: "action", title: "Action", description: "Apply a reviewed change in a customer system.", meta: "Approval required", icon: "⚡" },
19
21
  { 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
  ];
22
23
 
23
24
  function slug(value: string): string {
@@ -36,19 +37,15 @@ function jsonObject(value: string, label: string): Record<string, unknown> {
36
37
 
37
38
  function componentForPurpose(purpose: ToolPurpose, implementation: ImplementationKind): ComponentKind {
38
39
  if (purpose === "action") return "action";
39
- if (purpose === "ai") return implementation === "workers_ai" ? "workers_ai" : "specialist_agent";
40
- if (purpose === "message") return "message";
40
+ if (purpose === "ai") return "workers_ai";
41
41
  if (implementation === "webhook") return "webhook_enrichment";
42
- if (implementation === "external_api") return "api_enrichment";
43
- return "built_in";
42
+ return "api_enrichment";
44
43
  }
45
44
 
46
45
  function referenceLabel(implementation: ImplementationKind): string {
47
46
  if (["webhook", "external_api"].includes(implementation)) return "Operation";
48
47
  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";
48
+ return "Implementation";
52
49
  }
53
50
 
54
51
  export function RegistryDialog() {
@@ -64,14 +61,16 @@ export function RegistryDialog() {
64
61
  const [keyEdited, setKeyEdited] = useState(false);
65
62
  const [description, setDescription] = useState("");
66
63
  const [purpose, setPurpose] = useState<ToolPurpose>("enrichment");
67
- const [implementation, setImplementation] = useState<ImplementationKind>("built_in");
68
- const [reference, setReference] = useState("custom-enrichment-v1");
64
+ const [implementation, setImplementation] = useState<ImplementationKind>("external_api");
65
+ const [reference, setReference] = useState("POST /");
69
66
  const [connectionVersionId, setConnectionVersionId] = useState("");
70
67
  const [risk, setRisk] = useState("low");
71
68
  const [inputFields, setInputFields] = useState("report.id, report.reason_code");
72
69
  const [classifications, setClassifications] = useState("report_operational");
73
70
  const [inputSchema, setInputSchema] = useState(inputSchemaDefault);
74
71
  const [outputSchema, setOutputSchema] = useState(outputSchemaDefault);
72
+ const [fixtureInput, setFixtureInput] = useState(fixtureInputDefault);
73
+ const [fixtureOutput, setFixtureOutput] = useState(fixtureOutputDefault);
75
74
  const [connections, setConnections] = useState<ConnectionSummary[]>([]);
76
75
  const [connectionKind, setConnectionKind] = useState<ConnectionKind>("external_api");
77
76
  const [targetMode, setTargetMode] = useState<"url" | "service">("url");
@@ -81,11 +80,16 @@ export function RegistryDialog() {
81
80
  const [paths, setPaths] = useState("/");
82
81
  const [credential, setCredential] = useState<CredentialStrategy>("none");
83
82
  const [secretReference, setSecretReference] = useState("");
83
+ const [correctionEnabled, setCorrectionEnabled] = useState(false);
84
+ const [correctionCode, setCorrectionCode] = useState("");
85
+ const [correctsCodes, setCorrectsCodes] = useState("");
84
86
 
85
87
  const reset = (): void => {
86
88
  setStep(0); setName(""); setKey(""); setKeyEdited(false); setDescription(""); setPurpose("enrichment");
87
- setImplementation("built_in"); setReference("custom-enrichment-v1"); setConnectionVersionId(""); setRisk("low");
89
+ setImplementation("external_api"); setReference("POST /"); setConnectionVersionId(""); setRisk("low");
88
90
  setInputFields("report.id, report.reason_code"); setClassifications("report_operational"); setInputSchema(inputSchemaDefault); setOutputSchema(outputSchemaDefault);
91
+ setFixtureInput(fixtureInputDefault); setFixtureOutput(fixtureOutputDefault);
92
+ setCorrectionEnabled(false); setCorrectionCode(""); setCorrectsCodes("");
89
93
  setConnectionKind("external_api"); setTargetMode("url"); setBaseUrl(""); setServiceBinding(""); setMethods("POST"); setPaths("/"); setCredential("none"); setSecretReference(""); setError("");
90
94
  };
91
95
  const close = (): void => { if (!pending) { setKind(null); reset(); } };
@@ -127,19 +131,24 @@ export function RegistryDialog() {
127
131
 
128
132
  const selectPurpose = (next: ToolPurpose): void => {
129
133
  setPurpose(next);
130
- if (next === "enrichment") { setImplementation("built_in"); setReference("custom-enrichment-v1"); setRisk("low"); }
134
+ if (next !== "action") setCorrectionEnabled(false);
135
+ if (next === "enrichment") { setImplementation("external_api"); setReference("POST /"); setRisk("low"); }
131
136
  if (next === "action") { setImplementation("external_api"); setReference("POST /"); setRisk("high"); }
132
137
  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"); }
138
+ };
139
+
140
+ const useCorrectionContract = (): void => {
141
+ setInputFields("reason, original_operation_id");
142
+ setInputSchema(JSON.stringify({ type: "object", properties: { reason: { type: "string", minLength: 1, maxLength: 2000 }, original_operation_id: { type: "string", minLength: 1 } }, required: ["reason", "original_operation_id"], additionalProperties: false }, null, 2));
143
+ setFixtureInput(JSON.stringify({ reason: "An independent appeal reversed the original decision.", original_operation_id: "example-original-operation" }, null, 2));
144
+ setOutputSchema(JSON.stringify({ type: "object", properties: { action_id: { type: "string" }, operation_id: { type: "string" }, payload_digest: { type: "string" }, status: { type: "string", enum: ["accepted", "applied", "failed"] } }, required: ["action_id", "operation_id", "payload_digest", "status"] }, null, 2));
145
+ setFixtureOutput(JSON.stringify({ action_id: "example-correction", operation_id: "example-correction-operation", payload_digest: `sha256:${"a".repeat(64)}`, status: "applied" }, null, 2));
134
146
  };
135
147
 
136
148
  const changeImplementation = (next: ImplementationKind): void => {
137
149
  setImplementation(next); setConnectionVersionId("");
138
- if (next === "built_in") setReference("custom-enrichment-v1");
139
150
  if (["webhook", "external_api"].includes(next)) setReference("POST /");
140
151
  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");
143
152
  };
144
153
 
145
154
  const changeTargetMode = (next: "url" | "service"): void => {
@@ -160,8 +169,14 @@ export function RegistryDialog() {
160
169
  if (kind === "component") {
161
170
  if (!reference.trim()) { setError(`Enter the ${referenceLabel(implementation).toLowerCase()}.`); return false; }
162
171
  if (needsConnection && !connectionVersionId) { setError("Select the connection this tool should use."); return false; }
172
+ if (isAction && correctionEnabled && (!/^[a-z][a-z0-9_:-]{0,99}$/u.test(correctionCode) || !commaValues(correctsCodes).length || !commaValues(correctsCodes).every(code => /^[a-z][a-z0-9_:-]{0,99}$/u.test(code)))) {
173
+ setError("Enter the correction action code and the original action codes it can correct."); return false;
174
+ }
163
175
  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; }
176
+ try {
177
+ jsonObject(inputSchema, "Input schema"); jsonObject(outputSchema, "Output schema");
178
+ jsonObject(fixtureInput, "Example input"); jsonObject(fixtureOutput, "Example output");
179
+ } catch (cause) { setError(errorMessage(cause, "Fix the JSON contracts.")); return false; }
165
180
  } else {
166
181
  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
182
  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; }
@@ -170,7 +185,12 @@ export function RegistryDialog() {
170
185
  return true;
171
186
  };
172
187
 
173
- const next = (): void => { if (validateStep()) setStep((current) => Math.min(2, current + 1)); };
188
+ const next = (event: MouseEvent<HTMLButtonElement>): void => {
189
+ // Prevent the same click from submitting if React changes Continue into
190
+ // the submit button while rendering the final review step.
191
+ event.preventDefault();
192
+ if (validateStep()) setStep((current) => Math.min(2, current + 1));
193
+ };
174
194
  const back = (): void => { setError(""); setStep((current) => Math.max(0, current - 1)); };
175
195
 
176
196
  const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
@@ -201,8 +221,8 @@ export function RegistryDialog() {
201
221
  connection_version_id: needsConnection ? connectionVersionId : null, risk_class: risk,
202
222
  timeout_policy: { timeout_ms: 5000 }, retry_policy: { maximum_attempts: isAction ? 5 : 3, backoff: "exponential" },
203
223
  cache_policy: { enabled: !isAction }, concurrency_policy: { maximum_concurrency: 8 }, budget_policy: {},
204
- approval_policy: isAction ? { mode: "human" } : {}, tool_manifest: [],
205
- compatibility: { fixture_evidence: { passed: true, fixtures: [{ name: "Contract example", input: { report: {} }, expected_output: { result: "ok" }, expected_status: "succeeded", mode: "simulated" }] } },
224
+ approval_policy: isAction ? { mode: "human", ...(correctionEnabled ? { correction: { action_code: correctionCode, corrects_action_codes: commaValues(correctsCodes) } } : {}) } : {}, tool_manifest: [],
225
+ compatibility: { fixture_evidence: { fixtures: [{ name: "Contract example", input: jsonObject(fixtureInput, "Example input"), expected_output: jsonObject(fixtureOutput, "Example output"), expected_status: "succeeded", mode: "simulated" }] } },
206
226
  },
207
227
  }),
208
228
  });
@@ -215,14 +235,23 @@ export function RegistryDialog() {
215
235
  const componentSetup = <>
216
236
  <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
237
  <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>
238
+ <label>How it runs<select value={implementation} onChange={(event) => changeImplementation(event.currentTarget.value as ImplementationKind)}>{purpose === "ai" ? <option value="workers_ai">Workers AI</option> : <><option value="external_api">Connected API</option><option value="webhook">Connected webhook</option></>}</select><small>{needsConnection ? "Requests use a separately governed connection." : "Runs inside this Resolve deployment."}</small></label>
219
239
  <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
240
  {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
241
  <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>
242
+ {isAction ? <label className="wide-field"><span><input type="checkbox" checked={correctionEnabled} onChange={event => {
243
+ setCorrectionEnabled(event.currentTarget.checked);
244
+ if (event.currentTarget.checked && inputSchema === inputSchemaDefault && outputSchema === outputSchemaDefault) useCorrectionContract();
245
+ }} /> Use for appeal corrections</span><small>Only an explicitly approved appeal correction can use this capability to correct an earlier customer action.</small></label> : null}
246
+ {isAction && correctionEnabled ? <>
247
+ <label>Correction action code<input value={correctionCode} maxLength={100} placeholder="restore_message" onChange={event => setCorrectionCode(event.currentTarget.value)} /><small>The action code accepted by your customer endpoint.</small></label>
248
+ <label>Original action codes<input value={correctsCodes} maxLength={1000} placeholder="hide_message" onChange={event => setCorrectsCodes(event.currentTarget.value)} /><small>Comma-separated codes this capability is allowed to correct.</small></label>
249
+ <div className="wide-field"><p>The correction pins the original operation automatically. Reviewers provide the reason and any additional typed fields in your contract.</p><button type="button" className="secondary" onClick={useCorrectionContract}>Use standard correction contract</button><small>Replaces the input/output schemas and examples with the reason, original operation and signed receipt contract.</small></div>
250
+ </> : null}
222
251
  <label>Data classifications<input value={classifications} onChange={(event) => setClassifications(event.currentTarget.value)} /><small>Comma-separated labels used for governance.</small></label>
223
252
  <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
253
  </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>
254
+ <details className="registry-advanced"><summary>Advanced data contracts</summary><p>JSON schemas constrain what the workflow sends and what the tool may return. The example must match both schemas and is checked when the tool is created.</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">Example input<textarea className="code-input" spellCheck={false} value={fixtureInput} onChange={(event) => setFixtureInput(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><label className="wide-field">Example output<textarea className="code-input" spellCheck={false} value={fixtureOutput} onChange={(event) => setFixtureOutput(event.currentTarget.value)} /></label></div></details>
226
255
  </>;
227
256
 
228
257
  const connectionSecurity = <>
@@ -244,7 +273,7 @@ export function RegistryDialog() {
244
273
  {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
274
  {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
275
  {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}
276
+ {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>{kind === "component" && isAction && correctionEnabled ? <div><dt>Appeal correction</dt><dd>{correctionCode} corrects {commaValues(correctsCodes).join(", ")}</dd></div> : null}</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" ? isAction && correctionEnabled ? "Resolve publishes version 1 and checks its stored contract example. Independent appeal reviewers can then approve this correction for the original action types listed above." : "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}
248
277
  </div>
249
278
  <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>
250
279
  </form>
@@ -5,6 +5,7 @@ import { errorMessage, requestJson } from "../lib/http";
5
5
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
6
6
  import { openRegistryEditor, registryEvents } from "./events";
7
7
  import { RegistryDetailsDialog } from "./RegistryDetailsDialog";
8
+ import { IntegrationSetup } from "./IntegrationSetup";
8
9
  import type { ComponentListResponse, ComponentSummary, ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
9
10
 
10
11
  type RegistryItem = ComponentSummary | ConnectionSummary;
@@ -38,8 +39,10 @@ function RegistryRow({ item, kind, onOpen }: { item: RegistryItem; kind: Registr
38
39
  const category = component ? toolCategory(component) : "";
39
40
  const iconClass = connection ? "connection" : category === "action" ? "action" : category === "ai" ? "agent" : "";
40
41
  const icon = connection ? "↗" : category === "action" ? "⚡" : category === "ai" ? "✦" : category === "message" ? "✉" : "+";
41
- const status = lifecycleLabel(item.status || "active");
42
- const statusClass = item.status === "active" ? "available" : item.status === "archived" ? "archived" : "paused";
42
+ const status = connection && item.status === "active"
43
+ ? connection.healthState === "healthy" ? "Last call succeeded" : connection.healthState === "degraded" ? "Recent failures" : connection.healthState === "open_circuit" ? "Temporarily blocked" : connection.healthState === "paused" ? "Paused" : "Not verified yet"
44
+ : lifecycleLabel(item.status || "active");
45
+ const statusClass = item.status === "active" && (!connection || connection.healthState === "healthy") ? "available" : item.status === "archived" ? "archived" : "paused";
43
46
  const destination = connection?.baseUrl || connection?.serviceBindingName || "Destination not configured";
44
47
  return <article className="registry-resource-row">
45
48
  <span className={`object-icon ${iconClass}`} aria-hidden="true">{icon}</span>
@@ -109,12 +112,14 @@ export function RegistryWorkspace() {
109
112
  };
110
113
 
111
114
  return <div className="page-stack registry-workspace" data-react-slice="registry-workspace">
112
- <PageHeader titleId="components-title" title="Tools & connections" description="Control what workflows can do, which data they can use, and where requests may go.">
115
+ <PageHeader titleId="components-title" title="Connections" description="Connect your customer application and define what Safest can read, request, and confirm.">
113
116
  <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button>{canCreate ? <button className="primary" type="button" onClick={() => openRegistryEditor(currentKind)}>{tab === "tools" ? "Create tool" : "Add connection"}</button> : null}</div>
114
117
  </PageHeader>
115
118
 
116
- <section className="registry-model" aria-label="How workflows, tools, and connections work together">
117
- <div><span>1</span><strong>Workflow</strong><small>Chooses when work runs</small></div><b aria-hidden="true">→</b><div><span>2</span><strong>Tool</strong><small>Reads context or takes an action</small></div><b aria-hidden="true">→</b><div><span>3</span><strong>Connection</strong><small>Secures access to a service</small></div><b aria-hidden="true">→</b><div><span>4</span><strong>Your service</strong><small>Returns data or applies a change</small></div>
119
+ {canManage?<IntegrationSetup onTools={kind=>{setTab("tools");setFilter(kind);setSearch("");document.querySelector(".registry-tabs-row")?.scrollIntoView({block:"start",behavior:"smooth"});}} />:null}
120
+
121
+ <section className="registry-model" aria-label="How playbooks use customer capabilities">
122
+ <div><span>1</span><strong>Playbook</strong><small>Defines the permitted work</small></div><b aria-hidden="true">→</b><div><span>2</span><strong>Tool</strong><small>Reads context or takes an action</small></div><b aria-hidden="true">→</b><div><span>3</span><strong>Connection</strong><small>Secures access to a service</small></div><b aria-hidden="true">→</b><div><span>4</span><strong>Your service</strong><small>Returns data or applies a change</small></div>
118
123
  </section>
119
124
 
120
125
  <div className="registry-tabs-row"><div className="segmented-tabs" role="tablist" aria-label="Tools and connections"><button className={tab === "tools" ? "active" : ""} type="button" role="tab" aria-selected={tab === "tools"} onClick={() => { setTab("tools"); setSearch(""); }}>Tools <span>{components.filter((item) => item.status !== "archived").length}</span></button><button className={tab === "connections" ? "active" : ""} type="button" role="tab" aria-selected={tab === "connections"} onClick={() => { setTab("connections"); setSearch(""); }}>Connections <span>{connections.filter((item) => item.status !== "archived").length}</span></button></div><p>{tab === "tools" ? "Reusable capabilities placed in workflows. Enrichments are read-only; actions require approval." : "Reusable, allowlisted routes to APIs and Workers. Credential values stay in Worker secrets."}</p></div>
@@ -0,0 +1,25 @@
1
+ .integration-verification { min-width: 0; padding: 24px; }
2
+ .integration-verification > .surface-head { padding: 0 0 20px; margin-bottom: 20px; }
3
+ .verification-start { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }
4
+ .verification-start label, .verification-bind label, .verification-history { display: grid; gap: 8px; min-width: 0; font-weight: 600; font-size: .875rem; }
5
+ .verification-start select, .verification-bind select, .verification-history select { width: 100%; min-width: 0; min-height: 44px; font-size: 1rem; }
6
+ .verification-start .form-help { grid-column: 1 / -1; margin: 0; }
7
+ .verification-start button { justify-self: start; }
8
+ .verification-history, .verification-run { margin-top: 24px; }
9
+ .verification-run { border-top: 1px solid var(--border, #d8e0da); padding-top: 20px; }
10
+ .verification-summary h3, .verification-summary p { margin: 0 0 8px; overflow-wrap: anywhere; }
11
+ .verification-summary small { display: block; margin-bottom: 16px; }
12
+ .verification-state { display: inline-block; border: 1px solid currentColor; border-radius: 6px; padding: 4px 8px; margin: 4px 0 12px; }
13
+ .verification-state-verified { color: #235a43; }
14
+ .verification-state-blocked, .verification-state-stale { color: #8a391c; }
15
+ .verification-bind { display: grid; gap: 12px; margin: 16px 0; }
16
+ .verification-bind p { margin: 0; }
17
+ .verification-bind button { justify-self: start; }
18
+ .verification-evidence { list-style: none; padding: 0; margin: 20px 0; display: grid; gap: 12px; }
19
+ .verification-evidence li { border: 1px solid var(--border, #d8e0da); border-radius: 8px; padding: 14px; }
20
+ .verification-evidence li > div { display: flex; gap: 8px 16px; justify-content: space-between; flex-wrap: wrap; }
21
+ .verification-evidence li span { font-size: .875em; }
22
+ .verification-evidence li p { margin: 8px 0 0; overflow-wrap: anywhere; }
23
+ .verification-evidence li[data-state=complete] { border-left: 4px solid #377457; }
24
+ .verification-evidence li[data-state=blocked] { border-left: 4px solid #a64a25; }
25
+ @media(max-width: 600px) { .integration-verification { padding: 16px; } .integration-verification > .surface-head { align-items: stretch; flex-direction: column; gap: 16px; } .verification-start { grid-template-columns: minmax(0,1fr); } .verification-start button, .verification-bind button { width: 100%; } .verification-evidence li { padding: 12px; } }