diff --git a/cmd/attesto/connector_init.go b/cmd/attesto/connector_init.go new file mode 100644 index 0000000..8f66af9 --- /dev/null +++ b/cmd/attesto/connector_init.go @@ -0,0 +1,248 @@ +package main + +// [D.5] `attesto connector init ` — scaffold a marketplace-ready +// connector: a v2 manifest that passes connectorkit validation locally, a +// signed-webhook handler stub built on the P1.4 verification helper, and a +// README pointing at the submission flow. The local validation run is the +// same code the marketplace runs, so a green scaffold is a green +// pre-submission check. + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "go.attesto.eu/sdk/connectorkit" +) + +var connectorSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,95}$`) + +func connectorManifestTemplate(slug, name, category string) connectorkit.Manifest { + return connectorkit.Manifest{ + SchemaVersion: "attesto.connector.v2", + Slug: slug, + Name: name, + Version: "0.1.0", + AssetType: "connector", + Category: category, + Summary: fmt.Sprintf("Verify %s evidence into Attesto Proofstream.", name), + Description: fmt.Sprintf( + "Produces verifiable evidence for %s events through Attesto Proofstream.", name), + Publisher: map[string]string{"name": "CHANGE ME", "slug": "change-me"}, + Repository: map[string]string{"url": "https://example.com/CHANGE-ME/" + slug}, + Documentation: map[string]string{"url": "https://docs.attesto.eu/manuals/connectors.html"}, + Capabilities: []string{ + "proofstream", "signed-webhook", "offline-verification", + }, + Evidence: map[string]bool{ + "offlineVerification": true, + "receipts": true, + "witnessCompatible": true, + }, + Security: map[string]bool{ + "dependencyScan": true, + "secretScan": true, + "secretsServerSide": true, + }, + SupportedLanguages: []string{"en"}, + Provider: map[string]any{ + "id": slug, + "name": name, + "websiteUrl": "https://example.com", + }, + Auth: map[string]any{ + "mode": "signed-webhook", + "scopes": []string{"webhook:read"}, + }, + Sync: map[string]any{ + "modes": []string{"webhook"}, + "supportsReplay": true, + "rateLimitPolicy": "Provider webhook retries and Attesto idempotency keys", + }, + EventTypes: []string{slug + ".event"}, + SourceTime: map[string]any{ + "required": true, + "timezonePolicy": "source-timestamp-with-offset-required", + }, + ConfigSchema: map[string]any{ + "type": "object", + "required": []string{"resourceRef"}, + "properties": map[string]any{ + "resourceRef": map[string]any{"type": "string"}, + }, + }, + SecretSchema: map[string]any{ + "type": "object", + "required": []string{"webhookSecret"}, + "properties": map[string]any{ + "webhookSecret": map[string]any{"type": "string", "secret": true}, + }, + }, + Diagnostics: map[string]any{ + "providerAuthStatus": true, + "replayConflictCheck": true, + "revocationCheck": true, + "syncLag": true, + "testConnection": true, + }, + Runtime: map[string]any{ + "officialConnectorKit": true, + "sdkSurfaces": []string{"python", "typescript", "go", "cli"}, + "requiredMethods": []string{ + "metadata", "validateConfig", "testConnection", "sync", + "handleWebhook", "emitProofstreamEvent", "diagnostics", "revoke", + }, + // A scaffold cannot honestly claim a green assurance canary; this + // stays "pending" (the one expected validation finding) until the + // connector has real canary evidence. + "canary": map[string]any{ + "status": "pending", + "ref": "CHANGE ME: assurance canary evidence ref", + }, + }, + InstallRequirements: map[string]any{ + "tenantLoginRequired": true, + "entitlementRequired": true, + }, + Changelog: []map[string]any{ + {"version": "0.1.0", "changes": []string{"Initial scaffold."}}, + }, + } +} + +const webhookHandlerStub = `"""Signed-webhook handler stub for the %s connector. + +Verification uses the Attesto SDK's P1.4 helper — the same scheme the +platform signs with: HMAC-SHA256 over "{timestamp}.{body}" with a 300s +skew window and constant-time comparison. +""" +import os + +from attesto.webhooks import verify_webhook + + +def handle(headers: dict[str, str], body: bytes) -> dict: + if not verify_webhook( + body=body, + headers=headers, + secret=os.environ["WEBHOOK_SECRET"], + ): + raise PermissionError("invalid webhook signature or stale timestamp") + + # The payload is now authentic: turn it into a proofstream event here. + return {"ok": True} +` + +const connectorReadmeStub = `# %s + +Scaffolded by ` + "`attesto connector init`" + `. + +1. Edit ` + "`attesto.connector.json`" + ` (publisher, repository, provider, + event types — search for CHANGE ME). +2. Implement the runtime methods (see ` + "`webhook_handler.py`" + ` for the + signed-webhook entry point; verification is already wired). +3. Re-run the pre-submission check at any time: + + attesto connector init --validate-only %s + +4. Submit through the marketplace flow (docs.attesto.eu/manuals/connectors.html). +` + +func (a *app) connectorInit(args []string) error { + fs := flag.NewFlagSet("connector init", flag.ContinueOnError) + fs.SetOutput(a.err) + name := fs.String("name", "", "human-readable connector name (default: derived from slug)") + category := fs.String("category", "devops", "marketplace category") + dir := fs.String("dir", "", "output directory (default: ./)") + validateOnly := fs.String("validate-only", "", "validate an existing /attesto.connector.json and exit") + // Accept the slug positionally before flags: `connector init my-slug --category crm`. + slug := "" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + slug = args[0] + args = args[1:] + } + if err := fs.Parse(args); err != nil { + return err + } + + if *validateOnly != "" { + raw, err := os.ReadFile(filepath.Join(*validateOnly, "attesto.connector.json")) + if err != nil { + return err + } + var manifest connectorkit.Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + return err + } + result := connectorkit.ValidateManifest(manifest) + if err := a.write(result); err != nil { + return err + } + if !result.OK { + return errors.New("manifest validation failed") + } + return nil + } + + if slug == "" && fs.NArg() == 1 { + slug = fs.Arg(0) + } + if slug == "" { + return errors.New("usage: attesto connector init [--name ...] [--category ...]") + } + if !connectorSlugPattern.MatchString(slug) { + return fmt.Errorf("slug %q must match %s", slug, connectorSlugPattern) + } + connectorName := *name + if connectorName == "" { + connectorName = strings.Title(strings.ReplaceAll(slug, "-", " ")) //nolint:staticcheck + } + + manifest := connectorManifestTemplate(slug, connectorName, *category) + result := connectorkit.ValidateManifest(manifest) + // The only acceptable finding on a fresh scaffold is the pending canary — + // everything else must already satisfy the marketplace validator. + for _, finding := range result.Findings { + if finding.Code != "runtime.canary" { + return fmt.Errorf("internal error: scaffold template failed validation: %+v", result.Findings) + } + } + + outDir := *dir + if outDir == "" { + outDir = slug + } + if _, err := os.Stat(filepath.Join(outDir, "attesto.connector.json")); err == nil { + return fmt.Errorf("%s/attesto.connector.json already exists", outDir) + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(outDir, "attesto.connector.json"), append(raw, '\n'), 0o644); err != nil { + return err + } + handler := fmt.Sprintf(webhookHandlerStub, connectorName) + if err := os.WriteFile(filepath.Join(outDir, "webhook_handler.py"), []byte(handler), 0o644); err != nil { + return err + } + readme := fmt.Sprintf(connectorReadmeStub, connectorName, outDir) + if err := os.WriteFile(filepath.Join(outDir, "README.md"), []byte(readme), 0o644); err != nil { + return err + } + return a.write(map[string]any{ + "created": outDir, + "files": []string{"attesto.connector.json", "webhook_handler.py", "README.md"}, + "validation": result, + "nextSteps": "edit CHANGE ME fields, implement runtime methods, earn a green " + + "assurance canary, re-run with --validate-only until OK", + }) +} diff --git a/cmd/attesto/connector_init_test.go b/cmd/attesto/connector_init_test.go new file mode 100644 index 0000000..4612ccf --- /dev/null +++ b/cmd/attesto/connector_init_test.go @@ -0,0 +1,49 @@ +package main + +// [D.5] connector init scaffolds a manifest whose ONLY validation finding is +// the pending canary, and the generated webhook stub calls the real P1.4 +// helper with its actual signature. + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "go.attesto.eu/sdk/connectorkit" +) + +func TestConnectorInitScaffoldsValidManifest(t *testing.T) { + dir := filepath.Join(t.TempDir(), "my-crm") + a := &app{out: &bytes.Buffer{}, err: &bytes.Buffer{}} + if err := a.connectorInit([]string{"my-crm-evidence", "--category", "crm", "--dir", dir}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, "attesto.connector.json")) + if err != nil { + t.Fatal(err) + } + var manifest connectorkit.Manifest + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + result := connectorkit.ValidateManifest(manifest) + for _, finding := range result.Findings { + if finding.Code != "runtime.canary" { + t.Fatalf("unexpected finding: %+v", finding) + } + } + stub, err := os.ReadFile(filepath.Join(dir, "webhook_handler.py")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(stub), "from attesto.webhooks import verify_webhook") { + t.Fatal("stub does not use the P1.4 helper") + } + // re-running must refuse to overwrite + if err := a.connectorInit([]string{"my-crm-evidence", "--dir", dir}); err == nil { + t.Fatal("expected overwrite refusal") + } +} diff --git a/cmd/attesto/main.go b/cmd/attesto/main.go index b5d0ae9..758bf36 100644 --- a/cmd/attesto/main.go +++ b/cmd/attesto/main.go @@ -137,6 +137,12 @@ func (a *app) dispatch(ctx context.Context, args []string) error { return a.quorum(ctx, args[1:]) case "ivc": return a.ivc(ctx, args[1:]) + case "connector": + // [D.5] scaffold + local pre-submission validation + if len(args) > 1 && args[1] == "init" { + return a.connectorInit(args[2:]) + } + return errors.New("connector subcommand required (init)") case "connectors": return a.connectors(ctx, args[1:]) case "local-vault":