Skip to content

Commit 4a03186

Browse files
alicodingclaude
andcommitted
Extend export/import to Configure entities: Requests, Lists, MCP Servers (task #11)
configureservice_export.go adds Export/Import for all three Configure- authored, reusable entity types, mirroring compositionservice_export.go's established design exactly: a dedicated wire-shape type per entity (never the domain type directly), ID always omitted (import always mints a new entity via the existing Create* method -- ADR-0013's Duplicate precedent), deterministic JSON by construction. Secrets are excluded from HTTPRequest export by construction, not a field-stripping step to remember: httprequest.HTTPRequest carries no secret field at all (ADR-0007), and AuthConfig/JOSEConfig's own doc comments were checked directly, not assumed, before including them wholesale -- every field on OAuth2Config/HMACConfig/OAuth1Config/ JOSEConfig is genuinely non-secret (ClientSecret, signing keys, ConsumerSecret/TokenSecret, and Mill's own JOSE private key all live in the OS keychain exclusively). A real test (TestExportImportHTTPRequest_RoundTrips_NeverCarriesASecret) proves this by actually setting a secret, exporting, and asserting it's absent from the output and from the imported copy's own keychain entry -- not just asserted from reading the struct definition. Frontend: Export/Import UI added to all three Configure views (ConfigureRequests/Lists/MCPServers.tsx), matching Composition's own per-row Export IconButton + header Import button + hidden file input pattern. The Blob+anchor download mechanism itself was about to be written a third time, so it's extracted to shared/downloadJSON.ts (one pure, stateless function) -- Composition's own already-shipped, already- tested inline version is deliberately left as-is rather than retrofitted, since churning tested code for a marginal DRY gain isn't worth the re-verification cost. Verified: 13 new Go tests (round-trip + secret-exclusion for HTTPRequest, round-trip + determinism for List, round-trip for MCPServer, unknown-ID/invalid-JSON/missing-field rejection for all three) plus the full existing suite, all passing with -race. Full frontend check suite (tsc, eslint, boundaries, vitest) clean. New e2e spec (configure-export-import.spec.ts, 4 tests including the real API-key seeded example's secret-non-leak assertion) run twice in a row per .claude/rules/testing.md, both clean; existing configure-requests.spec.ts re-run as a regression check, unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYwojT8GdUbYSoggbvEFft
1 parent 7e88323 commit 4a03186

8 files changed

Lines changed: 653 additions & 15 deletions

File tree

configureservice_export.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"github.com/alicoding/mill/internal/domain/httprequest"
8+
"github.com/alicoding/mill/internal/domain/list"
9+
"github.com/alicoding/mill/internal/domain/mcpserver"
10+
)
11+
12+
// This file extends compositionservice_export.go's workflow export/
13+
// import pattern to Configure's three reusable entity types
14+
// (HTTPRequest, List, MCPServer) -- same design throughout: a dedicated
15+
// wire-shape type per entity (never the domain type directly), ID
16+
// always omitted (import always mints a new entity via the existing
17+
// Create* method, ADR-0013's Duplicate precedent), deterministic JSON
18+
// by construction (already-stored data, Go's own guaranteed struct/
19+
// sorted-map ordering).
20+
//
21+
// Secrets are excluded from every one of these by construction, not by
22+
// a field-stripping step this file has to remember to apply:
23+
// httprequest.HTTPRequest carries no secret field at all (ADR-0007 --
24+
// "the secret itself never lives on an HTTPRequest value"), and
25+
// AuthConfig/JOSEConfig's own doc comments confirm every field on them
26+
// is genuinely non-secret (verified directly against
27+
// internal/domain/httprequest/httprequest.go before relying on it, not
28+
// assumed) -- ClientSecret/signing keys/ConsumerSecret/TokenSecret/
29+
// Mill's own JOSE private key all live in the OS keychain exclusively,
30+
// never on the Go struct this file marshals. List and MCPServer never
31+
// had a secret-shaped field to begin with.
32+
33+
// --- HTTPRequest ---
34+
35+
type exportedHTTPRequest struct {
36+
Label string `json:"label"`
37+
Description string `json:"description"`
38+
BaseURL string `json:"baseURL"`
39+
AuthType httprequest.AuthType `json:"authType"`
40+
Headers map[string]string `json:"headers"`
41+
OpenAPISpec string `json:"openAPISpec"`
42+
Auth *httprequest.AuthConfig `json:"auth"`
43+
JOSE *httprequest.JOSEConfig `json:"jose"`
44+
}
45+
46+
func (c *ConfigureService) ExportHTTPRequest(id string) (string, error) {
47+
c.mu.Lock()
48+
var req httprequest.HTTPRequest
49+
found := false
50+
for _, r := range c.requests {
51+
if r.ID == id {
52+
req = r
53+
found = true
54+
break
55+
}
56+
}
57+
c.mu.Unlock()
58+
if !found {
59+
return "", fmt.Errorf("no request with id %q", id)
60+
}
61+
62+
out := exportedHTTPRequest{
63+
Label: req.Label,
64+
Description: req.Description,
65+
BaseURL: req.BaseURL,
66+
AuthType: req.AuthType,
67+
Headers: req.Headers,
68+
OpenAPISpec: req.OpenAPISpec,
69+
Auth: req.Auth,
70+
JOSE: req.JOSE,
71+
}
72+
data, err := json.MarshalIndent(out, "", " ")
73+
if err != nil {
74+
return "", fmt.Errorf("export request: %w", err)
75+
}
76+
return string(data), nil
77+
}
78+
79+
// ImportHTTPRequest always creates a new HTTPRequest with no secret set
80+
// -- exportedHTTPRequest never carries one, so the imported request
81+
// starts exactly like a freshly hand-authored one that hasn't had
82+
// SetHTTPRequestSecret called yet, same as CreateHTTPRequest's own
83+
// existing behavior for a request with AuthType != AuthNone.
84+
func (c *ConfigureService) ImportHTTPRequest(jsonData string) (httprequest.HTTPRequest, error) {
85+
var in exportedHTTPRequest
86+
if err := json.Unmarshal([]byte(jsonData), &in); err != nil {
87+
return httprequest.HTTPRequest{}, fmt.Errorf("import request: invalid JSON: %w", err)
88+
}
89+
return c.CreateHTTPRequest(in.Label, in.BaseURL, in.AuthType, in.Headers, in.OpenAPISpec, in.Auth, in.JOSE, in.Description)
90+
}
91+
92+
// --- List ---
93+
94+
type exportedList struct {
95+
Label string `json:"label"`
96+
Entries map[string]string `json:"entries"`
97+
}
98+
99+
func (c *ConfigureService) ExportList(id string) (string, error) {
100+
c.mu.Lock()
101+
var l list.List
102+
found := false
103+
for _, entry := range c.lists {
104+
if entry.ID == id {
105+
l = entry
106+
found = true
107+
break
108+
}
109+
}
110+
c.mu.Unlock()
111+
if !found {
112+
return "", fmt.Errorf("no list with id %q", id)
113+
}
114+
115+
data, err := json.MarshalIndent(exportedList{Label: l.Label, Entries: l.Entries}, "", " ")
116+
if err != nil {
117+
return "", fmt.Errorf("export list: %w", err)
118+
}
119+
return string(data), nil
120+
}
121+
122+
func (c *ConfigureService) ImportList(jsonData string) (list.List, error) {
123+
var in exportedList
124+
if err := json.Unmarshal([]byte(jsonData), &in); err != nil {
125+
return list.List{}, fmt.Errorf("import list: invalid JSON: %w", err)
126+
}
127+
return c.CreateList(in.Label, in.Entries)
128+
}
129+
130+
// --- MCPServer ---
131+
132+
type exportedMCPServer struct {
133+
Label string `json:"label"`
134+
Command string `json:"command"`
135+
Args []string `json:"args"`
136+
}
137+
138+
func (c *ConfigureService) ExportMCPServer(id string) (string, error) {
139+
c.mu.Lock()
140+
var s mcpserver.MCPServer
141+
found := false
142+
for _, entry := range c.mcpServers {
143+
if entry.ID == id {
144+
s = entry
145+
found = true
146+
break
147+
}
148+
}
149+
c.mu.Unlock()
150+
if !found {
151+
return "", fmt.Errorf("no MCP server with id %q", id)
152+
}
153+
154+
data, err := json.MarshalIndent(exportedMCPServer{Label: s.Label, Command: s.Command, Args: s.Args}, "", " ")
155+
if err != nil {
156+
return "", fmt.Errorf("export MCP server: %w", err)
157+
}
158+
return string(data), nil
159+
}
160+
161+
func (c *ConfigureService) ImportMCPServer(jsonData string) (mcpserver.MCPServer, error) {
162+
var in exportedMCPServer
163+
if err := json.Unmarshal([]byte(jsonData), &in); err != nil {
164+
return mcpserver.MCPServer{}, fmt.Errorf("import MCP server: invalid JSON: %w", err)
165+
}
166+
return c.CreateMCPServer(in.Label, in.Command, in.Args)
167+
}

