feat(D.5): attesto connector init — marketplace-ready scaffold
`attesto connector init <slug> [--name --category --dir]` generates attesto.connector.json (v2 manifest), webhook_handler.py wired to the P1.4 verify_webhook helper with its real signature, and a README with the submission flow; `--validate-only <dir>` re-runs the marketplace validator (the same connectorkit.ValidateManifest code) as a local pre-submission check. Honesty rule: a fresh scaffold cannot claim a green assurance canary, so runtime.canary ships as "pending" and the validator's single remaining finding IS the submission to-do list; the scaffold errors if its template ever drifts into any other finding. Verified end-to-end: generated stub accepts a genuinely signed webhook and rejects a forged signature against the published Python SDK; overwrite refusal tested; Go suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
248
cmd/attesto/connector_init.go
Normal file
248
cmd/attesto/connector_init.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
// [D.5] `attesto connector init <slug>` — 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: ./<slug>)")
|
||||
validateOnly := fs.String("validate-only", "", "validate an existing <dir>/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 <slug> [--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",
|
||||
})
|
||||
}
|
||||
49
cmd/attesto/connector_init_test.go
Normal file
49
cmd/attesto/connector_init_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user