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:])
|
return a.quorum(ctx, args[1:])
|
||||||
case "ivc":
|
case "ivc":
|
||||||
return a.ivc(ctx, args[1:])
|
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":
|
case "connectors":
|
||||||
return a.connectors(ctx, args[1:])
|
return a.connectors(ctx, args[1:])
|
||||||
case "local-vault":
|
case "local-vault":
|
||||||
|
|||||||
Reference in New Issue
Block a user