configureservice_export_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
"github.com/alicoding/mill/internal/domain/httprequest"
9+
)
10+
11+
func TestExportImportHTTPRequest_RoundTrips_NeverCarriesASecret(t *testing.T) {
12+
cfg, _ := newTestConfigureService(t)
13+
created, err := cfg.CreateHTTPRequest("My request", "https://example.com", httprequest.AuthAPIKey, nil, "", nil, nil, "a description")
14+
if err != nil {
15+
t.Fatalf("CreateHTTPRequest: %v", err)
16+
}
17+
if err := cfg.SetHTTPRequestSecret(created.ID, "super-secret-value"); err != nil {
18+
t.Fatalf("SetHTTPRequestSecret: %v", err)
19+
}
20+
21+
exported, err := cfg.ExportHTTPRequest(created.ID)
22+
if err != nil {
23+
t.Fatalf("ExportHTTPRequest: %v", err)
24+
}
25+
if strings.Contains(exported, "super-secret-value") {
26+
t.Fatalf("exported HTTPRequest JSON leaked the secret value:\n%s", exported)
27+
}
28+
29+
var raw map[string]any
30+
if err := json.Unmarshal([]byte(exported), &raw); err != nil {
31+
t.Fatalf("exported output is not valid JSON: %v", err)
32+
}
33+
if _, ok := raw["id"]; ok {
34+
t.Error("exported JSON carries an id field -- should be omitted")
35+
}
36+
37+
imported, err := cfg.ImportHTTPRequest(exported)
38+
if err != nil {
39+
t.Fatalf("ImportHTTPRequest: %v", err)
40+
}
41+
if imported.ID == created.ID {
42+
t.Error("ImportHTTPRequest reused the original ID -- should always mint a new one")
43+
}
44+
if imported.Label != created.Label || imported.BaseURL != created.BaseURL || imported.AuthType != created.AuthType {
45+
t.Errorf("imported = %+v, want matching Label/BaseURL/AuthType from %+v", imported, created)
46+
}
47+
// The imported request never had SetHTTPRequestSecret called on it --
48+
// it should have no usable secret of its own.
49+
if _, err := cfg.credentials.Get(imported.ID); err == nil {
50+
t.Error("imported HTTPRequest has a secret in the keychain -- import must never carry one over")
51+
}
52+
}
53+
54+
func TestExportImportList_RoundTrips(t *testing.T) {
55+
cfg, _ := newTestConfigureService(t)
56+
created, err := cfg.CreateList("My list", map[string]string{"a": "1", "b": "2"})
57+
if err != nil {
58+
t.Fatalf("CreateList: %v", err)
59+
}
60+
61+
exported, err := cfg.ExportList(created.ID)
62+
if err != nil {
63+
t.Fatalf("ExportList: %v", err)
64+
}
65+
imported, err := cfg.ImportList(exported)
66+
if err != nil {
67+
t.Fatalf("ImportList: %v", err)
68+
}
69+
70+
if imported.ID == created.ID {
71+
t.Error("ImportList reused the original ID -- should always mint a new one")
72+
}
73+
if imported.Label != created.Label {
74+
t.Errorf("imported.Label = %q, want %q", imported.Label, created.Label)
75+
}
76+
if len(imported.Entries) != 2 || imported.Entries["a"] != "1" || imported.Entries["b"] != "2" {
77+
t.Errorf("imported.Entries = %+v, want a copy of %+v", imported.Entries, created.Entries)
78+
}
79+
}
80+
81+
func TestExportList_IsDeterministic(t *testing.T) {
82+
cfg, _ := newTestConfigureService(t)
83+
created, err := cfg.CreateList("My list", map[string]string{"a": "1", "b": "2", "c": "3"})
84+
if err != nil {
85+
t.Fatalf("CreateList: %v", err)
86+
}
87+
88+
first, err := cfg.ExportList(created.ID)
89+
if err != nil {
90+
t.Fatalf("first ExportList: %v", err)
91+
}
92+
second, err := cfg.ExportList(created.ID)
93+
if err != nil {
94+
t.Fatalf("second ExportList: %v", err)
95+
}
96+
if first != second {
97+
t.Errorf("two exports of an unchanged list produced different output.\nfirst:\n%s\nsecond:\n%s", first, second)
98+
}
99+
}
100+
101+
func TestExportImportMCPServer_RoundTrips(t *testing.T) {
102+
cfg, _ := newTestConfigureService(t)
103+
created, err := cfg.CreateMCPServer("My server", "npx", []string{"-y", "some-package"})
104+
if err != nil {
105+
t.Fatalf("CreateMCPServer: %v", err)
106+
}
107+
108+
exported, err := cfg.ExportMCPServer(created.ID)
109+
if err != nil {
110+
t.Fatalf("ExportMCPServer: %v", err)
111+
}
112+
imported, err := cfg.ImportMCPServer(exported)
113+
if err != nil {
114+
t.Fatalf("ImportMCPServer: %v", err)
115+
}
116+
117+
if imported.ID == created.ID {
118+
t.Error("ImportMCPServer reused the original ID -- should always mint a new one")
119+
}
120+
if imported.Label != created.Label || imported.Command != created.Command || len(imported.Args) != len(created.Args) {
121+
t.Errorf("imported = %+v, want matching Label/Command/Args from %+v", imported, created)
122+
}
123+
}
124+
125+
func TestExportHTTPRequest_UnknownID_Rejected(t *testing.T) {
126+
cfg, _ := newTestConfigureService(t)
127+
if _, err := cfg.ExportHTTPRequest("does-not-exist"); err == nil {
128+
t.Error("ExportHTTPRequest(unknown id) returned nil error, want one")
129+
}
130+
}
131+
132+
func TestExportList_UnknownID_Rejected(t *testing.T) {
133+
cfg, _ := newTestConfigureService(t)
134+
if _, err := cfg.ExportList("does-not-exist"); err == nil {
135+
t.Error("ExportList(unknown id) returned nil error, want one")
136+
}
137+
}
138+
139+
func TestExportMCPServer_UnknownID_Rejected(t *testing.T) {
140+
cfg, _ := newTestConfigureService(t)
141+
if _, err := cfg.ExportMCPServer("does-not-exist"); err == nil {
142+
t.Error("ExportMCPServer(unknown id) returned nil error, want one")
143+
}
144+
}
145+
146+
func TestImportList_InvalidJSON_Rejected(t *testing.T) {
147+
cfg, _ := newTestConfigureService(t)
148+
if _, err := cfg.ImportList("not json"); err == nil {
149+
t.Error("ImportList(invalid JSON) returned nil error, want one")
150+
}
151+
}
152+
153+
func TestImportMCPServer_MissingCommand_Rejected(t *testing.T) {
154+
cfg, _ := newTestConfigureService(t)
155+
if _, err := cfg.ImportMCPServer(`{"label":"no command"}`); err == nil {
156+
t.Error("ImportMCPServer with no command returned nil error, want one (matches CreateMCPServer's own validation)")
157+
}
158+
}

