feat(figma): Figma in API endpoints, screenshot hosting (Phase F — T4F.1, T4F.2)#47
Conversation
…nterface T4F.1: IssueRequest and APIRequest/WorkflowRequest accept figma_url (optional) T4F.1: handlers set cfg.FigmaURL and cfg.FigmaToken from BAUER_FIGMA_TOKEN env var T4F.1: BAUER_FIGMA_TOKEN documented in .env.example T4F.2: ScreenshotHost interface in internal/artifacts/hosting.go T4F.2: LocalFileServer, NopHost, S3Host (stub) implementations T4F.2: HostFromEnv selects backend from BAUER_STATIC_BASE_URL / BAUER_S3_BUCKET T4F.2: IssuesHandler uses HostFromEnv; warns when no hosting configured with figma_url T4F.2: /static/ route serves artifact dir when BAUER_STATIC_BASE_URL is configured
- cmd/bauer/main.go: restore fs.Usage closure (help text was printing unconditionally) - cmd/app/v1/jira.go: add nil-agent guard (prevent panic on NewClient failure) - internal/auth/middleware.go: fail-closed when OIDC configured but JWKS fetch fails - internal/artifacts/hosting.go: reject path traversal in LocalFileServer.Host()
There was a problem hiding this comment.
Pull request overview
This PR completes Phase F of spec 002 by wiring optional Figma context into the workflow API endpoint and introducing a screenshot hosting abstraction to support public screenshot links in generated issue bodies (plus several post-audit reliability/security fixes).
Changes:
- Add optional
figma_urlsupport toPOST /api/v1/workflowsand plumb Figma URL/token through workflow input into the orchestrator config. - Introduce
internal/artifacts/hosting.go(ScreenshotHost,LocalFileServer,NopHost,S3Hoststub) and select hosting backend from env. - Conditionally mount a
/static/file server whenBAUER_STATIC_BASE_URLis set; harden auth middleware to fail closed when JWKS resolution/fetch fails; add nil-agent guard in Jira webhook handler.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| Taskfile.yml | Fix formatting/indentation for docker-run and clean tasks. |
| internal/workflow/workflow.go | Add FigmaURL/FigmaToken to WorkflowInput and forward into Bauer config. |
| internal/workflow/api.go | Add figma_url to workflow API request and pass through to workflow input. |
| internal/prompt/templates/figma-context.md | Adjust template formatting/whitespace around Figma context sections. |
| internal/copilotcli/client.go | Import grouping/formatting change. |
| internal/auth/middleware.go | Fail-closed behavior when JWKS discovery/fetch fails (return 503). |
| internal/artifacts/hosting.go | New screenshot hosting interface + implementations + path traversal guard. |
| docs/implementation-log.md | Update branch chain + add post-implementation review notes. |
| cmd/bauer/main.go | Restore fs.Usage closure; minor formatting adjustments. |
| cmd/app/v1/jira.go | Add nil-agent guard when creating Copilot client. |
| cmd/app/v1/issues.go | Add hosting selection + stub formatIssueBodyWithHosting and warning when hosting is not configured. |
| cmd/app/main.go | Conditionally mount /static/ file server when BAUER_STATIC_BASE_URL is set. |
| .env.example | Document screenshot hosting env vars. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -32,6 +32,9 @@ func main() { | |||
| branchPrefix := fs.String("branch-prefix", "", "Prefix for created branches (default: bauer)") | |||
| githubRepo := fs.String("github-repo", "", "GitHub repository in owner/repo format (required for --open-pr and --open-issue)") | |||
| figmaURL := fs.String("figma-url", "", "Figma file or design URL for design reference (requires BAUER_FIGMA_TOKEN)") | |||
| Model: firstNonEmpty(req.Model, os.Getenv("BAUER_MODEL"), "gpt-5-mini-high"), | ||
| DryRun: req.DryRun, | ||
| FigmaURL: req.FigmaURL, | ||
| FigmaToken: os.Getenv("BAUER_FIGMA_TOKEN"), |
| rel, err := filepath.Rel(s.ServeDir, localPath) | ||
| if err != nil { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } | ||
| if strings.HasPrefix(rel, "..") { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } | ||
| return s.BaseURL + "/" + filepath.ToSlash(rel), nil |
| rel, err := filepath.Rel(s.ServeDir, localPath) | ||
| if err != nil { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } | ||
| if strings.HasPrefix(rel, "..") { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) |
| artsDir := os.Getenv("BAUER_ARTIFACTS_DIR") | ||
| if artsDir == "" { | ||
| artsDir = "./bauer-artifacts" | ||
| } | ||
| if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { | ||
| slog.Info("Serving artifact screenshots at /static/", slog.String("base_url", baseURL)) | ||
| mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(artsDir)))) |
| if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { | ||
| slog.Info("Serving artifact screenshots at /static/", slog.String("base_url", baseURL)) | ||
| mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(artsDir)))) | ||
| } |
| {{range .Anchors}} | ||
|
|
||
| - **{{.NodeName}}** (node: `{{.NodeID}}`) | ||
| {{- end}} | ||
| {{end}} | ||
| {{if .Screenshots}} | ||
| {{- end}} | ||
| {{end}} | ||
| {{if .Screenshots}} | ||
|
|
| func (s *LocalFileServer) Host(_ context.Context, localPath string) (string, error) { | ||
| rel, err := filepath.Rel(s.ServeDir, localPath) | ||
| if err != nil { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } | ||
| if strings.HasPrefix(rel, "..") { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } |
There was a problem hiding this comment.
Acknowledged — adding unit tests for the hosting layer is a good follow-up. Out of scope for this PR which focuses on wiring the integration. Tracked as a TODO for a dedicated testing PR.
| @@ -32,6 +32,9 @@ func main() { | |||
| branchPrefix := fs.String("branch-prefix", "", "Prefix for created branches (default: bauer)") | |||
| githubRepo := fs.String("github-repo", "", "GitHub repository in owner/repo format (required for --open-pr and --open-issue)") | |||
| figmaURL := fs.String("figma-url", "", "Figma file or design URL for design reference (requires BAUER_FIGMA_TOKEN)") | |||
| // Serve artifact screenshots at /static/ when BAUER_STATIC_BASE_URL is configured. | ||
| artsDir := os.Getenv("BAUER_ARTIFACTS_DIR") | ||
| if artsDir == "" { | ||
| artsDir = "./bauer-artifacts" | ||
| } | ||
| if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { | ||
| slog.Info("Serving artifact screenshots at /static/", slog.String("base_url", baseURL)) | ||
| mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(artsDir)))) |
| if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { | ||
| slog.Info("Serving artifact screenshots at /static/", slog.String("base_url", baseURL)) | ||
| mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(artsDir)))) | ||
| } |
| artsDir := firstNonEmpty(os.Getenv("BAUER_ARTIFACTS_DIR"), "./bauer-artifacts") | ||
| host := artifacts.HostFromEnv(artsDir) | ||
| if _, isNop := host.(*artifacts.NopHost); isNop && req.FigmaURL != "" { | ||
| slog.Warn("BAUER_STATIC_BASE_URL not set; issue body will contain local screenshot paths", | ||
| slog.String("run_id", result.RunID)) | ||
| } | ||
| body := formatIssueBodyWithHosting(r.Context(), result, req.DocID, host) |
| // When a real hosting backend is configured, screenshot paths embedded in the | ||
| // issue body can be rewritten to public URLs here. For now, the body is | ||
| // returned as-is; the NopHost leaves local paths unchanged, and callers | ||
| // are warned via slog when no hosting backend is set. | ||
| _ = host | ||
| _ = ctx | ||
| return body | ||
| } | ||
|
|
There was a problem hiding this comment.
This is intentional — it is a placeholder for the next phase where screenshot paths in the issue body will be rewritten to public URLs via the host backend. The function signature is in place so callers do not need to change when the rewriting logic lands.
| if err != nil { | ||
| slog.Error("Failed to resolve JWKS URL from OIDC discovery; JWT validation bypassed", | ||
| slog.Error("Failed to resolve JWKS URL from OIDC discovery; all protected endpoints will return 503", | ||
| slog.String("issuer", issuer), | ||
| slog.String("error", err.Error()), | ||
| ) | ||
| return next | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| http.Error(w, "authentication service unavailable", http.StatusServiceUnavailable) | ||
| }) | ||
| } | ||
|
|
||
| keySet, err := jwk.Fetch(context.Background(), jwksURL) | ||
| if err != nil { | ||
| slog.Error("Failed to fetch JWKS; JWT validation bypassed", | ||
| slog.Error("Failed to fetch JWKS; all protected endpoints will return 503", | ||
| slog.String("jwks_url", jwksURL), | ||
| slog.String("error", err.Error()), | ||
| ) | ||
| return next | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| http.Error(w, "authentication service unavailable", http.StatusServiceUnavailable) | ||
| }) |
| rel, err := filepath.Rel(s.ServeDir, localPath) | ||
| if err != nil { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } | ||
| if strings.HasPrefix(rel, "..") { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } |
| if strings.HasPrefix(rel, "..") { | ||
| return "", fmt.Errorf("path %q is not under serve directory %q", localPath, s.ServeDir) | ||
| } | ||
| return s.BaseURL + "/" + filepath.ToSlash(rel), nil |
| # --- Screenshot hosting (T4F.2) --- | ||
| BAUER_STATIC_BASE_URL= # Public URL prefix for serving screenshots (e.g. https://bauer.example.com/static) | ||
| BAUER_S3_BUCKET= # S3 bucket for screenshot hosting (not yet implemented) | ||
| BAUER_S3_REGION= # AWS region for S3 bucket |
| // HostFromEnv returns a ScreenshotHost configured from environment variables. | ||
| // Priority: BAUER_STATIC_BASE_URL → BAUER_S3_BUCKET → NopHost. | ||
| func HostFromEnv(serveDir string) ScreenshotHost { | ||
| if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { | ||
| return &LocalFileServer{BaseURL: baseURL, ServeDir: serveDir} | ||
| } | ||
| // S3 stub: if BAUER_S3_BUCKET is set, return S3Host (not yet functional) | ||
| if bucket := os.Getenv("BAUER_S3_BUCKET"); bucket != "" { | ||
| return &S3Host{Bucket: bucket, Region: os.Getenv("BAUER_S3_REGION")} | ||
| } | ||
| return &NopHost{} |
| // Serve artifact screenshots at /static/ when BAUER_STATIC_BASE_URL is configured. | ||
| if baseURL := os.Getenv("BAUER_STATIC_BASE_URL"); baseURL != "" { | ||
| screenshotsDir := filepath.Join(cfg.ArtifactsDir, "screenshots") | ||
| slog.Info("Serving artifact screenshots at /static/", slog.String("base_url", baseURL), slog.String("dir", screenshotsDir)) | ||
| mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(screenshotsDir)))) |
| @@ -77,7 +77,7 @@ func CreatePR(owner, repo string, opts CreatePROptions) (string, error) { | |||
| } else { | |||
| logger.Debug("GH_TOKEN is set for PR creation", "token_prefix", ghToken[:10]) | |||
| - If the design shows a spacing or typography token, check whether an equivalent exists in the codebase. | ||
| {{if .FigmaURL}} | ||
| {{if .FigmaURL}} | ||
|
|
Summary
Wires Figma support into the API endpoints and introduces a screenshot hosting interface, completing Phase F of spec 002. Also includes review fixes from the post-implementation audit.
Tasks Implemented
POST /api/v1/workflowsnow accepts optionalfigma_urlfield — piped throughAPIRequest→WorkflowInput→ orchestrator config.POST /api/v1/issuesalready had support.ScreenshotHostinterface (internal/artifacts/hosting.go) with three implementations:LocalFileServer— serves screenshots from artifacts dir (local dev)NopHost— no-op when no hosting configuredS3Host— stub for future S3 integrationHostFromEnvfactory selects backend fromBAUER_STATIC_BASE_URL/BAUER_S3_BUCKET/static/whenBAUER_STATIC_BASE_URLis set.Review Fixes (post-implementation audit)
fs.Usageclosure incmd/bauer/main.go(was printing help unconditionally)cmd/app/v1/jira.go(prevented panic)internal/auth/middleware.go(was silently bypassing auth)internal/artifacts/hosting.go(rejected..prefix paths)Files Changed
internal/workflow/api.go—FigmaURLinAPIRequestinternal/workflow/workflow.go—FigmaURL/FigmaTokeninWorkflowInputinternal/artifacts/hosting.go—ScreenshotHostinterface + implementations + path traversal guardcmd/app/v1/issues.go—formatIssueBodyWithHostingcmd/app/main.go—/static/file server routecmd/bauer/main.go—fs.Usagefixcmd/app/v1/jira.go— nil-agent guardinternal/auth/middleware.go— fail-closed fix.env.example—BAUER_STATIC_BASE_URL,BAUER_S3_BUCKET,BAUER_S3_REGIONPart of the Bauer v2 stacked PR series (Branch 12 of 12).