diff --git a/client.go b/client.go index 2797412..5d01bed 100644 --- a/client.go +++ b/client.go @@ -396,6 +396,34 @@ func (c *Client) SubmitLocalVaultForkEvidence(ctx context.Context, installationI return c.postObject(ctx, "/v2/local-vault/installations/"+url.PathEscape(installationID)+"/witness/checkpoints", M{"forkEvidence": forkEvidence}, idempotency(options)) } +func (c *Client) GetMarketplaceItem(ctx context.Context, slug string) (M, error) { + return c.getObject(ctx, "/v1/marketplace/items/"+url.PathEscape(slug), nil) +} + +func (c *Client) SubmitMarketplaceAsset(ctx context.Context, input MarketplaceAssetSubmitInput, options ...RequestOptions) (M, error) { + return c.postObject(ctx, "/v1/marketplace/publisher/assets", M{ + "manifest": input.Manifest, + "sourceRef": input.SourceRef, + "visibility": input.Visibility, + "pricingModel": input.PricingModel, + "priceCents": input.PriceCents, + }, idempotency(options)) +} + +func (c *Client) ListMarketplaceReviewAssets(ctx context.Context, state string) ([]M, error) { + values := url.Values{} + if state != "" { + values.Set("state", state) + } + var out []M + err := c.requestJSON(ctx, http.MethodGet, "/v1/platform/marketplace/assets", values, nil, "", &out) + return out, err +} + +func (c *Client) ApproveMarketplaceAsset(ctx context.Context, slug string, reason string, options ...RequestOptions) (M, error) { + return c.postObject(ctx, "/v1/platform/marketplace/assets/"+url.PathEscape(slug)+"/approve", M{"reason": reason}, idempotency(options)) +} + func (c *Client) getObject(ctx context.Context, path string, values url.Values) (M, error) { var out M err := c.requestJSON(ctx, http.MethodGet, path, values, nil, "", &out) diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index af0f271..44ee450 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -15,6 +15,7 @@ import ( "strings" attesto "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go" + "git.rotz.ai/rotzmediagroup/attesto-v1/sdk/go/connectorkit" ) const cliVersion = "0.2.0" @@ -133,6 +134,8 @@ func (a *app) dispatch(ctx context.Context, args []string) error { return a.connectors(ctx, args[1:]) case "local-vault": return a.localVault(ctx, args[1:]) + case "marketplace": + return a.marketplace(ctx, args[1:]) case "readiness": return a.readiness(args[1:]) default: @@ -937,6 +940,189 @@ func (a *app) localVaultWitness(ctx context.Context, args []string, fork bool) e return a.write(out) } +func (a *app) marketplace(ctx context.Context, args []string) error { + if len(args) == 0 { + return errors.New("marketplace subcommand required") + } + switch args[0] { + case "init": + return a.marketplaceInit(args[1:]) + case "validate": + return a.marketplaceValidate(args[1:]) + case "submit": + return a.marketplaceSubmit(ctx, args[1:]) + case "review-list": + return a.marketplaceReviewList(ctx, args[1:]) + case "publish": + return a.marketplacePublish(ctx, args[1:]) + default: + return fmt.Errorf("unknown marketplace subcommand: %s", args[0]) + } +} + +func (a *app) marketplaceInit(args []string) error { + fs := flag.NewFlagSet("marketplace init", flag.ContinueOnError) + fs.SetOutput(a.err) + output := fs.String("output", "", "manifest output path") + slug := fs.String("slug", "", "lower-kebab-case connector slug") + name := fs.String("name", "", "connector display name") + version := fs.String("version", "", "semantic version") + category := fs.String("category", "", "marketplace category") + summary := fs.String("summary", "", "short public summary") + description := fs.String("description", "", "public description") + publisherSlug := fs.String("publisher-slug", "", "publisher slug") + publisherName := fs.String("publisher-name", "", "publisher display name") + repositoryURL := fs.String("repository-url", "", "public release source URL") + docsURL := fs.String("docs-url", "", "public documentation URL") + capabilitiesRaw := fs.String("capabilities", "", "comma-separated capabilities") + if err := fs.Parse(args); err != nil { + return err + } + if *output == "" { + return errors.New("output is required") + } + manifest := connectorkit.Manifest{ + SchemaVersion: "1.0", + Slug: *slug, + Name: *name, + Version: *version, + AssetType: "connector", + Category: *category, + Summary: *summary, + Description: *description, + Publisher: map[string]string{ + "slug": *publisherSlug, + "name": *publisherName, + }, + Repository: map[string]string{"url": *repositoryURL}, + Documentation: map[string]string{ + "url": *docsURL, + }, + Capabilities: splitCSV(*capabilitiesRaw), + Evidence: map[string]bool{ + "offlineVerification": true, + "receipts": true, + "witnessCompatible": true, + }, + Security: map[string]bool{ + "dependencyScan": true, + "secretScan": true, + }, + SupportedLanguages: []string{"en", "nl", "de", "fr", "es", "pl", "it"}, + } + if len(manifest.Capabilities) == 0 { + return errors.New("at least one capability is required") + } + result := connectorkit.ValidateManifest(manifest) + if !result.OK { + return a.write(map[string]any{"ok": false, "findings": result.Findings}) + } + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(*output, append(raw, '\n'), 0o600); err != nil { + return err + } + return a.write(map[string]any{"ok": true, "manifest": *output, "evidenceScore": result.EvidenceScore, "tier": result.Tier}) +} + +func (a *app) marketplaceValidate(args []string) error { + fs := flag.NewFlagSet("marketplace validate", flag.ContinueOnError) + fs.SetOutput(a.err) + file := fs.String("manifest-file", "", "connector manifest JSON file") + if err := fs.Parse(args); err != nil { + return err + } + manifest, err := readConnectorManifest(*file) + if err != nil { + return err + } + result := connectorkit.ValidateManifest(manifest) + return a.write(map[string]any{"ok": result.OK, "evidenceScore": result.EvidenceScore, "tier": result.Tier, "findings": result.Findings}) +} + +func (a *app) marketplaceSubmit(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("marketplace submit", flag.ContinueOnError) + fs.SetOutput(a.err) + manifestFile := fs.String("manifest-file", "", "connector manifest JSON file") + sourceRef := fs.String("source-ref", "", "real connector release source URL") + visibility := fs.String("visibility", "private", "private or public") + pricingModel := fs.String("pricing-model", "free", "free or paid") + priceCents := fs.Int("price-cents", 0, "price in cents for paid assets") + if err := fs.Parse(args); err != nil { + return err + } + manifest, err := readConnectorManifest(*manifestFile) + if err != nil { + return err + } + result := connectorkit.ValidateManifest(manifest) + if !result.OK { + return fmt.Errorf("manifest validation failed: %d finding(s)", len(result.Findings)) + } + rawManifest, err := manifestToMap(manifest) + if err != nil { + return err + } + var price *int + if *pricingModel == "paid" { + price = priceCents + } + client, err := a.bearerClient() + if err != nil { + return err + } + out, err := client.SubmitMarketplaceAsset(ctx, attesto.MarketplaceAssetSubmitInput{ + Manifest: rawManifest, + SourceRef: *sourceRef, + Visibility: *visibility, + PricingModel: *pricingModel, + PriceCents: price, + }) + if err != nil { + return err + } + return a.write(out) +} + +func (a *app) marketplaceReviewList(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("marketplace review-list", flag.ContinueOnError) + fs.SetOutput(a.err) + state := fs.String("state", "pending", "pending, published, rejected, revoked, or all") + if err := fs.Parse(args); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + out, err := client.ListMarketplaceReviewAssets(ctx, *state) + if err != nil { + return err + } + return a.write(out) +} + +func (a *app) marketplacePublish(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("marketplace publish", flag.ContinueOnError) + fs.SetOutput(a.err) + slug := fs.String("slug", "", "marketplace asset slug") + reason := fs.String("reason", "", "review reason") + if err := fs.Parse(args); err != nil { + return err + } + client, err := a.bearerClient() + if err != nil { + return err + } + out, err := client.ApproveMarketplaceAsset(ctx, *slug, *reason) + if err != nil { + return err + } + return a.write(out) +} + func (a *app) readiness(args []string) error { if len(args) == 0 { return errors.New("readiness subcommand required") @@ -1081,6 +1267,35 @@ func readStringMap(path string) (map[string]string, error) { return out, json.Unmarshal(raw, &out) } +func readConnectorManifest(path string) (connectorkit.Manifest, error) { + raw, err := os.ReadFile(path) + if err != nil { + return connectorkit.Manifest{}, err + } + var manifest connectorkit.Manifest + return manifest, json.Unmarshal(raw, &manifest) +} + +func manifestToMap(manifest connectorkit.Manifest) (attesto.M, error) { + raw, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + var out attesto.M + return out, json.Unmarshal(raw, &out) +} + +func splitCSV(raw string) []string { + values := []string{} + for _, part := range strings.Split(raw, ",") { + value := strings.TrimSpace(part) + if value != "" { + values = append(values, value) + } + } + return values +} + func getProofObject(ctx context.Context, client *attesto.Client, kind attesto.VerifyKind, id string, _ string) (attesto.M, error) { switch kind { case attesto.VerifyWindow: diff --git a/cmd/attesto/main_test.go b/cmd/attesto/main_test.go index 9d7d1f3..e81e9ba 100644 --- a/cmd/attesto/main_test.go +++ b/cmd/attesto/main_test.go @@ -98,6 +98,121 @@ func TestStreamsCreateCallsAPI(t *testing.T) { } } +func TestMarketplaceInitAndValidate(t *testing.T) { + dir := t.TempDir() + manifestFile := filepath.Join(dir, "attesto.connector.json") + + var stdout, stderr bytes.Buffer + code := run([]string{ + "--json", + "marketplace", + "init", + "--output", manifestFile, + "--slug", "acme-risk-connector", + "--name", "ACME Risk Connector", + "--version", "1.0.0", + "--category", "ai-governance", + "--summary", "Produces Attesto evidence for ACME risk decisions.", + "--description", "Produces verifiable Proofstream events for ACME risk decisions.", + "--publisher-slug", "acme", + "--publisher-name", "ACME", + "--repository-url", "https://git.example.com/acme/risk-connector", + "--docs-url", "https://docs.example.com/acme/risk-connector", + "--capabilities", "proofstream,offline-verification", + }, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"evidenceScore": 95`) { + t.Fatalf("unexpected init output: %s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + code = run([]string{"--json", "marketplace", "validate", "--manifest-file", manifestFile}, &stdout, &stderr, testEnv(t, nil)) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"ok": true`) { + t.Fatalf("unexpected validate output: %s", stdout.String()) + } +} + +func TestMarketplaceSubmitAndPublishCallRealAPIs(t *testing.T) { + dir := t.TempDir() + manifestFile := filepath.Join(dir, "attesto.connector.json") + writeMarketplaceManifest(t, manifestFile) + + var submitCalled bool + var publishCalled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer tenant-or-platform-token" { + t.Fatalf("missing auth header") + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/marketplace/publisher/assets": + submitCalled = true + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("submit json: %v", err) + } + if body["sourceRef"] != "https://git.example.com/acme/risk-connector/releases/v1.0.0" { + t.Fatalf("unexpected sourceRef: %#v", body["sourceRef"]) + } + _, _ = w.Write([]byte(`{"asset":{"slug":"acme-risk-connector","name":"ACME Risk Connector"},"validation":{"ok":true},"evidence":{"action":"asset_validation_finished","receiptHash":"rh","payloadHash":"ph"}}`)) + case r.Method == http.MethodPost && r.URL.Path == "/v1/platform/marketplace/assets/acme-risk-connector/approve": + publishCalled = true + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("publish json: %v", err) + } + if !strings.Contains(body["reason"].(string), "validation passed") { + t.Fatalf("unexpected reason: %#v", body["reason"]) + } + _, _ = w.Write([]byte(`{"asset":{"slug":"acme-risk-connector","listingState":"published"},"evidence":{"action":"asset_published","receiptHash":"rh","payloadHash":"ph"}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + env := testEnv(t, map[string]string{"ATT_TOKEN": "tenant-or-platform-token"}) + var stdout, stderr bytes.Buffer + code := run([]string{ + "--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN", + "marketplace", "submit", + "--manifest-file", manifestFile, + "--source-ref", "https://git.example.com/acme/risk-connector/releases/v1.0.0", + "--visibility", "public", + "--pricing-model", "free", + }, &stdout, &stderr, env) + if code != 0 { + t.Fatalf("submit exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "asset_validation_finished") { + t.Fatalf("unexpected submit output: %s", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + code = run([]string{ + "--json", "--base-url", server.URL, "--token-env", "ATT_TOKEN", + "marketplace", "publish", + "--slug", "acme-risk-connector", + "--reason", "validation passed and release source reviewed", + }, &stdout, &stderr, env) + if code != 0 { + t.Fatalf("publish exit=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "asset_published") { + t.Fatalf("unexpected publish output: %s", stdout.String()) + } + if !submitCalled || !publishCalled { + t.Fatalf("expected submit and publish calls, submit=%v publish=%v", submitCalled, publishCalled) + } +} + func loadVector(t *testing.T) map[string]any { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "golden-vectors", "proofstream-v0.1-alpha", "one-stream-two-events.json")) @@ -111,6 +226,48 @@ func loadVector(t *testing.T) map[string]any { return out } +func writeMarketplaceManifest(t *testing.T, path string) { + t.Helper() + manifest := map[string]any{ + "schemaVersion": "1.0", + "slug": "acme-risk-connector", + "name": "ACME Risk Connector", + "version": "1.0.0", + "assetType": "connector", + "category": "ai-governance", + "summary": "Produces Attesto evidence for ACME risk decisions.", + "description": "Produces verifiable Proofstream events for ACME risk decisions.", + "publisher": map[string]any{ + "slug": "acme", + "name": "ACME", + }, + "repository": map[string]any{ + "url": "https://git.example.com/acme/risk-connector", + }, + "documentation": map[string]any{ + "url": "https://docs.example.com/acme/risk-connector", + }, + "capabilities": []string{"proofstream", "offline-verification"}, + "evidence": map[string]bool{ + "offlineVerification": true, + "receipts": true, + "witnessCompatible": true, + }, + "security": map[string]bool{ + "dependencyScan": true, + "secretScan": true, + }, + "supportedLanguages": []string{"en", "nl", "de", "fr", "es", "pl", "it"}, + } + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} + func testEnv(t *testing.T, values map[string]string) func(string) string { t.Helper() return func(key string) string { diff --git a/types.go b/types.go index 800110c..eeaa806 100644 --- a/types.go +++ b/types.go @@ -269,3 +269,15 @@ type LocalVaultInstallation struct { CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } + +type MarketplaceAssetSubmitInput struct { + Manifest M `json:"manifest"` + SourceRef string `json:"sourceRef"` + Visibility string `json:"visibility"` + PricingModel string `json:"pricingModel"` + PriceCents *int `json:"priceCents,omitempty"` +} + +type MarketplaceReviewInput struct { + Reason string `json:"reason"` +}