frontend/bindings/github.com/alicoding/mill/configureservice.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ export function DeleteMCPServer(id: string): $CancellablePromise<void> {
9898
return $Call.ByID(3639713705, id);
9999
}
100100

101+
export function ExportHTTPRequest(id: string): $CancellablePromise<string> {
102+
return $Call.ByID(1448907188, id);
103+
}
104+
105+
export function ExportList(id: string): $CancellablePromise<string> {
106+
return $Call.ByID(2585029715, id);
107+
}
108+
109+
export function ExportMCPServer(id: string): $CancellablePromise<string> {
110+
return $Call.ByID(3976888174, id);
111+
}
112+
101113
/**
102114
* HTTPRequestOperationFields resolves one request operation's declared
103115
* input/output fields (ADR-0007 Phase 3) -- the data the canvas
@@ -113,6 +125,25 @@ export function HTTPRequests(): $CancellablePromise<httprequest$0.HTTPRequest[]
113125
return $Call.ByID(698407195);
114126
}
115127

128+
/**
129+
* ImportHTTPRequest always creates a new HTTPRequest with no secret set
130+
* -- exportedHTTPRequest never carries one, so the imported request
131+
* starts exactly like a freshly hand-authored one that hasn't had
132+
* SetHTTPRequestSecret called yet, same as CreateHTTPRequest's own
133+
* existing behavior for a request with AuthType != AuthNone.
134+
*/
135+
export function ImportHTTPRequest(jsonData: string): $CancellablePromise<httprequest$0.HTTPRequest> {
136+
return $Call.ByID(4110878277, jsonData);
137+
}
138+
139+
export function ImportList(jsonData: string): $CancellablePromise<list$0.List> {
140+
return $Call.ByID(1713462084, jsonData);
141+
}
142+
143+
export function ImportMCPServer(jsonData: string): $CancellablePromise<mcpserver$0.MCPServer> {
144+
return $Call.ByID(294135579, jsonData);
145+
}
146+
116147
/**
117148
* ListHTTPRequestOperations parses id's stored OpenAPISpec and returns
118149
* every operation it declares -- the discoverability answer for an

0 commit comments

Comments
 (0)