Add marketplace CLI publishing helpers

This commit is contained in:
Codex
2026-06-08 06:17:54 +02:00
parent b17c455df9
commit 2344a852b5
4 changed files with 412 additions and 0 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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"`
}