diff --git a/README.md b/README.md index edcc6e9..09949a3 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ To run your own, see [biga-sh/docker](https://github.com/biga-sh/docker). | `biga list` | List known sessions and whether they are live | | `biga open [dir]` | Open a folder's session in the browser | | `biga stop [dir]` | Stop a folder's session, or every session with `--all` | +| `biga extensions` | Create, list, or revoke checkpoint feed credentials | ## Configuration diff --git a/internal/api/extensions.go b/internal/api/extensions.go new file mode 100644 index 0000000..0e2b732 --- /dev/null +++ b/internal/api/extensions.go @@ -0,0 +1,37 @@ +package api + +import ( + "context" + "net/http" + "net/url" +) + +type Extension struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt string `json:"createdAt"` +} + +type ExtensionGrant struct { + Extension + Token string `json:"token"` +} + +func (c *Client) CreateExtension(ctx context.Context, sessionID, ownerToken, name string) (ExtensionGrant, error) { + var grant ExtensionGrant + _, err := c.do(ctx, http.MethodPost, sessionPath(sessionID, "extensions"), map[string]string{"name": name}, &grant, request{attempts: 1, token: ownerToken}) + return grant, err +} + +func (c *Client) Extensions(ctx context.Context, sessionID, ownerToken string) ([]Extension, error) { + var list struct { + Extensions []Extension `json:"extensions"` + } + _, err := c.do(ctx, http.MethodGet, sessionPath(sessionID, "extensions"), nil, &list, request{attempts: 2, token: ownerToken}) + return list.Extensions, err +} + +func (c *Client) RevokeExtension(ctx context.Context, sessionID, ownerToken, id string) error { + _, err := c.do(ctx, http.MethodDelete, sessionPath(sessionID, "extensions/"+url.PathEscape(id)), nil, nil, request{attempts: 2, token: ownerToken}) + return err +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d8eec13..b1f3c9d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -106,7 +106,7 @@ Run biga without a command in a terminal to pick one interactively.`, } root.PersistentFlags().StringVar(&a.apiURL, "api-url", "", fmt.Sprintf("server to use (default $%s or %s)", config.EnvAPIURL, config.DefaultAPIURL)) - root.AddCommand(a.startCmd(), a.listCmd(), a.statusCmd(), a.stopCmd(), a.openCmd()) + root.AddCommand(a.startCmd(), a.listCmd(), a.statusCmd(), a.stopCmd(), a.openCmd(), a.extensionsCmd()) return root } diff --git a/internal/cli/extensions.go b/internal/cli/extensions.go new file mode 100644 index 0000000..416b43a --- /dev/null +++ b/internal/cli/extensions.go @@ -0,0 +1,113 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/biga-sh/cli/internal/api" + "github.com/biga-sh/cli/internal/session" +) + +func (a *app) extensionsCmd() *cobra.Command { + var dir string + var createJSON, listJSON bool + cmd := &cobra.Command{ + Use: "extensions", + Short: "Manage checkpoint feed credentials for a directory's session", + } + cmd.PersistentFlags().StringVar(&dir, "dir", "", "broadcast directory (default: the current directory)") + create := &cobra.Command{ + Use: "create ", + Short: "Create a read-only checkpoint feed token", + Args: cobra.ExactArgs(1), + RunE: a.run(func(ctx context.Context, args []string) error { + rec, client, err := a.extensionSession(dir) + if err != nil { + return err + } + grant, err := client.CreateExtension(ctx, rec.SessionID, rec.Token, args[0]) + if err != nil { + return err + } + result := struct { + api.ExtensionGrant + FeedURL string `json:"feedUrl"` + }{ExtensionGrant: grant, FeedURL: rec.APIURL + "/api/sessions/" + rec.SessionID + "/extension-events"} + if createJSON { + return printJSON(result) + } + a.ui.Info("Extension ID: " + result.ID) + a.ui.Info("Feed URL: " + result.FeedURL) + a.ui.Info("Token (shown once): " + result.Token) + return nil + }), + } + create.Flags().BoolVar(&createJSON, "json", false, "print the credential and feed URL as JSON") + list := &cobra.Command{ + Use: "list", + Short: "List extensions without revealing tokens", + Args: cobra.NoArgs, + RunE: a.run(func(ctx context.Context, _ []string) error { + rec, client, err := a.extensionSession(dir) + if err != nil { + return err + } + extensions, err := client.Extensions(ctx, rec.SessionID, rec.Token) + if err != nil { + return err + } + if listJSON { + return printJSON(extensions) + } + if len(extensions) == 0 { + a.ui.Info("No extensions for this session.") + return nil + } + rows := make([][]string, 0, len(extensions)) + for _, extension := range extensions { + rows = append(rows, []string{extension.ID, extension.Name, extension.CreatedAt}) + } + a.ui.Table([]string{"ID", "NAME", "CREATED"}, rows) + return nil + }), + } + list.Flags().BoolVar(&listJSON, "json", false, "print extensions as JSON") + revoke := &cobra.Command{ + Use: "revoke ", + Short: "Revoke an extension's checkpoint feed token", + Args: cobra.ExactArgs(1), + RunE: a.run(func(ctx context.Context, args []string) error { + rec, client, err := a.extensionSession(dir) + if err != nil { + return err + } + return client.RevokeExtension(ctx, rec.SessionID, rec.Token, args[0]) + }), + } + cmd.AddCommand(create, list, revoke) + return cmd +} + +func (a *app) extensionSession(dir string) (session.Record, *api.Client, error) { + target, err := targetDir(dir, nil) + if err != nil { + return session.Record{}, nil, err + } + rec, found, err := a.store.Lookup(target) + if err != nil { + return rec, nil, err + } + if !found { + return rec, nil, errors.New("no session for this directory yet; run biga start first") + } + if rec.Token == "" || rec.APIURL == "" { + return rec, nil, errors.New("the saved session has no owner credentials or server URL") + } + if a.apiURL != "" && a.cfg.APIURL != rec.APIURL { + return rec, nil, fmt.Errorf("this session belongs to %s; omit --api-url or use that server", rec.APIURL) + } + return rec, a.client(rec.APIURL), nil +} diff --git a/internal/cli/extensions_test.go b/internal/cli/extensions_test.go new file mode 100644 index 0000000..fb9c6ef --- /dev/null +++ b/internal/cli/extensions_test.go @@ -0,0 +1,68 @@ +package cli + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/biga-sh/cli/internal/session" + "github.com/biga-sh/cli/internal/ui" +) + +func TestExtensionCommandsUseSavedSessionOwner(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer synthetic-owner" { + t.Error("request did not use the saved owner credential") + } + if r.Header.Get("X-Biga-Key") != "synthetic-access-key" { + t.Error("request did not include the server access key") + } + switch r.Method + " " + r.URL.Path { + case "POST /api/sessions/sample/extensions": + var body struct{ Name string } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Name != "Build monitor" { + t.Error("incorrect extension creation payload") + } + w.WriteHeader(http.StatusCreated) + io.WriteString(w, `{"ok":true,"data":{"id":"extension-id","name":"Build monitor","token":"synthetic-extension-token"}}`) + case "GET /api/sessions/sample/extensions": + io.WriteString(w, `{"ok":true,"data":{"extensions":[{"id":"extension-id","name":"Build monitor"}]}}`) + case "DELETE /api/sessions/sample/extensions/extension-id": + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + home, dir := t.TempDir(), t.TempDir() + t.Setenv("BIGA_HOME", home) + t.Setenv("BIGA_ACCESS_KEY", "synthetic-access-key") + t.Setenv("BIGA_NO_UPDATE_CHECK", "1") + if err := session.NewStore(home).Save(session.Record{RootDir: dir, SessionID: "sample", APIURL: server.URL, Token: "synthetic-owner"}); err != nil { + t.Fatal(err) + } + output, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = output.Close() }) + for _, args := range [][]string{{"create", "Build monitor"}, {"list"}, {"revoke", "extension-id"}} { + a := &app{ui: ui.New(output)} + cmd := a.rootCmd() + cmd.SetArgs(append([]string{"extensions", "--dir", dir}, args...)) + if err := cmd.ExecuteContext(t.Context()); err != nil { + t.Fatalf("%s: %v", args[0], err) + } + } + a := &app{ui: ui.New(output)} + cmd := a.rootCmd() + cmd.SetArgs([]string{"extensions", "list", "--dir", dir, "--api-url", "https://unrelated.example"}) + if err := cmd.ExecuteContext(t.Context()); err == nil || !strings.Contains(err.Error(), "this session belongs to") { + t.Fatalf("expected server override to be rejected, got %v", err) + } +}