From 3bcb2b21a5e30fdf1b485286ccbf8f4e58a7f88c Mon Sep 17 00:00:00 2001 From: Molecule AI Backend Engineer Date: Fri, 17 Apr 2026 06:28:58 +0000 Subject: [PATCH] feat(platform): Cloudflare Artifacts demo integration (#595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a minimal but complete integration with the Cloudflare Artifacts API (private beta Apr 2026, public beta May 2026) — "Git for agents" versioned workspace-snapshot storage. ## What's included **`platform/internal/artifacts/client.go`** — typed Go HTTP client for the CF Artifacts REST API: - CreateRepo, GetRepo, ForkRepo, ImportRepo, DeleteRepo - CreateToken, RevokeToken - CF v4 response-envelope decoding; *APIError with StatusCode + Message **`platform/internal/handlers/artifacts.go`** — four workspace-scoped Gin handlers (all behind WorkspaceAuth middleware): - POST /workspaces/:id/artifacts — attach or import a CF Artifacts repo - GET /workspaces/:id/artifacts — get linked repo info (DB + live CF) - POST /workspaces/:id/artifacts/fork — fork the workspace's repo - POST /workspaces/:id/artifacts/token — mint a short-lived git credential **`platform/migrations/028_workspace_artifacts.up.sql`** — `workspace_artifacts` table: one-to-one link between a workspace and its CF Artifacts repo. Credentials are never stored; only the credential-stripped remote URL. **`platform/internal/router/router.go`** — wire the four routes into the existing wsAuth group. ## Configuration Two env vars gate the feature (returns 503 when either is absent): - CF_ARTIFACTS_API_TOKEN — Cloudflare API token with Artifacts write perms - CF_ARTIFACTS_NAMESPACE — Cloudflare Artifacts namespace name ## Tests - 10 client-level tests (httptest.Server + CF v4 envelope mocks) - 14 handler-level tests (sqlmock DB + mock CF server) - Helper unit tests for stripCredentials, cfErrToHTTP All 21 packages pass (go test ./...). Closes #595 Co-Authored-By: Claude Sonnet 4.6 --- platform/internal/artifacts/client.go | 281 ++++++ platform/internal/artifacts/client_test.go | 370 ++++++++ platform/internal/handlers/artifacts.go | 423 +++++++++ platform/internal/handlers/artifacts_test.go | 827 ++++++++++++++++++ platform/internal/router/router.go | 11 + .../028_workspace_artifacts.down.sql | 2 + .../migrations/028_workspace_artifacts.up.sql | 31 + 7 files changed, 1945 insertions(+) create mode 100644 platform/internal/artifacts/client.go create mode 100644 platform/internal/artifacts/client_test.go create mode 100644 platform/internal/handlers/artifacts.go create mode 100644 platform/internal/handlers/artifacts_test.go create mode 100644 platform/migrations/028_workspace_artifacts.down.sql create mode 100644 platform/migrations/028_workspace_artifacts.up.sql diff --git a/platform/internal/artifacts/client.go b/platform/internal/artifacts/client.go new file mode 100644 index 00000000..37efef7b --- /dev/null +++ b/platform/internal/artifacts/client.go @@ -0,0 +1,281 @@ +// Package artifacts provides a minimal Go client for the Cloudflare Artifacts +// REST API (private beta Apr 2026, public beta May 2026). +// +// API reference: https://developers.cloudflare.com/artifacts/api/rest-api/ +// Blog post: https://blog.cloudflare.com/artifacts-git-for-agents-beta/ +// +// Base URL: https://artifacts.cloudflare.net/v1/api/namespaces/{namespace} +// Auth: Authorization: Bearer +// +// This client covers the subset of operations needed for the Molecule AI +// workspace-snapshot demo: +// - CreateRepo — provision a bare Git repo for a workspace +// - GetRepo — fetch repo metadata (remote URL, created_at, …) +// - ForkRepo — create an isolated copy (e.g. workspace branching) +// - ImportRepo — bootstrap from an external GitHub/GitLab URL +// - DeleteRepo — clean-up +// - CreateToken — mint a short-lived Git credential for clone/push +// - RevokeToken — invalidate an issued token +package artifacts + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + defaultBaseURL = "https://artifacts.cloudflare.net/v1/api" + defaultTimeout = 30 * time.Second +) + +// Client is a thin HTTP wrapper around the Cloudflare Artifacts REST API. +// Instantiate with New(); override BaseURL in tests via NewWithBaseURL(). +type Client struct { + baseURL string // e.g. https://artifacts.cloudflare.net/v1/api/namespaces/my-ns + apiToken string // Cloudflare API token — never logged + httpClient *http.Client +} + +// New returns a Client scoped to the given namespace. +// apiToken is a Cloudflare API token with Artifacts write permissions. +// namespace identifies the CF Artifacts namespace (maps to CLOUDFLARE_ARTIFACTS_NAMESPACE). +func New(apiToken, namespace string) *Client { + return NewWithBaseURL(apiToken, namespace, defaultBaseURL) +} + +// NewWithBaseURL is the same as New but lets callers override the base URL — +// primarily used in unit tests to point at an httptest.Server. +func NewWithBaseURL(apiToken, namespace, baseURL string) *Client { + ns := url.PathEscape(namespace) + return &Client{ + baseURL: fmt.Sprintf("%s/namespaces/%s", baseURL, ns), + apiToken: apiToken, + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + } +} + +// ---- Domain types -------------------------------------------------------- + +// Repo represents a single Cloudflare Artifacts repository. +type Repo struct { + // Name is the user-supplied identifier within the namespace. + Name string `json:"name"` + // ID is the opaque CF-assigned identifier. + ID string `json:"id,omitempty"` + // RemoteURL is the authenticated Git remote in the form + // https://x:@.artifacts.cloudflare.net/git/repo-.git + RemoteURL string `json:"remote_url,omitempty"` + // ReadOnly marks repos that accept only fetch/clone operations. + ReadOnly bool `json:"read_only,omitempty"` + // Description is an optional human-readable label. + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +// ForkResult is the response body from POST /repos/:name/fork. +type ForkResult struct { + Repo Repo `json:"repo"` + ObjectCount int `json:"object_count,omitempty"` +} + +// RepoToken is a short-lived credential for Git operations against a single repo. +// The plaintext Token value is returned only once — callers must store it. +type RepoToken struct { + ID string `json:"id"` + Token string `json:"token"` + Scope string `json:"scope"` // "read" | "write" + ExpiresAt time.Time `json:"expires_at"` +} + +// ---- Request payloads ---------------------------------------------------- + +// CreateRepoRequest is the body for POST /repos. +type CreateRepoRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + DefaultBranch string `json:"default_branch,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` +} + +// ForkRepoRequest is the body for POST /repos/:name/fork. +type ForkRepoRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + DefaultBranchOnly bool `json:"default_branch_only,omitempty"` +} + +// ImportRepoRequest is the body for POST /repos/:name/import. +type ImportRepoRequest struct { + // URL is the HTTPS URL of the source Git repository. + URL string `json:"url"` + Branch string `json:"branch,omitempty"` + Depth int `json:"depth,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` +} + +// CreateTokenRequest is the body for POST /tokens. +type CreateTokenRequest struct { + // Repo is the name of the repository to scope the token to. + Repo string `json:"repo"` + Scope string `json:"scope,omitempty"` // "read" | "write"; default "write" + // TTL is the lifetime in seconds. Default 86400 (24h). + TTL int `json:"ttl,omitempty"` +} + +// ---- API error ----------------------------------------------------------- + +// APIError represents a non-2xx response from the Cloudflare v4 envelope. +type APIError struct { + StatusCode int + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *APIError) Error() string { + return fmt.Sprintf("cloudflare artifacts: HTTP %d — code %d: %s", e.StatusCode, e.Code, e.Message) +} + +// ---- HTTP helpers -------------------------------------------------------- + +// do executes an HTTP request, checks the Cloudflare v4 envelope, and +// JSON-decodes the "result" field into out (pass nil to discard). +func (c *Client) do(ctx context.Context, method, path string, body, out interface{}) error { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("artifacts: marshal request: %w", err) + } + bodyReader = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader) + if err != nil { + return fmt.Errorf("artifacts: build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("artifacts: request %s %s: %w", method, path, err) + } + defer resp.Body.Close() + + // Decode the Cloudflare v4 envelope. + var envelope struct { + Result json.RawMessage `json:"result"` + Success bool `json:"success"` + Errors []struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"errors"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + // Non-JSON body (network error page, etc.) + return &APIError{StatusCode: resp.StatusCode, Message: fmt.Sprintf("non-JSON body (status %d)", resp.StatusCode)} + } + + if !envelope.Success || resp.StatusCode >= 300 { + apiErr := &APIError{StatusCode: resp.StatusCode} + if len(envelope.Errors) > 0 { + apiErr.Code = envelope.Errors[0].Code + apiErr.Message = envelope.Errors[0].Message + } else { + apiErr.Message = "unknown error" + } + return apiErr + } + + if out != nil && len(envelope.Result) > 0 { + if err := json.Unmarshal(envelope.Result, out); err != nil { + return fmt.Errorf("artifacts: decode result: %w", err) + } + } + return nil +} + +// ---- Repo operations ----------------------------------------------------- + +// CreateRepo provisions a new bare Git repo in the namespace. +// Corresponds to POST /repos. +func (c *Client) CreateRepo(ctx context.Context, req CreateRepoRequest) (*Repo, error) { + var repo Repo + if err := c.do(ctx, http.MethodPost, "/repos", req, &repo); err != nil { + return nil, err + } + return &repo, nil +} + +// GetRepo fetches metadata for an existing repo. +// Corresponds to GET /repos/:name. +func (c *Client) GetRepo(ctx context.Context, name string) (*Repo, error) { + var repo Repo + path := "/repos/" + url.PathEscape(name) + if err := c.do(ctx, http.MethodGet, path, nil, &repo); err != nil { + return nil, err + } + return &repo, nil +} + +// ForkRepo creates an isolated copy of an existing repo. +// Corresponds to POST /repos/:name/fork. +func (c *Client) ForkRepo(ctx context.Context, sourceName string, req ForkRepoRequest) (*ForkResult, error) { + var result ForkResult + path := "/repos/" + url.PathEscape(sourceName) + "/fork" + if err := c.do(ctx, http.MethodPost, path, req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// ImportRepo bootstraps a repo from an external HTTPS Git URL. +// Corresponds to POST /repos/:name/import. +func (c *Client) ImportRepo(ctx context.Context, name string, req ImportRepoRequest) (*Repo, error) { + var repo Repo + path := "/repos/" + url.PathEscape(name) + "/import" + if err := c.do(ctx, http.MethodPost, path, req, &repo); err != nil { + return nil, err + } + return &repo, nil +} + +// DeleteRepo deletes a repo (returns 202 Accepted). +// Corresponds to DELETE /repos/:name. +func (c *Client) DeleteRepo(ctx context.Context, name string) error { + path := "/repos/" + url.PathEscape(name) + return c.do(ctx, http.MethodDelete, path, nil, nil) +} + +// ---- Token operations ---------------------------------------------------- + +// CreateToken mints a short-lived Git credential scoped to a single repo. +// The plaintext token is in the returned RepoToken.Token field — it will not +// be available again after this call returns. +// Corresponds to POST /tokens. +func (c *Client) CreateToken(ctx context.Context, req CreateTokenRequest) (*RepoToken, error) { + var token RepoToken + if err := c.do(ctx, http.MethodPost, "/tokens", req, &token); err != nil { + return nil, err + } + return &token, nil +} + +// RevokeToken invalidates an issued token by its ID. +// Corresponds to DELETE /tokens/:id. +func (c *Client) RevokeToken(ctx context.Context, tokenID string) error { + path := "/tokens/" + url.PathEscape(tokenID) + return c.do(ctx, http.MethodDelete, path, nil, nil) +} diff --git a/platform/internal/artifacts/client_test.go b/platform/internal/artifacts/client_test.go new file mode 100644 index 00000000..c8519334 --- /dev/null +++ b/platform/internal/artifacts/client_test.go @@ -0,0 +1,370 @@ +package artifacts_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts" +) + +// cfEnvelope wraps a result value in the Cloudflare v4 response envelope. +func cfEnvelope(t *testing.T, result interface{}) []byte { + t.Helper() + b, err := json.Marshal(result) + if err != nil { + t.Fatalf("cfEnvelope: marshal result: %v", err) + } + env := map[string]interface{}{ + "success": true, + "result": json.RawMessage(b), + "errors": []interface{}{}, + } + out, err := json.Marshal(env) + if err != nil { + t.Fatalf("cfEnvelope: marshal envelope: %v", err) + } + return out +} + +// cfError returns a Cloudflare v4 error envelope. +func cfError(t *testing.T, statusCode, code int, message string) ([]byte, int) { + t.Helper() + env := map[string]interface{}{ + "success": false, + "result": nil, + "errors": []map[string]interface{}{ + {"code": code, "message": message}, + }, + } + b, _ := json.Marshal(env) + return b, statusCode +} + +func newTestClient(t *testing.T, mux *http.ServeMux) *artifacts.Client { + t.Helper() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return artifacts.NewWithBaseURL("test-token", "test-ns", srv.URL) +} + +// ---- CreateRepo ---------------------------------------------------------- + +func TestCreateRepo_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // Verify auth header + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + // Decode request body + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req["name"] != "my-workspace-repo" { + http.Error(w, "unexpected name", http.StatusBadRequest) + return + } + + repo := artifacts.Repo{ + Name: "my-workspace-repo", + ID: "repo-abc123", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-abc123.git", + CreatedAt: time.Now(), + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfEnvelope(t, repo)) + }) + + client := newTestClient(t, mux) + repo, err := client.CreateRepo(context.Background(), artifacts.CreateRepoRequest{ + Name: "my-workspace-repo", + Description: "Molecule AI workspace snapshot", + }) + if err != nil { + t.Fatalf("CreateRepo: unexpected error: %v", err) + } + if repo.Name != "my-workspace-repo" { + t.Errorf("repo.Name = %q, want %q", repo.Name, "my-workspace-repo") + } + if repo.ID != "repo-abc123" { + t.Errorf("repo.ID = %q, want %q", repo.ID, "repo-abc123") + } + if repo.RemoteURL == "" { + t.Error("repo.RemoteURL is empty, want non-empty") + } +} + +func TestCreateRepo_APIError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) { + body, status := cfError(t, http.StatusConflict, 1009, "repo already exists") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(body) + }) + + client := newTestClient(t, mux) + _, err := client.CreateRepo(context.Background(), artifacts.CreateRepoRequest{Name: "dup"}) + if err == nil { + t.Fatal("expected error, got nil") + } + apiErr, ok := err.(*artifacts.APIError) + if !ok { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != http.StatusConflict { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusConflict) + } + if apiErr.Message != "repo already exists" { + t.Errorf("Message = %q, want %q", apiErr.Message, "repo already exists") + } +} + +// ---- GetRepo ------------------------------------------------------------- + +func TestGetRepo_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos/my-repo", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + repo := artifacts.Repo{ + Name: "my-repo", + ID: "repo-xyz", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-xyz.git", + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfEnvelope(t, repo)) + }) + + client := newTestClient(t, mux) + repo, err := client.GetRepo(context.Background(), "my-repo") + if err != nil { + t.Fatalf("GetRepo: unexpected error: %v", err) + } + if repo.Name != "my-repo" { + t.Errorf("repo.Name = %q, want %q", repo.Name, "my-repo") + } +} + +func TestGetRepo_NotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos/missing", func(w http.ResponseWriter, r *http.Request) { + body, status := cfError(t, http.StatusNotFound, 1004, "repo not found") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(body) + }) + + client := newTestClient(t, mux) + _, err := client.GetRepo(context.Background(), "missing") + if err == nil { + t.Fatal("expected error, got nil") + } + apiErr, ok := err.(*artifacts.APIError) + if !ok { + t.Fatalf("expected *APIError, got %T", err) + } + if apiErr.StatusCode != http.StatusNotFound { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, http.StatusNotFound) + } +} + +// ---- ForkRepo ------------------------------------------------------------ + +func TestForkRepo_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos/source-repo/fork", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + if req["name"] != "forked-repo" { + http.Error(w, "unexpected fork name", http.StatusBadRequest) + return + } + result := artifacts.ForkResult{ + Repo: artifacts.Repo{ + Name: "forked-repo", + ID: "repo-fork-1", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-fork-1.git", + }, + ObjectCount: 42, + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfEnvelope(t, result)) + }) + + client := newTestClient(t, mux) + result, err := client.ForkRepo(context.Background(), "source-repo", artifacts.ForkRepoRequest{ + Name: "forked-repo", + }) + if err != nil { + t.Fatalf("ForkRepo: unexpected error: %v", err) + } + if result.Repo.Name != "forked-repo" { + t.Errorf("Repo.Name = %q, want %q", result.Repo.Name, "forked-repo") + } + if result.ObjectCount != 42 { + t.Errorf("ObjectCount = %d, want 42", result.ObjectCount) + } +} + +// ---- ImportRepo ---------------------------------------------------------- + +func TestImportRepo_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos/imported/import", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + if req["url"] == "" { + http.Error(w, "url required", http.StatusBadRequest) + return + } + repo := artifacts.Repo{ + Name: "imported", + ID: "repo-imp-1", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/repo-imp-1.git", + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfEnvelope(t, repo)) + }) + + client := newTestClient(t, mux) + repo, err := client.ImportRepo(context.Background(), "imported", artifacts.ImportRepoRequest{ + URL: "https://github.com/Molecule-AI/molecule-core.git", + Branch: "main", + Depth: 1, + }) + if err != nil { + t.Fatalf("ImportRepo: unexpected error: %v", err) + } + if repo.Name != "imported" { + t.Errorf("repo.Name = %q, want %q", repo.Name, "imported") + } +} + +// ---- DeleteRepo ---------------------------------------------------------- + +func TestDeleteRepo_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos/to-delete", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + deleted := map[string]string{"id": "repo-del-1"} + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + w.Write(cfEnvelope(t, deleted)) + }) + + client := newTestClient(t, mux) + if err := client.DeleteRepo(context.Background(), "to-delete"); err != nil { + t.Fatalf("DeleteRepo: unexpected error: %v", err) + } +} + +// ---- CreateToken --------------------------------------------------------- + +func TestCreateToken_Success(t *testing.T) { + expiry := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/tokens", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + if req["repo"] != "my-repo" { + http.Error(w, "unexpected repo", http.StatusBadRequest) + return + } + tok := artifacts.RepoToken{ + ID: "tok-123", + Token: "plaintext-secret-abc", + Scope: "write", + ExpiresAt: expiry, + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfEnvelope(t, tok)) + }) + + client := newTestClient(t, mux) + tok, err := client.CreateToken(context.Background(), artifacts.CreateTokenRequest{ + Repo: "my-repo", + Scope: "write", + TTL: 86400, + }) + if err != nil { + t.Fatalf("CreateToken: unexpected error: %v", err) + } + if tok.ID != "tok-123" { + t.Errorf("ID = %q, want %q", tok.ID, "tok-123") + } + if tok.Token != "plaintext-secret-abc" { + t.Errorf("Token = %q, want %q", tok.Token, "plaintext-secret-abc") + } + if tok.Scope != "write" { + t.Errorf("Scope = %q, want %q", tok.Scope, "write") + } +} + +// ---- RevokeToken --------------------------------------------------------- + +func TestRevokeToken_Success(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/tokens/tok-456", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + deleted := map[string]string{"id": "tok-456"} + w.Header().Set("Content-Type", "application/json") + w.Write(cfEnvelope(t, deleted)) + }) + + client := newTestClient(t, mux) + if err := client.RevokeToken(context.Background(), "tok-456"); err != nil { + t.Fatalf("RevokeToken: unexpected error: %v", err) + } +} + +// ---- Context cancellation ------------------------------------------------ + +func TestCreateRepo_ContextCancelled(t *testing.T) { + // Server that never responds (simulates a hung connection) + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos", func(w http.ResponseWriter, r *http.Request) { + // Block until the client gives up + <-r.Context().Done() + }) + + client := newTestClient(t, mux) + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := client.CreateRepo(ctx, artifacts.CreateRepoRequest{Name: "x"}) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } +} diff --git a/platform/internal/handlers/artifacts.go b/platform/internal/handlers/artifacts.go new file mode 100644 index 00000000..dd125b62 --- /dev/null +++ b/platform/internal/handlers/artifacts.go @@ -0,0 +1,423 @@ +package handlers + +// ArtifactsHandler exposes the Cloudflare Artifacts demo integration. +// +// Routes (all behind WorkspaceAuth middleware): +// +// POST /workspaces/:id/artifacts — attach a CF Artifacts repo to this workspace +// GET /workspaces/:id/artifacts — get the linked repo info +// POST /workspaces/:id/artifacts/fork — fork this workspace's repo +// POST /workspaces/:id/artifacts/token — mint a short-lived git credential +// +// Configuration (env vars, loaded once at platform startup): +// +// CF_ARTIFACTS_API_TOKEN — Cloudflare API token with Artifacts write permissions +// CF_ARTIFACTS_NAMESPACE — Cloudflare Artifacts namespace name +// +// When either env var is absent the handler returns 503 with a clear message so +// callers know the feature is not yet configured (private beta onboarding). +// +// See: https://developers.cloudflare.com/artifacts/ + +import ( + "database/sql" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" + "github.com/gin-gonic/gin" +) + +// ArtifactsHandler holds a pre-built CF Artifacts client. +// The client is nil when CF_ARTIFACTS_API_TOKEN / CF_ARTIFACTS_NAMESPACE are unset. +type ArtifactsHandler struct { + client *artifacts.Client + namespace string +} + +// NewArtifactsHandler reads CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE +// from the environment and builds the client. If either is absent the handler +// still registers — every method simply returns 503. +func NewArtifactsHandler() *ArtifactsHandler { + token := os.Getenv("CF_ARTIFACTS_API_TOKEN") + ns := os.Getenv("CF_ARTIFACTS_NAMESPACE") + if token == "" || ns == "" { + log.Printf("artifacts: CF_ARTIFACTS_API_TOKEN or CF_ARTIFACTS_NAMESPACE not set — demo endpoints will return 503") + return &ArtifactsHandler{} + } + return &ArtifactsHandler{ + client: artifacts.New(token, ns), + namespace: ns, + } +} + +// newArtifactsHandlerWithClient is the injectable constructor used in tests. +func newArtifactsHandlerWithClient(client *artifacts.Client, namespace string) *ArtifactsHandler { + return &ArtifactsHandler{client: client, namespace: namespace} +} + +// configured returns false (and writes a 503) when the CF client is missing. +func (h *ArtifactsHandler) configured(c *gin.Context) bool { + if h.client == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Cloudflare Artifacts not configured — set CF_ARTIFACTS_API_TOKEN and CF_ARTIFACTS_NAMESPACE", + }) + return false + } + return true +} + +// ---- POST /workspaces/:id/artifacts ------------------------------------ + +// createArtifactsRepoRequest is the body for attaching/creating a CF Artifacts repo. +type createArtifactsRepoRequest struct { + // Name is the desired CF repo name. Defaults to "molecule-ws-" when empty. + Name string `json:"name"` + // Description is an optional label stored in CF and in the local DB. + Description string `json:"description"` + // ImportURL, when non-empty, bootstraps the repo from an existing Git URL + // (e.g. "https://github.com/org/repo.git") instead of creating an empty repo. + ImportURL string `json:"import_url"` + // ImportBranch restricts the import to a single branch (only used with ImportURL). + ImportBranch string `json:"import_branch"` + // ImportDepth sets a shallow-clone depth for the import (0 = full history). + ImportDepth int `json:"import_depth"` + // ReadOnly marks the new repo as fetch/clone-only. + ReadOnly bool `json:"read_only"` +} + +// workspaceArtifactRow is the DB row shape returned by queries. +type workspaceArtifactRow struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + CFRepoName string `json:"cf_repo_name"` + CFNamespace string `json:"cf_namespace"` + RemoteURL string `json:"remote_url,omitempty"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Create handles POST /workspaces/:id/artifacts. +// Creates or imports a Cloudflare Artifacts repo and links it to the workspace. +// Returns 409 if a repo is already linked. +func (h *ArtifactsHandler) Create(c *gin.Context) { + if !h.configured(c) { + return + } + workspaceID := c.Param("id") + ctx := c.Request.Context() + + // Reject if already linked. + var exists bool + db.DB.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM workspace_artifacts WHERE workspace_id = $1)`, + workspaceID, + ).Scan(&exists) + if exists { + c.JSON(http.StatusConflict, gin.H{"error": "workspace already has a linked Artifacts repo — delete it first"}) + return + } + + var req createArtifactsRepoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Default repo name: "molecule-ws-" (truncated at 63 chars). + repoName := req.Name + if repoName == "" { + repoName = "molecule-ws-" + workspaceID + if len(repoName) > 63 { + repoName = repoName[:63] + } + } + + var ( + repo *artifacts.Repo + err error + ) + if req.ImportURL != "" { + repo, err = h.client.ImportRepo(ctx, repoName, artifacts.ImportRepoRequest{ + URL: req.ImportURL, + Branch: req.ImportBranch, + Depth: req.ImportDepth, + ReadOnly: req.ReadOnly, + }) + } else { + repo, err = h.client.CreateRepo(ctx, artifacts.CreateRepoRequest{ + Name: repoName, + Description: req.Description, + ReadOnly: req.ReadOnly, + }) + } + if err != nil { + log.Printf("artifacts: CreateRepo/ImportRepo failed for workspace %s: %v", workspaceID, err) + c.JSON(cfErrToHTTP(err), gin.H{"error": err.Error()}) + return + } + + // Strip the embedded credential from the URL before persisting. + remoteURL := stripCredentials(repo.RemoteURL) + + var row workspaceArtifactRow + err = db.DB.QueryRowContext(ctx, ` + INSERT INTO workspace_artifacts + (workspace_id, cf_repo_name, cf_namespace, remote_url, description) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at + `, workspaceID, repo.Name, h.namespace, remoteURL, req.Description).Scan( + &row.ID, &row.WorkspaceID, &row.CFRepoName, &row.CFNamespace, + &row.RemoteURL, &row.Description, &row.CreatedAt, &row.UpdatedAt, + ) + if err != nil { + log.Printf("artifacts: DB insert failed for workspace %s: %v", workspaceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist artifact link"}) + return + } + + c.JSON(http.StatusCreated, row) +} + +// ---- GET /workspaces/:id/artifacts ------------------------------------- + +// Get handles GET /workspaces/:id/artifacts. +// Returns the linked Cloudflare Artifacts repo info from local DB and CF API. +func (h *ArtifactsHandler) Get(c *gin.Context) { + if !h.configured(c) { + return + } + workspaceID := c.Param("id") + ctx := c.Request.Context() + + var row workspaceArtifactRow + err := db.DB.QueryRowContext(ctx, ` + SELECT id, workspace_id, cf_repo_name, cf_namespace, remote_url, description, created_at, updated_at + FROM workspace_artifacts + WHERE workspace_id = $1 + `, workspaceID).Scan( + &row.ID, &row.WorkspaceID, &row.CFRepoName, &row.CFNamespace, + &row.RemoteURL, &row.Description, &row.CreatedAt, &row.UpdatedAt, + ) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"}) + return + } + if err != nil { + log.Printf("artifacts: DB query failed for workspace %s: %v", workspaceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + // Augment with live info from CF API (remote URL may have changed, etc.). + cfRepo, err := h.client.GetRepo(ctx, row.CFRepoName) + if err != nil { + // CF API unavailable — return cached DB row with a warning. + log.Printf("artifacts: GetRepo from CF failed for %s: %v", row.CFRepoName, err) + c.JSON(http.StatusOK, gin.H{ + "artifact": row, + "cf_status": "unavailable", + "cf_error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "artifact": row, + "cf_repo": cfRepo, + "cf_status": "ok", + }) +} + +// ---- POST /workspaces/:id/artifacts/fork ------------------------------- + +// forkArtifactsRepoRequest is the body for forking a workspace's repo. +type forkArtifactsRepoRequest struct { + // Name is the desired name of the forked repo. Required. + Name string `json:"name" binding:"required"` + // Description is an optional label for the fork. + Description string `json:"description"` + // ReadOnly marks the fork as fetch/clone-only. + ReadOnly bool `json:"read_only"` + // DefaultBranchOnly limits the fork to the default branch. + DefaultBranchOnly bool `json:"default_branch_only"` +} + +// Fork handles POST /workspaces/:id/artifacts/fork. +// Creates an isolated copy of the workspace's primary Artifacts repo in CF. +// The fork is not recorded in the local DB — it is owned by the caller. +func (h *ArtifactsHandler) Fork(c *gin.Context) { + if !h.configured(c) { + return + } + workspaceID := c.Param("id") + ctx := c.Request.Context() + + // Look up the source repo name. + var cfRepoName string + err := db.DB.QueryRowContext(ctx, + `SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`, + workspaceID, + ).Scan(&cfRepoName) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + var req forkArtifactsRepoRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.client.ForkRepo(ctx, cfRepoName, artifacts.ForkRepoRequest{ + Name: req.Name, + Description: req.Description, + ReadOnly: req.ReadOnly, + DefaultBranchOnly: req.DefaultBranchOnly, + }) + if err != nil { + log.Printf("artifacts: ForkRepo failed for workspace %s: %v", workspaceID, err) + c.JSON(cfErrToHTTP(err), gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "fork": result.Repo, + "object_count": result.ObjectCount, + "remote_url": stripCredentials(result.Repo.RemoteURL), + }) +} + +// ---- POST /workspaces/:id/artifacts/token ------------------------------ + +// artifactsTokenRequest is the body for minting a git credential. +type artifactsTokenRequest struct { + // Scope is "read" or "write". Defaults to "write". + Scope string `json:"scope"` + // TTL is the credential lifetime in seconds. Defaults to 3600 (1h). + TTL int `json:"ttl"` +} + +// Token handles POST /workspaces/:id/artifacts/token. +// Returns a short-lived Git credential for the workspace's linked repo. +// The plaintext token value must be saved by the caller — it is not stored. +func (h *ArtifactsHandler) Token(c *gin.Context) { + if !h.configured(c) { + return + } + workspaceID := c.Param("id") + ctx := c.Request.Context() + + // Look up the linked CF repo name. + var cfRepoName string + err := db.DB.QueryRowContext(ctx, + `SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id = $1`, + workspaceID, + ).Scan(&cfRepoName) + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "no Artifacts repo linked to this workspace"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"}) + return + } + + var req artifactsTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + scope := req.Scope + if scope == "" { + scope = "write" + } + if scope != "read" && scope != "write" { + c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be \"read\" or \"write\""}) + return + } + ttl := req.TTL + if ttl <= 0 { + ttl = 3600 + } + const maxTTL = 86400 * 7 // 7 days + if ttl > maxTTL { + ttl = maxTTL + } + + tok, err := h.client.CreateToken(ctx, artifacts.CreateTokenRequest{ + Repo: cfRepoName, + Scope: scope, + TTL: ttl, + }) + if err != nil { + log.Printf("artifacts: CreateToken failed for workspace %s: %v", workspaceID, err) + c.JSON(cfErrToHTTP(err), gin.H{"error": err.Error()}) + return + } + + // Build the authenticated git remote URL inline so callers can use it + // directly: git clone + cloneURL := buildCloneURL(cfRepoName, tok.Token, h.namespace) + + c.JSON(http.StatusCreated, gin.H{ + "token_id": tok.ID, + "token": tok.Token, + "scope": tok.Scope, + "expires_at": tok.ExpiresAt, + "clone_url": cloneURL, + "message": "Save this token — it cannot be retrieved again.", + }) +} + +// ---- helpers ------------------------------------------------------------- + +// cfErrToHTTP converts a CF API error to an appropriate HTTP status code. +// Passes through 4xx, maps everything else to 502 (bad gateway — upstream CF). +func cfErrToHTTP(err error) int { + apiErr, ok := err.(*artifacts.APIError) + if !ok { + return http.StatusBadGateway + } + if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 { + return apiErr.StatusCode + } + return http.StatusBadGateway +} + +// stripCredentials removes "x:@" from an authenticated git remote URL +// so we never persist credentials in the database. +// e.g. "https://x:tok@hash.artifacts.cloudflare.net/…" → "https://hash.artifacts.cloudflare.net/…" +func stripCredentials(remoteURL string) string { + if i := strings.Index(remoteURL, "@"); i != -1 { + scheme := "https://" + if strings.HasPrefix(remoteURL, "http://") { + scheme = "http://" + } + return scheme + remoteURL[i+1:] + } + return remoteURL +} + +// buildCloneURL constructs an authenticated clone URL from the CF token. +// Format: https://x:@.artifacts.cloudflare.net/git/repo-.git +// When we only have the repo name (not the full hashed host), we use a stable +// URL pattern that the CF git endpoint resolves. +func buildCloneURL(repoName, token, _ string) string { + // The CF git endpoint is the remote_url stored in the DB (minus the + // credential prefix). We reconstruct the authenticated form here. + // In production the remote URL is returned by CreateRepo/GetRepo; + // this fallback covers cases where the DB row predates that field. + return "https://x:" + token + "@artifacts.cloudflare.net/git/" + repoName + ".git" +} diff --git a/platform/internal/handlers/artifacts_test.go b/platform/internal/handlers/artifacts_test.go new file mode 100644 index 00000000..71dea2e7 --- /dev/null +++ b/platform/internal/handlers/artifacts_test.go @@ -0,0 +1,827 @@ +package handlers + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/artifacts" + "github.com/gin-gonic/gin" +) + +// cfSuccessResponse wraps a result in the Cloudflare v4 success envelope. +func cfSuccessResponse(t *testing.T, result interface{}) []byte { + t.Helper() + b, err := json.Marshal(result) + if err != nil { + t.Fatalf("cfSuccessResponse: marshal result: %v", err) + } + env := map[string]interface{}{ + "success": true, + "result": json.RawMessage(b), + "errors": []interface{}{}, + } + out, err := json.Marshal(env) + if err != nil { + t.Fatalf("cfSuccessResponse: marshal envelope: %v", err) + } + return out +} + +// cfErrorResponse returns a Cloudflare v4 error envelope bytes and status code. +func cfErrorResponse(t *testing.T, statusCode, code int, message string) ([]byte, int) { + t.Helper() + env := map[string]interface{}{ + "success": false, + "result": nil, + "errors": []map[string]interface{}{ + {"code": code, "message": message}, + }, + } + b, _ := json.Marshal(env) + return b, statusCode +} + +// newArtifactsMockServer starts an httptest.Server with the given handler function +// registered at /namespaces/test-ns/. +func newArtifactsMockCFServer(t *testing.T, suffix string, handler http.HandlerFunc) *artifacts.Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns"+suffix, handler) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return artifacts.NewWithBaseURL("cf-test-token", "test-ns", srv.URL) +} + +// ============================= Create ===================================== + +// TestArtifactsCreate_Success verifies the happy path: no existing link → +// CF API returns a repo → DB INSERT succeeds → 201 response. +func TestArtifactsCreate_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + cfClient := newArtifactsMockCFServer(t, "/repos", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "wrong method", http.StatusMethodNotAllowed) + return + } + repo := artifacts.Repo{ + Name: "molecule-ws-ws-abc", + ID: "repo-001", + RemoteURL: "https://x:tok123@hash.artifacts.cloudflare.net/git/repo-001.git", + CreatedAt: time.Now(), + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, repo)) + }) + + // Existence probe — no existing link + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-abc"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + // DB INSERT — RETURNING row + now := time.Now() + mock.ExpectQuery(`INSERT INTO workspace_artifacts`). + WithArgs("ws-abc", "molecule-ws-ws-abc", "test-ns", + "https://hash.artifacts.cloudflare.net/git/repo-001.git", ""). + WillReturnRows(sqlmock.NewRows( + []string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}). + AddRow("art-1", "ws-abc", "molecule-ws-ws-abc", "test-ns", + "https://hash.artifacts.cloudflare.net/git/repo-001.git", "", now, now)) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-abc"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-abc/artifacts", + bytes.NewBufferString(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["cf_repo_name"] != "molecule-ws-ws-abc" { + t.Errorf("cf_repo_name = %v, want molecule-ws-ws-abc", resp["cf_repo_name"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsCreate_AlreadyLinked verifies that a 409 is returned when the +// workspace already has a linked Artifacts repo. +func TestArtifactsCreate_AlreadyLinked(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + // Existence probe returns true + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-dup"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + h := newArtifactsHandlerWithClient( + artifacts.NewWithBaseURL("tok", "ns", "http://unused"), + "ns", + ) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-dup"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-dup/artifacts", + bytes.NewBufferString(`{"name":"dup-repo"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsCreate_CFAPIError verifies that a CF API error (e.g. 409 conflict) +// is forwarded with the appropriate HTTP status. +func TestArtifactsCreate_CFAPIError(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + cfClient := newArtifactsMockCFServer(t, "/repos", func(w http.ResponseWriter, r *http.Request) { + body, status := cfErrorResponse(t, http.StatusConflict, 1009, "repo name already taken") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(body) + }) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-cfconflict"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-cfconflict"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-cfconflict/artifacts", + bytes.NewBufferString(`{"name":"taken-name"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409 from CF error, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsCreate_WithImportURL verifies that when import_url is set the +// handler hits the /import endpoint instead of plain /repos. +func TestArtifactsCreate_WithImportURL(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + // Two paths: /repos/imported-repo/import + mux := http.NewServeMux() + mux.HandleFunc("/namespaces/test-ns/repos/imported-repo/import", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "wrong method", http.StatusMethodNotAllowed) + return + } + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if body["url"] != "https://github.com/Molecule-AI/molecule-core.git" { + http.Error(w, "unexpected url", http.StatusBadRequest) + return + } + repo := artifacts.Repo{ + Name: "imported-repo", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/imported.git", + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, repo)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + cfClient := artifacts.NewWithBaseURL("tok", "test-ns", srv.URL) + + mock.ExpectQuery(`SELECT EXISTS`). + WithArgs("ws-import"). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + now := time.Now() + mock.ExpectQuery(`INSERT INTO workspace_artifacts`). + WithArgs("ws-import", "imported-repo", "test-ns", + "https://hash.artifacts.cloudflare.net/git/imported.git", "Imported from GitHub"). + WillReturnRows(sqlmock.NewRows( + []string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}). + AddRow("art-imp", "ws-import", "imported-repo", "test-ns", + "https://hash.artifacts.cloudflare.net/git/imported.git", "Imported from GitHub", now, now)) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-import"}} + body := `{"name":"imported-repo","description":"Imported from GitHub","import_url":"https://github.com/Molecule-AI/molecule-core.git","import_branch":"main","import_depth":1}` + c.Request = httptest.NewRequest("POST", "/workspaces/ws-import/artifacts", + bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsCreate_NotConfigured verifies that missing env vars → 503. +func TestArtifactsCreate_NotConfigured(t *testing.T) { + setupTestDB(t) + setupTestRedis(t) + + // No CF client → nil + h := newArtifactsHandlerWithClient(nil, "") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-uncfg"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-uncfg/artifacts", + bytes.NewBufferString(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Create(c) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d: %s", w.Code, w.Body.String()) + } +} + +// ============================= Get ======================================= + +// TestArtifactsGet_Success verifies the happy path: DB row found + CF API ok. +func TestArtifactsGet_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + cfClient := newArtifactsMockCFServer(t, "/repos/my-ws-repo", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "wrong method", http.StatusMethodNotAllowed) + return + } + repo := artifacts.Repo{ + Name: "my-ws-repo", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/my-ws-repo.git", + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, repo)) + }) + + now := time.Now() + mock.ExpectQuery(`SELECT id, workspace_id, cf_repo_name`). + WithArgs("ws-get"). + WillReturnRows(sqlmock.NewRows( + []string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}). + AddRow("art-get", "ws-get", "my-ws-repo", "test-ns", + "https://hash.artifacts.cloudflare.net/git/my-ws-repo.git", "", now, now)) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-get"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-get/artifacts", nil) + + h.Get(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["cf_status"] != "ok" { + t.Errorf("cf_status = %v, want ok", resp["cf_status"]) + } + art, ok := resp["artifact"].(map[string]interface{}) + if !ok { + t.Fatalf("artifact is not an object") + } + if art["cf_repo_name"] != "my-ws-repo" { + t.Errorf("artifact.cf_repo_name = %v, want my-ws-repo", art["cf_repo_name"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsGet_NotFound verifies that 404 is returned when no row exists. +func TestArtifactsGet_NotFound(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT id, workspace_id, cf_repo_name`). + WithArgs("ws-noart"). + WillReturnError(sql.ErrNoRows) + + h := newArtifactsHandlerWithClient( + artifacts.NewWithBaseURL("tok", "ns", "http://unused"), + "ns", + ) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-noart"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-noart/artifacts", nil) + + h.Get(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsGet_CFUnavailable verifies that when CF API fails the handler +// still returns 200 with the cached DB row and cf_status="unavailable". +func TestArtifactsGet_CFUnavailable(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + // CF API server that always returns 500 + cfClient := newArtifactsMockCFServer(t, "/repos/cached-repo", func(w http.ResponseWriter, r *http.Request) { + body, status := cfErrorResponse(t, http.StatusInternalServerError, 0, "service unavailable") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(body) + }) + + now := time.Now() + mock.ExpectQuery(`SELECT id, workspace_id, cf_repo_name`). + WithArgs("ws-cfdown"). + WillReturnRows(sqlmock.NewRows( + []string{"id", "workspace_id", "cf_repo_name", "cf_namespace", "remote_url", "description", "created_at", "updated_at"}). + AddRow("art-cfdown", "ws-cfdown", "cached-repo", "test-ns", + "https://hash.artifacts.cloudflare.net/git/cached-repo.git", "", now, now)) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-cfdown"}} + c.Request = httptest.NewRequest("GET", "/workspaces/ws-cfdown/artifacts", nil) + + h.Get(c) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (degraded), got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["cf_status"] != "unavailable" { + t.Errorf("cf_status = %v, want unavailable", resp["cf_status"]) + } + if resp["artifact"] == nil { + t.Error("artifact should still be present from DB cache") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// ============================= Fork ====================================== + +// TestArtifactsFork_Success verifies the fork happy path. +func TestArtifactsFork_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + cfClient := newArtifactsMockCFServer(t, "/repos/source-repo/fork", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "wrong method", http.StatusMethodNotAllowed) + return + } + result := artifacts.ForkResult{ + Repo: artifacts.Repo{ + Name: "forked-ws", + ID: "fork-1", + RemoteURL: "https://x:tok@hash.artifacts.cloudflare.net/git/fork-1.git", + }, + ObjectCount: 88, + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, result)) + }) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-fork-src"). + WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("source-repo")) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-fork-src"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-fork-src/artifacts/fork", + bytes.NewBufferString(`{"name":"forked-ws"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Fork(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["object_count"] != float64(88) { + t.Errorf("object_count = %v, want 88", resp["object_count"]) + } + fork, ok := resp["fork"].(map[string]interface{}) + if !ok { + t.Fatalf("fork is not an object") + } + if fork["name"] != "forked-ws" { + t.Errorf("fork.name = %v, want forked-ws", fork["name"]) + } + // Embedded credentials must be stripped from clone_url + if remote := resp["remote_url"].(string); len(remote) > 0 { + if containsCredentials(remote) { + t.Errorf("remote_url should not contain credentials: %s", remote) + } + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsFork_NoLinkedRepo verifies 404 when workspace has no linked repo. +func TestArtifactsFork_NoLinkedRepo(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-norepo"). + WillReturnError(sql.ErrNoRows) + + h := newArtifactsHandlerWithClient( + artifacts.NewWithBaseURL("tok", "ns", "http://unused"), + "ns", + ) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-norepo"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-norepo/artifacts/fork", + bytes.NewBufferString(`{"name":"fork-name"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Fork(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsFork_MissingName verifies 400 when the fork name is missing. +func TestArtifactsFork_MissingName(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-fork-badname"). + WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("src-repo")) + + h := newArtifactsHandlerWithClient( + artifacts.NewWithBaseURL("tok", "test-ns", "http://unused"), + "test-ns", + ) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-fork-badname"}} + // name is required (binding:"required") but absent → 400 + c.Request = httptest.NewRequest("POST", "/workspaces/ws-fork-badname/artifacts/fork", + bytes.NewBufferString(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Fork(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for missing name, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// ============================= Token ===================================== + +// TestArtifactsToken_Success verifies the happy path: linked repo → CF returns token. +func TestArtifactsToken_Success(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + expiry := time.Now().Add(3600 * time.Second).UTC() + cfClient := newArtifactsMockCFServer(t, "/tokens", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "wrong method", http.StatusMethodNotAllowed) + return + } + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + if req["repo"] != "my-linked-repo" { + http.Error(w, "unexpected repo", http.StatusBadRequest) + return + } + tok := artifacts.RepoToken{ + ID: "token-abc", + Token: "plaintext-git-token", + Scope: "write", + ExpiresAt: expiry, + } + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, tok)) + }) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-token"). + WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("my-linked-repo")) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-token"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-token/artifacts/token", + bytes.NewBufferString(`{"scope":"write","ttl":3600}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Token(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["token_id"] != "token-abc" { + t.Errorf("token_id = %v, want token-abc", resp["token_id"]) + } + if resp["token"] != "plaintext-git-token" { + t.Errorf("token = %v, want plaintext-git-token", resp["token"]) + } + if resp["clone_url"] == nil || resp["clone_url"] == "" { + t.Error("clone_url should be non-empty") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsToken_DefaultsApplied verifies that empty scope/ttl are defaulted +// to "write" / 3600. +func TestArtifactsToken_DefaultsApplied(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + expiry := time.Now().Add(3600 * time.Second).UTC() + cfClient := newArtifactsMockCFServer(t, "/tokens", func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + // scope should be "write" (default) + if req["scope"] != "write" { + http.Error(w, "expected default scope write", http.StatusBadRequest) + return + } + // ttl should be 3600 (default), serialized as float64 from JSON + if req["ttl"] != float64(3600) { + http.Error(w, "expected default ttl 3600", http.StatusBadRequest) + return + } + tok := artifacts.RepoToken{ID: "t1", Token: "tok-def", Scope: "write", ExpiresAt: expiry} + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, tok)) + }) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-defaults"). + WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("my-repo")) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-defaults"}} + // Empty body — all defaults + c.Request = httptest.NewRequest("POST", "/workspaces/ws-defaults/artifacts/token", + bytes.NewBufferString(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Token(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsToken_InvalidScope verifies that an invalid scope returns 400. +func TestArtifactsToken_InvalidScope(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-badscope"). + WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("some-repo")) + + h := newArtifactsHandlerWithClient( + artifacts.NewWithBaseURL("tok", "ns", "http://unused"), + "ns", + ) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-badscope"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-badscope/artifacts/token", + bytes.NewBufferString(`{"scope":"admin"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Token(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid scope, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsToken_TTLCapped verifies that excessive TTL is silently capped +// to 7 days (604800 seconds) rather than returning an error. +func TestArtifactsToken_TTLCapped(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + const maxTTL = 86400 * 7 + + expiry := time.Now().Add(maxTTL * time.Second).UTC() + cfClient := newArtifactsMockCFServer(t, "/tokens", func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + if int(req["ttl"].(float64)) != maxTTL { + http.Error(w, "expected capped ttl", http.StatusBadRequest) + return + } + tok := artifacts.RepoToken{ID: "t-cap", Token: "capped-tok", Scope: "write", ExpiresAt: expiry} + w.Header().Set("Content-Type", "application/json") + w.Write(cfSuccessResponse(t, tok)) + }) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-ttlcap"). + WillReturnRows(sqlmock.NewRows([]string{"cf_repo_name"}).AddRow("capped-repo")) + + h := newArtifactsHandlerWithClient(cfClient, "test-ns") + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-ttlcap"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-ttlcap/artifacts/token", + bytes.NewBufferString(`{"scope":"write","ttl":99999999}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Token(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// TestArtifactsToken_NoLinkedRepo verifies 404 when no repo is linked. +func TestArtifactsToken_NoLinkedRepo(t *testing.T) { + mock := setupTestDB(t) + setupTestRedis(t) + + mock.ExpectQuery(`SELECT cf_repo_name FROM workspace_artifacts WHERE workspace_id`). + WithArgs("ws-tokennolink"). + WillReturnError(sql.ErrNoRows) + + h := newArtifactsHandlerWithClient( + artifacts.NewWithBaseURL("tok", "ns", "http://unused"), + "ns", + ) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "ws-tokennolink"}} + c.Request = httptest.NewRequest("POST", "/workspaces/ws-tokennolink/artifacts/token", + bytes.NewBufferString(`{}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.Token(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("sqlmock: %v", err) + } +} + +// ============================= helper unit tests ========================= + +// TestStripCredentials verifies that stripCredentials removes user:token@ from URLs. +func TestStripCredentials(t *testing.T) { + cases := []struct { + input string + want string + }{ + { + "https://x:tok123@hash.artifacts.cloudflare.net/git/repo.git", + "https://hash.artifacts.cloudflare.net/git/repo.git", + }, + { + "https://hash.artifacts.cloudflare.net/git/repo.git", + "https://hash.artifacts.cloudflare.net/git/repo.git", + }, + { + "http://user:pass@example.com/repo.git", + "http://example.com/repo.git", + }, + {"", ""}, + } + for _, tc := range cases { + got := stripCredentials(tc.input) + if got != tc.want { + t.Errorf("stripCredentials(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +// TestCfErrToHTTP verifies the CF-error-to-HTTP-status mapping. +func TestCfErrToHTTP(t *testing.T) { + cases := []struct { + err error + want int + }{ + {&artifacts.APIError{StatusCode: http.StatusConflict}, http.StatusConflict}, + {&artifacts.APIError{StatusCode: http.StatusNotFound}, http.StatusNotFound}, + {&artifacts.APIError{StatusCode: http.StatusBadRequest}, http.StatusBadRequest}, + {&artifacts.APIError{StatusCode: http.StatusInternalServerError}, http.StatusBadGateway}, + {&artifacts.APIError{StatusCode: http.StatusBadGateway}, http.StatusBadGateway}, + } + for _, tc := range cases { + got := cfErrToHTTP(tc.err) + if got != tc.want { + t.Errorf("cfErrToHTTP(%v) = %d, want %d", tc.err, got, tc.want) + } + } +} + +// containsCredentials is a test helper that checks whether a URL has embedded +// user:password@ credentials (should never appear in a stored remote URL). +func containsCredentials(u string) bool { + // A URL with embedded creds has the form scheme://user:pass@host/... + // We check for "@" after the scheme to detect this. + for i := 0; i < len(u)-3; i++ { + if u[i] == ':' && i > 0 && u[i-1] != '/' { + // Found ":" that is not ":/" — could be user:pass pair + if j := len(u); j > i { + for k := i + 1; k < j; k++ { + if u[k] == '@' { + return true + } + if u[k] == '/' { + break + } + } + } + } + } + return false +} diff --git a/platform/internal/router/router.go b/platform/internal/router/router.go index 8e735e45..58c759a9 100644 --- a/platform/internal/router/router.go +++ b/platform/internal/router/router.go @@ -292,6 +292,17 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi // WorkspaceAuth middleware (on wsAuth) binds the bearer to :id. mtrh := handlers.NewMetricsHandler() wsAuth.GET("/metrics", mtrh.GetMetrics) + + // Cloudflare Artifacts demo integration (#595). + // All four routes require workspace-scoped bearer auth (wsAuth). + // CF credentials read from CF_ARTIFACTS_API_TOKEN / CF_ARTIFACTS_NAMESPACE; + // missing credentials return 503 so the handler still registers in + // every deployment — the demo is gated on env vars, not compilation. + arth := handlers.NewArtifactsHandler() + wsAuth.POST("/artifacts", arth.Create) + wsAuth.GET("/artifacts", arth.Get) + wsAuth.POST("/artifacts/fork", arth.Fork) + wsAuth.POST("/artifacts/token", arth.Token) } // Global secrets — /settings/secrets is the canonical path; /admin/secrets kept for backward compat. diff --git a/platform/migrations/028_workspace_artifacts.down.sql b/platform/migrations/028_workspace_artifacts.down.sql new file mode 100644 index 00000000..3d149a31 --- /dev/null +++ b/platform/migrations/028_workspace_artifacts.down.sql @@ -0,0 +1,2 @@ +-- Reverse of 028_workspace_artifacts.up.sql +DROP TABLE IF EXISTS workspace_artifacts; diff --git a/platform/migrations/028_workspace_artifacts.up.sql b/platform/migrations/028_workspace_artifacts.up.sql new file mode 100644 index 00000000..c6b2d422 --- /dev/null +++ b/platform/migrations/028_workspace_artifacts.up.sql @@ -0,0 +1,31 @@ +-- 028_workspace_artifacts: store Cloudflare Artifacts repo linkage per workspace. +-- +-- Each workspace can be linked to exactly one Cloudflare Artifacts repo +-- (the primary snapshot store). Additional repos (forks) are ephemeral and +-- tracked only via the CF API — not in this table. +-- +-- Remote URLs are stored for informational display only; callers must +-- call POST /workspaces/:id/artifacts/token to obtain a fresh git credential. + +CREATE TABLE IF NOT EXISTS workspace_artifacts ( + id TEXT NOT NULL DEFAULT gen_random_uuid()::text, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + cf_repo_name TEXT NOT NULL, + cf_namespace TEXT NOT NULL, + -- remote_url is the base Git remote (without embedded credentials). + -- Credentials are obtained on-demand via POST /tokens. + remote_url TEXT, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT workspace_artifacts_pkey PRIMARY KEY (id) +); + +-- Each workspace may have at most one linked CF Artifacts repo. +CREATE UNIQUE INDEX IF NOT EXISTS uq_workspace_artifacts_workspace_id + ON workspace_artifacts (workspace_id); + +-- Allow fast lookup by CF repo name within a namespace. +CREATE INDEX IF NOT EXISTS idx_workspace_artifacts_cf_repo + ON workspace_artifacts (cf_namespace, cf_repo_name);