diff --git a/api/openapi.yaml b/api/openapi.yaml index f1ad902..a4b23db 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -33,6 +33,8 @@ tags: description: AI session and agent observability - name: plans description: Ephemeral plan review artifacts + - name: shares + description: Author-side keyring of paste shares created on this machine paths: # ==================== @@ -968,6 +970,83 @@ paths: "500": $ref: "#/components/responses/InternalError" + # ==================== + # Shares (author keyring) + # ==================== + /shares: + get: + operationId: listShares + tags: [shares] + summary: List authored shares from the local keyring + responses: + "200": + description: List of shares (newest first) + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Share" + "500": + $ref: "#/components/responses/InternalError" + + post: + operationId: upsertShare + tags: [shares] + summary: Insert or replace a share keyring entry + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertShareRequest" + responses: + "200": + description: Share stored + content: + application/json: + schema: + $ref: "#/components/schemas/Share" + "400": + $ref: "#/components/responses/BadRequest" + "500": + $ref: "#/components/responses/InternalError" + + /shares/{shareId}: + parameters: + - name: shareId + in: path + required: true + description: Share ID (server-generated by the paste host) + schema: + type: string + + get: + operationId: getShare + tags: [shares] + summary: Get a single share keyring entry + responses: + "200": + description: Share record + content: + application/json: + schema: + $ref: "#/components/schemas/Share" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + + delete: + operationId: deleteShare + tags: [shares] + summary: Remove a share from the keyring (idempotent) + responses: + "204": + description: Share removed (or absent — same response) + "500": + $ref: "#/components/responses/InternalError" + # ==================== # Issue-Label Associations (project-scoped) # ==================== @@ -1854,6 +1933,48 @@ components: label: type: string + # ==================== + # Share Schemas + # ==================== + Share: + type: object + required: [id, kind, url, key_b64url, edit_token, created_at] + properties: + id: + type: string + kind: + $ref: "#/components/schemas/ShareKind" + url: + type: string + key_b64url: + type: string + edit_token: + type: string + plan_file: + type: string + created_at: + type: string + format: date-time + ShareKind: + type: string + enum: [local, shared] + UpsertShareRequest: + type: object + required: [id, kind, url, key_b64url, edit_token] + properties: + id: + type: string + kind: + $ref: "#/components/schemas/ShareKind" + url: + type: string + key_b64url: + type: string + edit_token: + type: string + plan_file: + type: string + # ==================== # Comment Schemas # ==================== diff --git a/cmd/arc/main.go b/cmd/arc/main.go index 5e582b4..dda5a56 100644 --- a/cmd/arc/main.go +++ b/cmd/arc/main.go @@ -17,6 +17,7 @@ import ( "github.com/fatih/color" "github.com/sentiolabs/arc/internal/client" "github.com/sentiolabs/arc/internal/project" + "github.com/sentiolabs/arc/internal/sharesconfig" "github.com/sentiolabs/arc/internal/types" "github.com/sentiolabs/arc/internal/version" "github.com/spf13/cobra" @@ -331,6 +332,13 @@ func init() { rootCmd.PersistentFlags().BoolVar(&outputJSON, "json", false, "Output as JSON") rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Config file path") + // Wire sharesconfig to talk to arc-server over HTTP. The factory is + // invoked lazily so flag/env/config resolution happens at command time, + // not at process start. + sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { + return getClient() + }) + // Add commands rootCmd.AddCommand(projectCmd) rootCmd.AddCommand(listCmd) diff --git a/cmd/arc/share.go b/cmd/arc/share.go index 7f42231..67348cd 100644 --- a/cmd/arc/share.go +++ b/cmd/arc/share.go @@ -163,7 +163,7 @@ var shareApproveCmd = &cobra.Command{ var shareUpdateCmd = &cobra.Command{ Use: "update ", - Short: "Replace the encrypted plan content (uses edit_token from shares.json)", + Short: "Replace the encrypted plan content (uses the edit_token from the local arc keyring)", // `update` takes exactly the share ref AND the plan file path. Args: cobra.ExactArgs(shareUpdateArgCount), RunE: runShareUpdate, @@ -171,8 +171,8 @@ var shareUpdateCmd = &cobra.Command{ const shareUpdateArgCount = 2 -// shareKindLocal / shareKindShared label the resolved server in the saved -// shares.json registry and surface in `arc share list` output. +// shareKindLocal / shareKindShared label the resolved server in the local +// arc keyring and surface in `arc share list` output. const ( shareKindLocal = "local" shareKindShared = "shared" @@ -182,7 +182,7 @@ const defaultShareServer = "https://arcplanner.sentiolabs.io" var shareDeleteCmd = &cobra.Command{ Use: "delete ", - Short: "Delete a share (uses edit_token from shares.json)", + Short: "Delete a share (uses the edit_token from the local arc keyring)", Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: runShareDelete, @@ -283,7 +283,7 @@ func runShareCreate(cmd *cobra.Command, args []string) error { trimmedServer, resp.ID, keyB64) fmt.Printf("Author URL (keep private — gives you Accept/Resolve):\n %s/share/%s#k=%s&t=%s\n\n", trimmedServer, resp.ID, keyB64, resp.EditToken) - fmt.Println("Edit token saved to ~/.arc/shares.json") + fmt.Println("Edit token saved to the local arc keyring") return nil } @@ -326,7 +326,7 @@ func printAuthorURL(ref string) error { } s, _ := sharesconfig.Find(id) if s == nil || s.EditToken == "" || s.KeyB64Url == "" { - return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json "+ + return fmt.Errorf("no edit_token for share %s in the local arc keyring "+ "(--author-url requires a share registered on this machine)", id) } fmt.Printf("%s/share/%s#k=%s&t=%s\n", @@ -384,7 +384,7 @@ func runShareUpdate(cmd *cobra.Command, args []string) error { } s, _ := sharesconfig.Find(id) if s == nil || s.EditToken == "" { - return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json", id) + return fmt.Errorf("no edit_token for share %s in the local arc keyring", id) } plain := planPlaintext{ Version: 1, @@ -406,7 +406,7 @@ func runShareDelete(cmd *cobra.Command, args []string) error { s, _ := sharesconfig.Find(id) if s == nil || s.EditToken == "" { if !shareDeleteForce { - return fmt.Errorf("no edit_token for share %s in ~/.arc/shares.json "+ + return fmt.Errorf("no edit_token for share %s in the local arc keyring "+ "(use --force to remove the local entry)", id) } _, _ = fmt.Fprintf(os.Stderr, "warning: no edit_token for %s; skipping server delete\n", id) @@ -809,7 +809,7 @@ func printCommentEntries(entries []commentEntry) { // // Plan content is exposed via exactly one of two fields: // - `file` — absolute path on disk. Set when the share is registered in -// shares.json AND the file is readable. Agent reads it directly. +// the local arc keyring AND the file is readable. Agent reads it directly. // - `markdown_b64` — base64-encoded markdown. Set when there's no local // file (e.g. an agent consuming a shared URL it didn't create). Base64 // avoids JSON escape bloat (every \n and \" doubles the byte count and @@ -828,7 +828,7 @@ type bundlePlan struct { Title string `json:"title,omitempty"` AuthorName string `json:"author_name,omitempty"` // File is the absolute path the agent should Edit. Present iff the - // share is in shares.json and the file is readable. + // share is in the local arc keyring and the file is readable. File string `json:"file,omitempty"` // MarkdownB64 is the plan content, base64-encoded. Present iff File // is not set. Decode with standard base64 (RawStdEncoding-compatible). @@ -852,7 +852,7 @@ type bundleResolvedAnchor struct { // emitBundle assembles and prints the JSON bundle for `--json` output. // // Plan content sourcing: -// - If the share is in shares.json AND the recorded file is readable, +// - If the share is in the local arc keyring AND the recorded file is readable, // emit `file` (absolute path) and run anchor resolution against the // file's current bytes. The agent reads the file directly. // - Otherwise, emit `markdown_b64` containing the encrypted blob's @@ -925,7 +925,7 @@ func emitBundle(id string, plan *planPlaintext, entries []commentEntry) error { // resolveShareRef parses a share reference which may be either a full share // URL (e.g. https://arcplanner.sentiolabs.io/share/abc12345#k=KEY) or a bare -// share ID known to ~/.arc/shares.json. +// share ID known to the local arc keyring. func resolveShareRef(ref string) (id, server string, key []byte, err error) { if strings.Contains(ref, "://") { return resolveShareURL(ref) @@ -942,8 +942,8 @@ func resolveShareRef(ref string) (id, server string, key []byte, err error) { } // resolveShareURL is the URL branch of resolveShareRef, split out to keep the -// nesting depth low. Falls back to ~/.arc/shares.json for the key if the URL -// has no fragment. +// nesting depth low. Falls back to the local arc keyring for the key if the +// URL has no fragment. func resolveShareURL(ref string) (id, server string, key []byte, err error) { u, perr := url.Parse(ref) if perr != nil { diff --git a/cmd/arc/share_test.go b/cmd/arc/share_test.go index ffc3a8f..3804928 100644 --- a/cmd/arc/share_test.go +++ b/cmd/arc/share_test.go @@ -2,8 +2,6 @@ package main import ( "bytes" - "context" - "database/sql" "encoding/base64" "encoding/json" "io" @@ -17,23 +15,52 @@ import ( "github.com/labstack/echo/v4" _ "modernc.org/sqlite" + "github.com/sentiolabs/arc/internal/api" + "github.com/sentiolabs/arc/internal/client" "github.com/sentiolabs/arc/internal/paste" pastesqlite "github.com/sentiolabs/arc/internal/paste/sqlite" "github.com/sentiolabs/arc/internal/sharesconfig" + "github.com/sentiolabs/arc/internal/storage/sqlite" ) func startTestPasteServer(t *testing.T) *httptest.Server { t.Helper() - db, err := sql.Open("sqlite", ":memory:") + // Open an arc storage backed by a temp-file sqlite db so the full + // migration set (including 017_shares.sql) is applied. The paste + // subsystem migrations are run by sqlite.New itself, so the same db + // connection serves both /api/paste and /api/v1/shares. + dbPath := filepath.Join(t.TempDir(), "test.db") + store, err := sqlite.New(dbPath) if err != nil { - t.Fatalf("open sqlite: %v", err) - } - if err := pastesqlite.Apply(context.Background(), db); err != nil { - t.Fatalf("apply migrations: %v", err) + t.Fatalf("sqlite.New: %v", err) } + t.Cleanup(func() { _ = store.Close() }) + e := echo.New() - paste.NewHandlers(pastesqlite.New(db)).Register(e.Group("/api/paste")) - return httptest.NewServer(e) + paste.NewHandlers(pastesqlite.New(store.DB())).Register(e.Group("/api/paste")) + + // Mount the share keyring routes on /api/v1 against the same store so + // sharesconfig.{Load,Add,Find,Remove} hits a real handler chain. + apiSrv := api.New(api.Config{Store: store}) + apiSrv.RegisterShareRoutes(e.Group("/api/v1")) + + srv := httptest.NewServer(e) + t.Cleanup(srv.Close) + + // Inject a client pointed at this test server so sharesconfig calls + // from CLI command code reach our in-process handlers. Restore the + // production factory on test exit so other tests in this package + // (and the CLI's main init) still see the real getClient wiring. + sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { + return client.New(srv.URL), nil + }) + t.Cleanup(func() { + sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { + return getClient() + }) + }) + + return srv } func TestShareCreateRoundTrip(t *testing.T) { @@ -824,6 +851,9 @@ func TestShareShowAuthorURL(t *testing.T) { func TestShareShowAuthorURLMissingShare(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) + // Bring up an empty in-process server so sharesconfig.Find returns + // a clean ErrShareNotFound rather than trying to dial localhost:7432. + _ = startTestPasteServer(t) shareShowAuthorURL = true defer func() { shareShowAuthorURL = false }() diff --git a/internal/api/openapi.gen.go b/internal/api/openapi.gen.go index 3e8faa6..0b94647 100644 --- a/internal/api/openapi.gen.go +++ b/internal/api/openapi.gen.go @@ -61,6 +61,12 @@ const ( Rejected PlanStatus = "rejected" ) +// Defines values for ShareKind. +const ( + Local ShareKind = "local" + Shared ShareKind = "shared" +) + // Defines values for Status. const ( StatusBlocked Status = "blocked" @@ -493,6 +499,20 @@ type Project struct { UpdatedAt time.Time `json:"updated_at"` } +// Share defines model for Share. +type Share struct { + CreatedAt time.Time `json:"created_at"` + EditToken string `json:"edit_token"` + ID string `json:"id"` + KeyB64Url string `json:"key_b64url"` + Kind ShareKind `json:"kind"` + PlanFile *string `json:"plan_file,omitempty"` + URL string `json:"url"` +} + +// ShareKind defines model for ShareKind. +type ShareKind string + // Statistics defines model for Statistics. type Statistics struct { AvgLeadTimeHours *float64 `json:"avg_lead_time_hours,omitempty"` @@ -590,6 +610,16 @@ type UpdateProjectRequest struct { Path *string `json:"path,omitempty"` } +// UpsertShareRequest defines model for UpsertShareRequest. +type UpsertShareRequest struct { + EditToken string `json:"edit_token"` + ID string `json:"id"` + KeyB64Url string `json:"key_b64url"` + Kind ShareKind `json:"kind"` + PlanFile *string `json:"plan_file,omitempty"` + URL string `json:"url"` +} + // ActorHeader defines model for ActorHeader. type ActorHeader = string @@ -803,6 +833,9 @@ type AddDependencyJSONRequestBody = AddDependencyRequest // AddLabelToIssueJSONRequestBody defines body for AddLabelToIssue for application/json ContentType. type AddLabelToIssueJSONRequestBody = AddLabelToIssueRequest +// UpsertShareJSONRequestBody defines body for UpsertShare for application/json ContentType. +type UpsertShareJSONRequestBody = UpsertShareRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // Get issue by globally-unique ID @@ -949,6 +982,18 @@ type ServerInterface interface { // Get issues grouped by teammate role labels // (GET /projects/{projectId}/team-context) GetTeamContext(ctx echo.Context, projectID ProjectID, params GetTeamContextParams) error + // List authored shares from the local keyring + // (GET /shares) + ListShares(ctx echo.Context) error + // Insert or replace a share keyring entry + // (POST /shares) + UpsertShare(ctx echo.Context) error + // Remove a share from the keyring (idempotent) + // (DELETE /shares/{shareId}) + DeleteShare(ctx echo.Context, shareID string) error + // Get a single share keyring entry + // (GET /shares/{shareId}) + GetShare(ctx echo.Context, shareID string) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -2242,6 +2287,56 @@ func (w *ServerInterfaceWrapper) GetTeamContext(ctx echo.Context) error { return err } +// ListShares converts echo context to params. +func (w *ServerInterfaceWrapper) ListShares(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ListShares(ctx) + return err +} + +// UpsertShare converts echo context to params. +func (w *ServerInterfaceWrapper) UpsertShare(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.UpsertShare(ctx) + return err +} + +// DeleteShare converts echo context to params. +func (w *ServerInterfaceWrapper) DeleteShare(ctx echo.Context) error { + var err error + // ------------- Path parameter "shareId" ------------- + var shareID string + + err = runtime.BindStyledParameterWithOptions("simple", "shareId", ctx.Param("shareId"), &shareID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter shareId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.DeleteShare(ctx, shareID) + return err +} + +// GetShare converts echo context to params. +func (w *ServerInterfaceWrapper) GetShare(ctx echo.Context) error { + var err error + // ------------- Path parameter "shareId" ------------- + var shareID string + + err = runtime.BindStyledParameterWithOptions("simple", "shareId", ctx.Param("shareId"), &shareID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter shareId: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetShare(ctx, shareID) + return err +} + // This is a simple interface which specifies echo.Route addition functions which // are present on both echo.Echo and echo.Group, since we want to allow using // either of them for path registration @@ -2318,6 +2413,10 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/projects/:projectId/ready", wrapper.GetReadyWork) router.GET(baseURL+"/projects/:projectId/stats", wrapper.GetProjectStats) router.GET(baseURL+"/projects/:projectId/team-context", wrapper.GetTeamContext) + router.GET(baseURL+"/shares", wrapper.ListShares) + router.POST(baseURL+"/shares", wrapper.UpsertShare) + router.DELETE(baseURL+"/shares/:shareId", wrapper.DeleteShare) + router.GET(baseURL+"/shares/:shareId", wrapper.GetShare) } @@ -4122,6 +4221,126 @@ func (response GetTeamContext500JSONResponse) VisitGetTeamContextResponse(w http return json.NewEncoder(w).Encode(response) } +type ListSharesRequestObject struct { +} + +type ListSharesResponseObject interface { + VisitListSharesResponse(w http.ResponseWriter) error +} + +type ListShares200JSONResponse []Share + +func (response ListShares200JSONResponse) VisitListSharesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ListShares500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ListShares500JSONResponse) VisitListSharesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type UpsertShareRequestObject struct { + Body *UpsertShareJSONRequestBody +} + +type UpsertShareResponseObject interface { + VisitUpsertShareResponse(w http.ResponseWriter) error +} + +type UpsertShare200JSONResponse Share + +func (response UpsertShare200JSONResponse) VisitUpsertShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type UpsertShare400JSONResponse struct{ BadRequestJSONResponse } + +func (response UpsertShare400JSONResponse) VisitUpsertShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UpsertShare500JSONResponse struct{ InternalErrorJSONResponse } + +func (response UpsertShare500JSONResponse) VisitUpsertShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteShareRequestObject struct { + ShareID string `json:"shareId"` +} + +type DeleteShareResponseObject interface { + VisitDeleteShareResponse(w http.ResponseWriter) error +} + +type DeleteShare204Response struct { +} + +func (response DeleteShare204Response) VisitDeleteShareResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DeleteShare500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DeleteShare500JSONResponse) VisitDeleteShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetShareRequestObject struct { + ShareID string `json:"shareId"` +} + +type GetShareResponseObject interface { + VisitGetShareResponse(w http.ResponseWriter) error +} + +type GetShare200JSONResponse Share + +func (response GetShare200JSONResponse) VisitGetShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetShare404JSONResponse struct{ NotFoundJSONResponse } + +func (response GetShare404JSONResponse) VisitGetShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type GetShare500JSONResponse struct{ InternalErrorJSONResponse } + +func (response GetShare500JSONResponse) VisitGetShareResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Get issue by globally-unique ID @@ -4268,6 +4487,18 @@ type StrictServerInterface interface { // Get issues grouped by teammate role labels // (GET /projects/{projectId}/team-context) GetTeamContext(ctx context.Context, request GetTeamContextRequestObject) (GetTeamContextResponseObject, error) + // List authored shares from the local keyring + // (GET /shares) + ListShares(ctx context.Context, request ListSharesRequestObject) (ListSharesResponseObject, error) + // Insert or replace a share keyring entry + // (POST /shares) + UpsertShare(ctx context.Context, request UpsertShareRequestObject) (UpsertShareResponseObject, error) + // Remove a share from the keyring (idempotent) + // (DELETE /shares/{shareId}) + DeleteShare(ctx context.Context, request DeleteShareRequestObject) (DeleteShareResponseObject, error) + // Get a single share keyring entry + // (GET /shares/{shareId}) + GetShare(ctx context.Context, request GetShareRequestObject) (GetShareResponseObject, error) } type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc @@ -5625,103 +5856,211 @@ func (sh *strictHandler) GetTeamContext(ctx echo.Context, projectID ProjectID, p return nil } +// ListShares operation middleware +func (sh *strictHandler) ListShares(ctx echo.Context) error { + var request ListSharesRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ListShares(ctx.Request().Context(), request.(ListSharesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListShares") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ListSharesResponseObject); ok { + return validResponse.VisitListSharesResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// UpsertShare operation middleware +func (sh *strictHandler) UpsertShare(ctx echo.Context) error { + var request UpsertShareRequestObject + + var body UpsertShareJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.UpsertShare(ctx.Request().Context(), request.(UpsertShareRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpsertShare") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(UpsertShareResponseObject); ok { + return validResponse.VisitUpsertShareResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// DeleteShare operation middleware +func (sh *strictHandler) DeleteShare(ctx echo.Context, shareID string) error { + var request DeleteShareRequestObject + + request.ShareID = shareID + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.DeleteShare(ctx.Request().Context(), request.(DeleteShareRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteShare") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(DeleteShareResponseObject); ok { + return validResponse.VisitDeleteShareResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetShare operation middleware +func (sh *strictHandler) GetShare(ctx echo.Context, shareID string) error { + var request GetShareRequestObject + + request.ShareID = shareID + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetShare(ctx.Request().Context(), request.(GetShareRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetShare") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetShareResponseObject); ok { + return validResponse.VisitGetShareResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/internal/api/server.go b/internal/api/server.go index a4a335d..3810c48 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -95,6 +95,17 @@ func (s *Server) Shutdown(ctx context.Context) error { return s.echo.Shutdown(ctx) } +// RegisterShareRoutes mounts the /shares endpoints on the given group. +// Exported so test fixtures outside the api package can wire the share +// keyring routes onto an in-process Echo server without spinning up the +// full registerRoutes() surface. +func (s *Server) RegisterShareRoutes(g *echo.Group) { + g.GET("/shares", s.listShares) + g.POST("/shares", s.upsertShare) + g.GET("/shares/:id", s.getShare) + g.DELETE("/shares/:id", s.deleteShare) +} + // registerRoutes sets up all API routes. func (s *Server) registerRoutes() { // Health check @@ -148,6 +159,9 @@ func (s *Server) registerRoutes() { v1.PUT("/labels/:name", s.updateLabel) v1.DELETE("/labels/:name", s.deleteLabel) + // Shares (author-side keyring of paste shares created on this machine) + s.RegisterShareRoutes(v1) + // Project-scoped routes (issues, AI sessions, etc.) s.registerProjectRoutes(v1) s.registerProjectAIRoutes(v1) diff --git a/internal/api/shares.go b/internal/api/shares.go new file mode 100644 index 0000000..8964d6f --- /dev/null +++ b/internal/api/shares.go @@ -0,0 +1,62 @@ +// Share keyring API handlers — author-side registry of paste shares created +// from this machine. The keyring stores the secrets (edit_token, key_b64url) +// that authenticate this user as the share author to the hosting paste server. +package api + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/sentiolabs/arc/internal/storage" + "github.com/sentiolabs/arc/internal/types" +) + +// listShares returns all keyring entries, newest first. +func (s *Server) listShares(c echo.Context) error { + shares, err := s.store.ListShares(c.Request().Context()) + if err != nil { + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + return successJSON(c, shares) +} + +// getShare returns a single keyring entry by share ID. +func (s *Server) getShare(c echo.Context) error { + id := c.Param("id") + share, err := s.store.GetShare(c.Request().Context(), id) + if err != nil { + if errors.Is(err, storage.ErrShareNotFound) { + return errorJSON(c, http.StatusNotFound, err.Error()) + } + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + return successJSON(c, share) +} + +// upsertShare inserts or replaces a keyring entry by share ID. The store +// stamps CreatedAt when callers omit it, so handlers and the legacy import +// path both end up with consistent timestamps. +func (s *Server) upsertShare(c echo.Context) error { + var share types.Share + if err := c.Bind(&share); err != nil { + return errorJSON(c, http.StatusBadRequest, "invalid request body") + } + if err := share.Validate(); err != nil { + return errorJSON(c, http.StatusBadRequest, err.Error()) + } + if err := s.store.UpsertShare(c.Request().Context(), &share); err != nil { + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + return successJSON(c, &share) +} + +// deleteShare removes a keyring entry by share ID. Idempotent: 204 even if +// the ID doesn't exist. +func (s *Server) deleteShare(c echo.Context) error { + id := c.Param("id") + if err := s.store.DeleteShare(c.Request().Context(), id); err != nil { + return errorJSON(c, http.StatusInternalServerError, err.Error()) + } + return c.NoContent(http.StatusNoContent) +} diff --git a/internal/api/shares_import.go b/internal/api/shares_import.go new file mode 100644 index 0000000..a76f101 --- /dev/null +++ b/internal/api/shares_import.go @@ -0,0 +1,79 @@ +// Legacy share keyring import — one-shot migration from ~/.arc/shares.json +// into the shares table. Runs at arc-server startup after schema migrations. +// Idempotency comes from the file's presence: a successful import renames +// the JSON to shares.json.bak, so subsequent startups simply find no file +// to read and return early. +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/sentiolabs/arc/internal/types" +) + +// legacyFile mirrors the on-disk format of ~/.arc/shares.json. +type legacyFile struct { + Shares []legacyShare `json:"shares"` +} + +type legacyShare struct { + ID string `json:"id"` + Kind string `json:"kind"` + URL string `json:"url"` + KeyB64Url string `json:"key_b64url"` + EditToken string `json:"edit_token"` + PlanFile string `json:"plan_file,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ImportLegacySharesJSON imports the legacy shares.json keyring into the +// shares table. Returns the number of records imported. Behavior: +// - if path does not exist: returns (0, nil) +// - on parse error: returns (0, err) and does not modify the file or database +// - on per-share validation/constraint failure: rolls the whole batch back +// atomically, leaves the JSON file untouched, and returns the error so +// the next startup can retry after the user fixes the entry +// - on success: renames path to path+".bak" so subsequent startups find no +// file to read and return early +func (s *Server) ImportLegacySharesJSON(ctx context.Context, path string) (int, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("read legacy shares.json: %w", err) + } + + var lf legacyFile + if err := json.Unmarshal(data, &lf); err != nil { + return 0, fmt.Errorf("parse legacy shares.json: %w", err) + } + + shares := make([]*types.Share, 0, len(lf.Shares)) + for _, ls := range lf.Shares { + shares = append(shares, &types.Share{ + ID: ls.ID, + Kind: types.ShareKind(ls.Kind), + URL: ls.URL, + KeyB64Url: ls.KeyB64Url, + EditToken: ls.EditToken, + PlanFile: ls.PlanFile, + CreatedAt: ls.CreatedAt, + }) + } + + if err := s.store.UpsertShares(ctx, shares); err != nil { + return 0, fmt.Errorf("import legacy shares: %w", err) + } + + if err := os.Rename(path, path+".bak"); err != nil { + return len(shares), fmt.Errorf("rename legacy shares.json to .bak: %w", err) + } + + return len(shares), nil +} diff --git a/internal/api/shares_import_test.go b/internal/api/shares_import_test.go new file mode 100644 index 0000000..775f5d7 --- /dev/null +++ b/internal/api/shares_import_test.go @@ -0,0 +1,382 @@ +package api //nolint:testpackage // tests use internal helpers that access unexported fields + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sentiolabs/arc/internal/types" +) + +// TestImportLegacySharesJSONNoFile verifies that when the JSON file doesn't exist, +// the function returns (0, nil) with no side effects. +func TestImportLegacySharesJSONNoFile(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + tmpDir := t.TempDir() + nonexistentPath := filepath.Join(tmpDir, "nonexistent.json") + + count, err := server.ImportLegacySharesJSON(context.Background(), nonexistentPath) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if count != 0 { + t.Errorf("count = %d, want 0", count) + } + + // Verify file still doesn't exist + if _, err := os.Stat(nonexistentPath); err == nil { + t.Error("expected file to not exist") + } +} + +// TestImportLegacySharesJSONValidJSON verifies that a valid JSON file with 2 shares +// is imported successfully, returns (2, nil), and the file is renamed to .bak. +func TestImportLegacySharesJSONValidJSON(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + tmpDir := t.TempDir() + jsonPath := filepath.Join(tmpDir, "shares.json") + + // Create a valid legacy shares.json file with 2 shares + legacyData := map[string]any{ + "shares": []map[string]any{ + { + "id": "share-legacy-1", + "kind": "shared", + "url": "https://example.com/paste/1", + "key_b64url": "key1", + "edit_token": "token1", + "plan_file": "/tmp/plan1.json", + "created_at": time.Now().UTC(), + }, + { + "id": "share-legacy-2", + "kind": "local", + "url": "https://example.com/paste/2", + "key_b64url": "key2", + "edit_token": "token2", + "plan_file": "", + "created_at": time.Now().UTC(), + }, + }, + } + + jsonBytes, _ := json.Marshal(legacyData) + if err := os.WriteFile(jsonPath, jsonBytes, 0o600); err != nil { + t.Fatalf("failed to write JSON file: %v", err) + } + + // Import + count, err := server.ImportLegacySharesJSON(context.Background(), jsonPath) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if count != 2 { + t.Errorf("count = %d, want 2", count) + } + + // Verify file renamed to .bak + if _, err := os.Stat(jsonPath); err == nil { + t.Error("expected original file to be renamed") + } + bakPath := jsonPath + ".bak" + if _, err := os.Stat(bakPath); err != nil { + t.Errorf("expected .bak file to exist: %v", err) + } + + // Verify shares were imported into the table + shares, err := server.store.ListShares(context.Background()) + if err != nil { + t.Fatalf("failed to list shares: %v", err) + } + if len(shares) != 2 { + t.Errorf("expected 2 shares in table, got %d", len(shares)) + } + + // Verify the imported shares have correct values + idToShare := make(map[string]*types.Share) + for _, s := range shares { + idToShare[s.ID] = s + } + + if s, ok := idToShare["share-legacy-1"]; ok { + if s.Kind != types.ShareKindShared { + t.Errorf("share-legacy-1 kind = %q, want %q", s.Kind, types.ShareKindShared) + } + if s.URL != "https://example.com/paste/1" { + t.Errorf("share-legacy-1 url = %q, want %q", s.URL, "https://example.com/paste/1") + } + } else { + t.Error("share-legacy-1 not found in imported shares") + } + + if s, ok := idToShare["share-legacy-2"]; ok { + if s.Kind != types.ShareKindLocal { + t.Errorf("share-legacy-2 kind = %q, want %q", s.Kind, types.ShareKindLocal) + } + } else { + t.Error("share-legacy-2 not found in imported shares") + } +} + +// TestImportLegacySharesJSONIdempotentUpsert verifies that re-importing entries +// already in the table is a clean no-op upsert: pre-existing rows that aren't +// in the file are preserved, and rows that are in both get overwritten. +func TestImportLegacySharesJSONIdempotentUpsert(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + // Pre-populate the table with two shares: one that will also appear in + // the JSON (overwritten on import) and one that won't (preserved). + existingShared := &types.Share{ + ID: "share-overlapping", + Kind: types.ShareKindLocal, + URL: "https://example.com/old", + KeyB64Url: "oldkey", + EditToken: "oldtoken", + CreatedAt: time.Now().UTC(), + } + existingPreserved := &types.Share{ + ID: "share-preserved", + Kind: types.ShareKindLocal, + URL: "https://example.com/preserved", + KeyB64Url: "key", + EditToken: "token", + CreatedAt: time.Now().UTC(), + } + for _, s := range []*types.Share{existingShared, existingPreserved} { + if err := server.store.UpsertShare(context.Background(), s); err != nil { + t.Fatalf("seed share: %v", err) + } + } + + tmpDir := t.TempDir() + jsonPath := filepath.Join(tmpDir, "shares.json") + + // JSON contains the overlapping ID with new field values. + legacyData := map[string]any{ + "shares": []map[string]any{ + { + "id": "share-overlapping", + "kind": "shared", + "url": "https://example.com/new", + "key_b64url": "newkey", + "edit_token": "newtoken", + "created_at": time.Now().UTC(), + }, + }, + } + jsonBytes, _ := json.Marshal(legacyData) + if err := os.WriteFile(jsonPath, jsonBytes, 0o600); err != nil { + t.Fatalf("failed to write JSON file: %v", err) + } + + // Import should succeed and rename the file — the row-count is no longer + // a guard; the file's presence is. + count, err := server.ImportLegacySharesJSON(context.Background(), jsonPath) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if count != 1 { + t.Errorf("count = %d, want 1", count) + } + if _, err := os.Stat(jsonPath); err == nil { + t.Error("expected original file to be renamed") + } + if _, err := os.Stat(jsonPath + ".bak"); err != nil { + t.Errorf("expected .bak file to exist: %v", err) + } + + // Overlapping row should be overwritten with the JSON's values; the + // preserved row should still be there untouched. + shares, err := server.store.ListShares(context.Background()) + if err != nil { + t.Fatalf("failed to list shares: %v", err) + } + if len(shares) != 2 { + t.Fatalf("expected 2 shares in table, got %d", len(shares)) + } + byID := map[string]*types.Share{} + for _, s := range shares { + byID[s.ID] = s + } + if got, want := byID["share-overlapping"].URL, "https://example.com/new"; got != want { + t.Errorf("overlapping URL = %q, want %q (upsert should have overwritten)", got, want) + } + if got, want := byID["share-preserved"].URL, "https://example.com/preserved"; got != want { + t.Errorf("preserved URL = %q, want %q (untouched row mutated)", got, want) + } +} + +// TestImportLegacySharesJSONMalformedJSON verifies that malformed JSON causes +// an error, with no file rename and no rows inserted. +func TestImportLegacySharesJSONMalformedJSON(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + tmpDir := t.TempDir() + jsonPath := filepath.Join(tmpDir, "shares.json") + + // Create a malformed JSON file + if err := os.WriteFile(jsonPath, []byte("{not json"), 0o600); err != nil { + t.Fatalf("failed to write malformed JSON: %v", err) + } + + // Attempt import + count, err := server.ImportLegacySharesJSON(context.Background(), jsonPath) + if err == nil { + t.Error("expected error for malformed JSON, got nil") + } + if count != 0 { + t.Errorf("count = %d, want 0 on error", count) + } + + // Verify JSON file was NOT renamed + if _, err := os.Stat(jsonPath); err != nil { + t.Errorf("expected original file to still exist after parse error: %v", err) + } + bakPath := jsonPath + ".bak" + if _, err := os.Stat(bakPath); err == nil { + t.Error("expected .bak file to NOT be created on parse error") + } + + // Verify no rows were inserted + shares, err := server.store.ListShares(context.Background()) + if err != nil { + t.Fatalf("failed to list shares: %v", err) + } + if len(shares) != 0 { + t.Errorf("expected 0 shares in table after parse error, got %d", len(shares)) + } +} + +// TestImportLegacySharesJSONValidationFailureMidImport verifies atomic rollback: +// a validation error on any entry must roll the entire batch back so the file +// stays as shares.json (un-renamed) and the table stays empty. The next +// startup can then retry once the operator fixes the bad entry. +func TestImportLegacySharesJSONValidationFailureMidImport(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + tmpDir := t.TempDir() + jsonPath := filepath.Join(tmpDir, "shares.json") + + // First entry is valid; second has an empty ID and will fail Validate(). + legacyData := map[string]any{ + "shares": []map[string]any{ + { + "id": "share-valid", + "kind": "shared", + "url": "https://example.com/valid", + "key_b64url": "key1", + "edit_token": "token1", + "created_at": time.Now().UTC(), + }, + { + "id": "", + "kind": "shared", + "url": "https://example.com/invalid", + "key_b64url": "key2", + "edit_token": "token2", + "created_at": time.Now().UTC(), + }, + }, + } + jsonBytes, _ := json.Marshal(legacyData) + if err := os.WriteFile(jsonPath, jsonBytes, 0o600); err != nil { + t.Fatalf("failed to write JSON file: %v", err) + } + + count, err := server.ImportLegacySharesJSON(context.Background(), jsonPath) + if err == nil { + t.Error("expected error for validation failure, got nil") + } + if count != 0 { + t.Errorf("count = %d, want 0 (transactional rollback)", count) + } + + // File stays as shares.json so the next startup retries. + if _, err := os.Stat(jsonPath); err != nil { + t.Errorf("expected original file to still exist after mid-import error: %v", err) + } + if _, err := os.Stat(jsonPath + ".bak"); err == nil { + t.Error("expected .bak file to NOT be created on mid-import error") + } + + // No partial state — table must be empty. + shares, err := server.store.ListShares(context.Background()) + if err != nil { + t.Fatalf("failed to list shares: %v", err) + } + if len(shares) != 0 { + t.Errorf("expected 0 shares (rollback), got %d", len(shares)) + } +} + +// TestImportLegacySharesJSONBakAlreadyExists verifies that if the .bak file +// already exists, os.Rename overwrites it successfully on import. +func TestImportLegacySharesJSONBakAlreadyExists(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + tmpDir := t.TempDir() + jsonPath := filepath.Join(tmpDir, "shares.json") + bakPath := jsonPath + ".bak" + + // Pre-create the .bak file + if err := os.WriteFile(bakPath, []byte("old backup"), 0o600); err != nil { + t.Fatalf("failed to create .bak file: %v", err) + } + + // Create a valid legacy shares.json file + legacyData := map[string]any{ + "shares": []map[string]any{ + { + "id": "share-new", + "kind": "shared", + "url": "https://example.com/new", + "key_b64url": "key", + "edit_token": "token", + "created_at": time.Now().UTC(), + }, + }, + } + jsonBytes, _ := json.Marshal(legacyData) + if err := os.WriteFile(jsonPath, jsonBytes, 0o600); err != nil { + t.Fatalf("failed to write JSON file: %v", err) + } + + // Attempt import (should succeed and overwrite .bak on Linux) + count, err := server.ImportLegacySharesJSON(context.Background(), jsonPath) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if count != 1 { + t.Errorf("count = %d, want 1", count) + } + + // Verify original JSON file no longer exists + if _, err := os.Stat(jsonPath); err == nil { + t.Error("expected original file to be renamed") + } + + // Verify .bak file now contains the JSON (overwritten) + if _, err := os.Stat(bakPath); err != nil { + t.Errorf("expected .bak file to exist: %v", err) + } + + // Verify shares were imported + shares, err := server.store.ListShares(context.Background()) + if err != nil { + t.Fatalf("failed to list shares: %v", err) + } + if len(shares) != 1 { + t.Errorf("expected 1 share in table, got %d", len(shares)) + } +} diff --git a/internal/api/shares_test.go b/internal/api/shares_test.go new file mode 100644 index 0000000..2531bf2 --- /dev/null +++ b/internal/api/shares_test.go @@ -0,0 +1,285 @@ +package api //nolint:testpackage // tests use internal helpers that access unexported fields + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/sentiolabs/arc/internal/types" +) + +// shareFixture returns a valid Share payload for use in tests. +func shareFixture() types.Share { + return types.Share{ + ID: "share-abc123", + Kind: types.ShareKindShared, + URL: "https://example.com/paste/abc123", + KeyB64Url: "c2VjcmV0a2V5", + EditToken: "tok-xyz", + PlanFile: "/tmp/plan.json", + } +} + +// doShareRequest sends a JSON request to the echo instance and returns the recorder. +func doShareRequest(e *echo.Echo, method, path string, body any) *httptest.ResponseRecorder { + var bodyBytes []byte + if body != nil { + bodyBytes, _ = json.Marshal(body) + } + req := httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func TestListSharesEmpty(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + rec := doShareRequest(server.echo, http.MethodGet, "/api/v1/shares", nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var shares []*types.Share + if err := json.Unmarshal(rec.Body.Bytes(), &shares); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(shares) != 0 { + t.Errorf("expected empty list, got %d shares", len(shares)) + } +} + +func TestUpsertShareValid(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + share := shareFixture() + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var stored types.Share + if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if stored.ID != share.ID { + t.Errorf("id = %q, want %q", stored.ID, share.ID) + } + if stored.Kind != share.Kind { + t.Errorf("kind = %q, want %q", stored.Kind, share.Kind) + } + if stored.URL != share.URL { + t.Errorf("url = %q, want %q", stored.URL, share.URL) + } +} + +func TestUpsertShareInvalidKind(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + share := shareFixture() + share.Kind = "bogus" + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestUpsertShareMissingRequiredFields(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + // Missing URL, KeyB64Url, EditToken + share := types.Share{ + ID: "share-missing", + Kind: types.ShareKindLocal, + } + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestUpsertShareReplaces(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + share := shareFixture() + // First POST: create + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusOK { + t.Fatalf("first upsert: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Second POST: replace with updated URL + share.URL = "https://example.com/paste/replaced" + rec = doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusOK { + t.Fatalf("second upsert: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Verify replacement via GET + rec = doShareRequest(server.echo, http.MethodGet, "/api/v1/shares/"+share.ID, nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET after upsert: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var stored types.Share + if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if stored.URL != "https://example.com/paste/replaced" { + t.Errorf("url = %q, want %q", stored.URL, "https://example.com/paste/replaced") + } +} + +func TestGetShareFound(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + share := shareFixture() + // Insert + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusOK { + t.Fatalf("upsert: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Fetch + rec = doShareRequest(server.echo, http.MethodGet, "/api/v1/shares/"+share.ID, nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + var stored types.Share + if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if stored.ID != share.ID { + t.Errorf("id = %q, want %q", stored.ID, share.ID) + } +} + +func TestGetShareNotFound(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + rec := doShareRequest(server.echo, http.MethodGet, "/api/v1/shares/nonexistent", nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestDeleteShareExisting(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + share := shareFixture() + // Insert + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", share) + if rec.Code != http.StatusOK { + t.Fatalf("upsert: expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + // Delete + rec = doShareRequest(server.echo, http.MethodDelete, "/api/v1/shares/"+share.ID, nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + + // Verify gone + rec = doShareRequest(server.echo, http.MethodGet, "/api/v1/shares/"+share.ID, nil) + if rec.Code != http.StatusNotFound { + t.Fatalf("after delete, expected 404, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestDeleteShareIdempotent(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + rec := doShareRequest(server.echo, http.MethodDelete, "/api/v1/shares/does-not-exist", nil) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204 (idempotent), got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestListSharesNewestFirst(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + type entry struct { + id string + createdAt time.Time + } + entries := []entry{ + {"share-first", baseTime}, + {"share-second", baseTime.Add(time.Hour)}, + {"share-third", baseTime.Add(2 * time.Hour)}, + } + for _, e := range entries { + s := shareFixture() + s.ID = e.id + s.CreatedAt = e.createdAt + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", s) + if rec.Code != http.StatusOK { + t.Fatalf("upsert %s: expected 200, got %d: %s", e.id, rec.Code, rec.Body.String()) + } + } + + rec := doShareRequest(server.echo, http.MethodGet, "/api/v1/shares", nil) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + + var shares []*types.Share + if err := json.Unmarshal(rec.Body.Bytes(), &shares); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(shares) != 3 { + t.Fatalf("expected 3 shares, got %d", len(shares)) + } + + // Newest first means creation order reversed: share-third should be first + if shares[0].ID != "share-third" { + t.Errorf("first share = %q, want %q (newest first)", shares[0].ID, "share-third") + } + if shares[2].ID != "share-first" { + t.Errorf("last share = %q, want %q (newest first)", shares[2].ID, "share-first") + } +} + +func TestUpsertShareStampsCreatedAt(t *testing.T) { + server, cleanup := testServer(t) + defer cleanup() + + // Build a share with a zero CreatedAt. + s := shareFixture() + s.CreatedAt = time.Time{} + + before := time.Now().UTC().Add(-time.Second) + rec := doShareRequest(server.echo, http.MethodPost, "/api/v1/shares", s) + after := time.Now().UTC().Add(time.Second) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + var stored types.Share + if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if stored.CreatedAt.IsZero() { + t.Errorf("expected server-stamped CreatedAt, got zero value") + } + if stored.CreatedAt.Before(before) || stored.CreatedAt.After(after) { + t.Errorf("CreatedAt %v outside expected window [%v, %v]", + stored.CreatedAt, before, after) + } +} diff --git a/internal/api/workspace_paths_test.go b/internal/api/workspace_paths_test.go index 83f220e..201cfa3 100644 --- a/internal/api/workspace_paths_test.go +++ b/internal/api/workspace_paths_test.go @@ -318,6 +318,26 @@ func (m *mockWPStore) GetAgentSummariesForSessions( panic("not implemented") } +func (m *mockWPStore) UpsertShare(_ context.Context, _ *types.Share) error { + panic("not implemented") +} + +func (m *mockWPStore) UpsertShares(_ context.Context, _ []*types.Share) error { + panic("not implemented") +} + +func (m *mockWPStore) GetShare(_ context.Context, _ string) (*types.Share, error) { + panic("not implemented") +} + +func (m *mockWPStore) ListShares(_ context.Context) ([]*types.Share, error) { + panic("not implemented") +} + +func (m *mockWPStore) DeleteShare(_ context.Context, _ string) error { + panic("not implemented") +} + func (m *mockWPStore) Close() error { return nil } func (m *mockWPStore) Path() string { return "" } diff --git a/internal/client/shares.go b/internal/client/shares.go new file mode 100644 index 0000000..d742f64 --- /dev/null +++ b/internal/client/shares.go @@ -0,0 +1,108 @@ +// Share keyring client methods — wrap the /api/v1/shares endpoints used by +// arc share commands to read/write the author's local keyring. +package client + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/sentiolabs/arc/internal/types" +) + +// ErrShareNotFound is returned by GetShare when the keyring has no entry +// for the given ID. Callers can use errors.Is to branch on this. +var ErrShareNotFound = errors.New("share not found") + +// ListShares returns all keyring entries (newest first). +func (c *Client) ListShares() ([]*types.Share, error) { + resp, err := c.get("/api/v1/shares") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var shares []*types.Share + if err := json.NewDecoder(resp.Body).Decode(&shares); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return shares, nil +} + +// GetShare returns the keyring entry for the given share ID. +// Returns ErrShareNotFound if the ID is not in the keyring. +func (c *Client) GetShare(id string) (*types.Share, error) { + req, err := http.NewRequest("GET", c.baseURL+"/api/v1/shares/"+id, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Actor", c.actor) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, ErrShareNotFound + } + + if err := c.checkError(resp); err != nil { + return nil, err + } + + var share types.Share + if err := json.NewDecoder(resp.Body).Decode(&share); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &share, nil +} + +// UpsertShare inserts or replaces a keyring entry. Returns the stored record. +func (c *Client) UpsertShare(share *types.Share) (*types.Share, error) { + resp, err := c.post("/api/v1/shares", share) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var stored types.Share + if err := json.NewDecoder(resp.Body).Decode(&stored); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &stored, nil +} + +// DeleteShare removes a keyring entry. Idempotent: no error if absent. +func (c *Client) DeleteShare(id string) error { + req, err := http.NewRequest("DELETE", c.baseURL+"/api/v1/shares/"+id, nil) + if err != nil { + return err + } + req.Header.Set("X-Actor", c.actor) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // 204 No Content is expected for successful delete + if resp.StatusCode == http.StatusNoContent { + return nil + } + + // 404 Not Found is also acceptable (idempotent delete) + if resp.StatusCode == http.StatusNotFound { + return nil + } + + // Any other non-2xx status is an error + if err := c.checkError(resp); err != nil { + return err + } + + return nil +} diff --git a/internal/client/shares_test.go b/internal/client/shares_test.go new file mode 100644 index 0000000..d4a7c76 --- /dev/null +++ b/internal/client/shares_test.go @@ -0,0 +1,174 @@ +package client_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sentiolabs/arc/internal/client" + "github.com/sentiolabs/arc/internal/types" +) + +func TestListShares(t *testing.T) { + c, cleanup := testClientServer(t) + defer cleanup() + + // Initially empty + shares, err := c.ListShares() + require.NoError(t, err) + assert.Empty(t, shares) +} + +func TestUpsertShare(t *testing.T) { + // Create a custom test server that captures the request body + var receivedBody types.Share + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/shares" && r.Method == http.MethodPost { + // Decode request body + if err := json.NewDecoder(r.Body).Decode(&receivedBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + // Return the share with CreatedAt set by server + responseShare := receivedBody + responseShare.CreatedAt = time.Now() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(responseShare); err != nil { + t.Errorf("encode response: %v", err) + } + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + c := client.New(srv.URL) + c.SetActor("test-user") + + share := &types.Share{ + ID: "share-123", + Kind: types.ShareKindShared, + URL: "https://example.com/share/abc", + KeyB64Url: "key_b64url_value", + EditToken: "token_value", + PlanFile: "plan.md", + } + + stored, err := c.UpsertShare(share) + require.NoError(t, err) + assert.Equal(t, "share-123", stored.ID) + assert.Equal(t, types.ShareKindShared, stored.Kind) + assert.Equal(t, "https://example.com/share/abc", stored.URL) + assert.Equal(t, "key_b64url_value", stored.KeyB64Url) + assert.Equal(t, "token_value", stored.EditToken) + assert.Equal(t, "plan.md", stored.PlanFile) + // CreatedAt should be set by server + assert.False(t, stored.CreatedAt.IsZero()) + + // Verify the request body matches the input share + assert.Equal(t, share.ID, receivedBody.ID, "request body ID mismatch") + assert.Equal(t, share.Kind, receivedBody.Kind, "request body Kind mismatch") + assert.Equal(t, share.URL, receivedBody.URL, "request body URL mismatch") + assert.Equal(t, share.KeyB64Url, receivedBody.KeyB64Url, "request body KeyB64Url mismatch") + assert.Equal(t, share.EditToken, receivedBody.EditToken, "request body EditToken mismatch") + assert.Equal(t, share.PlanFile, receivedBody.PlanFile, "request body PlanFile mismatch") +} + +func TestGetShare(t *testing.T) { + c, cleanup := testClientServer(t) + defer cleanup() + + share := &types.Share{ + ID: "share-456", + Kind: types.ShareKindLocal, + URL: "https://example.com/share/def", + KeyB64Url: "key_b64url_value", + EditToken: "token_value", + } + + _, err := c.UpsertShare(share) + require.NoError(t, err) + + retrieved, err := c.GetShare("share-456") + require.NoError(t, err) + assert.Equal(t, "share-456", retrieved.ID) + assert.Equal(t, types.ShareKindLocal, retrieved.Kind) + assert.Equal(t, "https://example.com/share/def", retrieved.URL) +} + +func TestGetShareNotFound(t *testing.T) { + c, cleanup := testClientServer(t) + defer cleanup() + + _, err := c.GetShare("nonexistent-id") + require.Error(t, err) + assert.ErrorIs(t, err, client.ErrShareNotFound) +} + +func TestDeleteShare(t *testing.T) { + c, cleanup := testClientServer(t) + defer cleanup() + + share := &types.Share{ + ID: "share-789", + Kind: types.ShareKindShared, + URL: "https://example.com/share/ghi", + KeyB64Url: "key_b64url_value", + EditToken: "token_value", + } + + _, err := c.UpsertShare(share) + require.NoError(t, err) + + err = c.DeleteShare("share-789") + require.NoError(t, err) + + // Verify it's deleted + _, err = c.GetShare("share-789") + require.Error(t, err) + assert.ErrorIs(t, err, client.ErrShareNotFound) +} + +func TestDeleteShareIdempotent(t *testing.T) { + c, cleanup := testClientServer(t) + defer cleanup() + + // Delete nonexistent share should not error (idempotent) + err := c.DeleteShare("nonexistent-id") + require.NoError(t, err) +} + +func TestListSharesMultiple(t *testing.T) { + c, cleanup := testClientServer(t) + defer cleanup() + + share1 := &types.Share{ + ID: "share-1", + Kind: types.ShareKindLocal, + URL: "https://example.com/1", + KeyB64Url: "key1", + EditToken: "token1", + } + + share2 := &types.Share{ + ID: "share-2", + Kind: types.ShareKindShared, + URL: "https://example.com/2", + KeyB64Url: "key2", + EditToken: "token2", + } + + _, err := c.UpsertShare(share1) + require.NoError(t, err) + + _, err = c.UpsertShare(share2) + require.NoError(t, err) + + shares, err := c.ListShares() + require.NoError(t, err) + assert.Len(t, shares, 2) +} diff --git a/internal/server/server.go b/internal/server/server.go index a355be6..b33e398 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "time" "github.com/sentiolabs/arc/internal/api" + "github.com/sentiolabs/arc/internal/sharesconfig" "github.com/sentiolabs/arc/internal/storage/sqlite" ) @@ -70,6 +71,19 @@ func Run(cfg Config) error { DB: store.DB(), }) + // One-shot import of legacy ~/.arc/shares.json keyring into the shares + // table. Idempotent — skips when the table already has rows. On success + // the JSON file is renamed to .bak so subsequent startups are no-ops. + if path, pathErr := sharesconfig.LegacyPath(); pathErr == nil { + n, importErr := server.ImportLegacySharesJSON(context.Background(), path) + switch { + case importErr != nil: + log.Printf("legacy shares.json import failed: %v", importErr) + case n > 0: + log.Printf("imported %d legacy share(s) from %s", n, path) + } + } + // Start server in goroutine errCh := make(chan error, 1) go func() { diff --git a/internal/sharesconfig/sharesconfig.go b/internal/sharesconfig/sharesconfig.go index 736d4b0..eb59021 100644 --- a/internal/sharesconfig/sharesconfig.go +++ b/internal/sharesconfig/sharesconfig.go @@ -1,26 +1,26 @@ -// Package sharesconfig manages the registry of paste shares the user has -// created, stored at ~/.arc/shares.json with file mode 0600. +// Package sharesconfig provides backward-compatible access to the user's +// share keyring. As of v0.next, the keyring is stored in arc-server's +// SQLite database (data.db); this package wraps the /api/v1/shares HTTP +// endpoints to preserve the existing public API used by cmd/arc/share.go. package sharesconfig import ( - "encoding/json" "errors" "os" "path/filepath" "time" -) -// File permission bits for the shares registry. shares.json holds edit -// tokens, so it must not be world-readable. -const ( - dirMode os.FileMode = 0o700 - fileMode os.FileMode = 0o600 + "github.com/sentiolabs/arc/internal/client" + "github.com/sentiolabs/arc/internal/types" ) // ErrShareNotFound is returned by Find when no share matches the given ID. +// Preserved for backward compatibility with existing callers. var ErrShareNotFound = errors.New("share not found") -// Share holds the metadata for a single paste share created by this machine. +// Share is the public representation of a keyring entry. Field names +// match the legacy shares.json on-disk format, so callers built against +// the JSON-backed implementation continue to compile. type Share struct { ID string `json:"id"` Kind string `json:"kind"` // "local" | "shared" @@ -31,13 +31,43 @@ type Share struct { CreatedAt time.Time `json:"created_at"` } -// File is the top-level structure of ~/.arc/shares.json. +// File preserves the legacy shape that callers iterate over. type File struct { Shares []Share `json:"shares"` } -// defaultPath returns the path to ~/.arc/shares.json. -func defaultPath() (string, error) { +// Client is the minimal HTTP client surface this package uses. +// The real implementation is internal/client.Client; tests inject fakes. +type Client interface { + ListShares() ([]*types.Share, error) + GetShare(id string) (*types.Share, error) + UpsertShare(share *types.Share) (*types.Share, error) + DeleteShare(id string) error +} + +// clientFactory is injected at process startup (typically from cmd/arc/main.go). +// Tests use SetClientFactory to inject fakes. +var clientFactory func() (Client, error) + +// SetClientFactory installs the function used to obtain the HTTP client. +// Must be called before any sharesconfig package function is invoked. +func SetClientFactory(fn func() (Client, error)) { + clientFactory = fn +} + +// getClient is the package-internal accessor; errors clearly if the factory +// hasn't been set, so misuse is loud. +func getClient() (Client, error) { + if clientFactory == nil { + return nil, errors.New("sharesconfig: client factory not initialized: call SetClientFactory in main") + } + return clientFactory() +} + +// LegacyPath returns the path to the legacy ~/.arc/shares.json file. +// Used by the server-side import logic to find the file at startup. +// The CLI no longer reads this path. +func LegacyPath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err @@ -45,89 +75,80 @@ func defaultPath() (string, error) { return filepath.Join(home, ".arc", "shares.json"), nil } -// Load reads the shares file from disk. Returns an empty File if the file does -// not exist yet. +// Load returns all keyring entries, matching the legacy contract. func Load() (*File, error) { - path, err := defaultPath() + c, err := getClient() if err != nil { return nil, err } - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return &File{}, nil - } + remote, err := c.ListShares() if err != nil { return nil, err } - var f File - if err := json.Unmarshal(data, &f); err != nil { - return nil, err + f := &File{Shares: make([]Share, 0, len(remote))} + for _, r := range remote { + f.Shares = append(f.Shares, fromTypes(r)) } - return &f, nil + return f, nil } -// Save writes the File to disk at ~/.arc/shares.json with mode 0600. The -// parent directory is created with mode 0700 if it does not exist. -func Save(f *File) error { - path, err := defaultPath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), dirMode); err != nil { - return err - } - data, err := json.MarshalIndent(f, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, fileMode) -} - -// Add upserts a Share into the registry. If a share with the same ID already -// exists it is replaced; otherwise the share is appended. +// Add upserts a Share into the keyring. func Add(s Share) error { - f, err := Load() + c, err := getClient() if err != nil { return err } - for i, existing := range f.Shares { - if existing.ID == s.ID { - f.Shares[i] = s - return Save(f) - } - } - f.Shares = append(f.Shares, s) - return Save(f) + _, err = c.UpsertShare(toTypes(s)) + return err } -// Find returns the Share with the given ID, or ErrShareNotFound if no entry -// matches. Callers may also use errors.Is(err, ErrShareNotFound) to branch. +// Find returns the keyring entry for the given share ID, or +// ErrShareNotFound if no entry matches. func Find(id string) (*Share, error) { - f, err := Load() + c, err := getClient() if err != nil { return nil, err } - for _, s := range f.Shares { - if s.ID == id { - return &s, nil + remote, err := c.GetShare(id) + if err != nil { + if errors.Is(err, client.ErrShareNotFound) { + return nil, ErrShareNotFound } + return nil, err } - return nil, ErrShareNotFound + s := fromTypes(remote) + return &s, nil } -// Remove deletes the share with the given ID from the registry. It is a no-op -// if the ID does not exist. +// Remove deletes the keyring entry for the given ID. No-op if missing. func Remove(id string) error { - f, err := Load() + c, err := getClient() if err != nil { return err } - out := f.Shares[:0] - for _, s := range f.Shares { - if s.ID != id { - out = append(out, s) - } + return c.DeleteShare(id) +} + +func toTypes(s Share) *types.Share { + return &types.Share{ + ID: s.ID, + Kind: types.ShareKind(s.Kind), + URL: s.URL, + KeyB64Url: s.KeyB64Url, + EditToken: s.EditToken, + PlanFile: s.PlanFile, + CreatedAt: s.CreatedAt, + } +} + +func fromTypes(t *types.Share) Share { + return Share{ + ID: t.ID, + Kind: string(t.Kind), + URL: t.URL, + KeyB64Url: t.KeyB64Url, + EditToken: t.EditToken, + PlanFile: t.PlanFile, + CreatedAt: t.CreatedAt, } - f.Shares = out - return Save(f) } diff --git a/internal/sharesconfig/sharesconfig_test.go b/internal/sharesconfig/sharesconfig_test.go index bf22230..4318169 100644 --- a/internal/sharesconfig/sharesconfig_test.go +++ b/internal/sharesconfig/sharesconfig_test.go @@ -1,49 +1,117 @@ package sharesconfig_test import ( - "os" - "path/filepath" + "errors" + "strings" "testing" "time" + "github.com/sentiolabs/arc/internal/client" "github.com/sentiolabs/arc/internal/sharesconfig" + "github.com/sentiolabs/arc/internal/types" ) -func TestAddAndFind(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - s := sharesconfig.Share{ - ID: "abc", Kind: "local", URL: "http://x", - KeyB64Url: "k", EditToken: "t", CreatedAt: time.Now(), +type fakeClient struct { + store map[string]*types.Share +} + +func newFakeClient() *fakeClient { + return &fakeClient{store: map[string]*types.Share{}} +} + +func (f *fakeClient) ListShares() ([]*types.Share, error) { + out := make([]*types.Share, 0, len(f.store)) + for _, s := range f.store { + out = append(out, s) + } + return out, nil +} + +func (f *fakeClient) GetShare(id string) (*types.Share, error) { + s, ok := f.store[id] + if !ok { + return nil, client.ErrShareNotFound } + return s, nil +} + +func (f *fakeClient) UpsertShare(s *types.Share) (*types.Share, error) { + f.store[s.ID] = s + return s, nil +} + +func (f *fakeClient) DeleteShare(id string) error { + delete(f.store, id) + return nil +} + +func withFake(t *testing.T) *fakeClient { + t.Helper() + fake := newFakeClient() + sharesconfig.SetClientFactory(func() (sharesconfig.Client, error) { return fake, nil }) + t.Cleanup(func() { sharesconfig.SetClientFactory(nil) }) + return fake +} + +func TestAddAndFind(t *testing.T) { + withFake(t) + s := sharesconfig.Share{ID: "x", Kind: "local", URL: "u", KeyB64Url: "k", EditToken: "t", CreatedAt: time.Now()} if err := sharesconfig.Add(s); err != nil { - t.Fatal(err) + t.Fatalf("add: %v", err) + } + got, err := sharesconfig.Find("x") + if err != nil { + t.Fatalf("find: %v", err) + } + if got.ID != "x" || got.URL != "u" { + t.Errorf("unexpected: %+v", got) } - found, err := sharesconfig.Find("abc") - if err != nil || found == nil || found.ID != "abc" { - t.Errorf("unexpected: %+v err=%v", found, err) +} + +func TestFindNotFound(t *testing.T) { + withFake(t) + _, err := sharesconfig.Find("missing") + if !errors.Is(err, sharesconfig.ErrShareNotFound) { + t.Errorf("expected ErrShareNotFound, got %v", err) } } -func TestFileMode0600(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - _ = sharesconfig.Add(sharesconfig.Share{ID: "x", Kind: "local", CreatedAt: time.Now()}) - info, err := os.Stat(filepath.Join(home, ".arc", "shares.json")) +func TestLoadEmpty(t *testing.T) { + withFake(t) + f, err := sharesconfig.Load() if err != nil { - t.Fatal(err) + t.Fatalf("load: %v", err) } - if info.Mode().Perm() != 0o600 { - t.Errorf("expected mode 0600, got %o", info.Mode().Perm()) + if len(f.Shares) != 0 { + t.Errorf("expected empty, got %d", len(f.Shares)) } } func TestRemove(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - _ = sharesconfig.Add(sharesconfig.Share{ID: "a", CreatedAt: time.Now()}) - _ = sharesconfig.Add(sharesconfig.Share{ID: "b", CreatedAt: time.Now()}) - _ = sharesconfig.Remove("a") - f, _ := sharesconfig.Load() - if len(f.Shares) != 1 || f.Shares[0].ID != "b" { - t.Errorf("after remove, got %+v", f.Shares) + fake := withFake(t) + fake.store["x"] = &types.Share{ID: "x"} + if err := sharesconfig.Remove("x"); err != nil { + t.Fatalf("remove: %v", err) + } + if _, ok := fake.store["x"]; ok { + t.Errorf("expected entry removed") + } +} + +func TestLegacyPath(t *testing.T) { + p, err := sharesconfig.LegacyPath() + if err != nil { + t.Fatalf("legacy path: %v", err) + } + if p == "" || !strings.HasSuffix(p, "/.arc/shares.json") { + t.Errorf("unexpected legacy path: %s", p) + } +} + +func TestNoFactorySet(t *testing.T) { + sharesconfig.SetClientFactory(nil) + _, err := sharesconfig.Load() + if err == nil { + t.Error("expected error when factory not set, got nil") } } diff --git a/internal/storage/sqlite/db/models.go b/internal/storage/sqlite/db/models.go index 4ccb3f7..6cd7085 100644 --- a/internal/storage/sqlite/db/models.go +++ b/internal/storage/sqlite/db/models.go @@ -135,6 +135,16 @@ type Project struct { UpdatedAt time.Time `json:"updated_at"` } +type Share struct { + ID string `json:"id"` + Kind string `json:"kind"` + Url string `json:"url"` + KeyB64url string `json:"key_b64url"` + EditToken string `json:"edit_token"` + PlanFile sql.NullString `json:"plan_file"` + CreatedAt time.Time `json:"created_at"` +} + type Workspace struct { ID string `json:"id"` ProjectID string `json:"project_id"` diff --git a/internal/storage/sqlite/db/queries/shares.sql b/internal/storage/sqlite/db/queries/shares.sql new file mode 100644 index 0000000..3cfd2ea --- /dev/null +++ b/internal/storage/sqlite/db/queries/shares.sql @@ -0,0 +1,19 @@ +-- name: UpsertShare :exec +INSERT INTO shares (id, kind, url, key_b64url, edit_token, plan_file, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + kind = excluded.kind, + url = excluded.url, + key_b64url = excluded.key_b64url, + edit_token = excluded.edit_token, + plan_file = excluded.plan_file, + created_at = excluded.created_at; + +-- name: GetShare :one +SELECT * FROM shares WHERE id = ?; + +-- name: ListShares :many +SELECT * FROM shares ORDER BY created_at DESC; + +-- name: DeleteShare :exec +DELETE FROM shares WHERE id = ?; diff --git a/internal/storage/sqlite/db/schema.sql b/internal/storage/sqlite/db/schema.sql index 905637c..3b735e5 100644 --- a/internal/storage/sqlite/db/schema.sql +++ b/internal/storage/sqlite/db/schema.sql @@ -197,3 +197,16 @@ CREATE TABLE ai_agents ( ); CREATE INDEX idx_ai_agents_session ON ai_agents(session_id); + +-- Shares (author-side keyring) +CREATE TABLE shares ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('local', 'shared')), + url TEXT NOT NULL, + key_b64url TEXT NOT NULL, + edit_token TEXT NOT NULL, + plan_file TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_shares_created_at ON shares(created_at DESC); diff --git a/internal/storage/sqlite/db/shares.sql.go b/internal/storage/sqlite/db/shares.sql.go new file mode 100644 index 0000000..03c325a --- /dev/null +++ b/internal/storage/sqlite/db/shares.sql.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: shares.sql + +package db + +import ( + "context" + "database/sql" + "time" +) + +const deleteShare = `-- name: DeleteShare :exec +DELETE FROM shares WHERE id = ? +` + +func (q *Queries) DeleteShare(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteShare, id) + return err +} + +const getShare = `-- name: GetShare :one +SELECT id, kind, url, key_b64url, edit_token, plan_file, created_at FROM shares WHERE id = ? +` + +func (q *Queries) GetShare(ctx context.Context, id string) (*Share, error) { + row := q.db.QueryRowContext(ctx, getShare, id) + var i Share + err := row.Scan( + &i.ID, + &i.Kind, + &i.Url, + &i.KeyB64url, + &i.EditToken, + &i.PlanFile, + &i.CreatedAt, + ) + return &i, err +} + +const listShares = `-- name: ListShares :many +SELECT id, kind, url, key_b64url, edit_token, plan_file, created_at FROM shares ORDER BY created_at DESC +` + +func (q *Queries) ListShares(ctx context.Context) ([]*Share, error) { + rows, err := q.db.QueryContext(ctx, listShares) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*Share{} + for rows.Next() { + var i Share + if err := rows.Scan( + &i.ID, + &i.Kind, + &i.Url, + &i.KeyB64url, + &i.EditToken, + &i.PlanFile, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertShare = `-- name: UpsertShare :exec +INSERT INTO shares (id, kind, url, key_b64url, edit_token, plan_file, created_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + kind = excluded.kind, + url = excluded.url, + key_b64url = excluded.key_b64url, + edit_token = excluded.edit_token, + plan_file = excluded.plan_file, + created_at = excluded.created_at +` + +type UpsertShareParams struct { + ID string `json:"id"` + Kind string `json:"kind"` + Url string `json:"url"` + KeyB64url string `json:"key_b64url"` + EditToken string `json:"edit_token"` + PlanFile sql.NullString `json:"plan_file"` + CreatedAt time.Time `json:"created_at"` +} + +func (q *Queries) UpsertShare(ctx context.Context, arg UpsertShareParams) error { + _, err := q.db.ExecContext(ctx, upsertShare, + arg.ID, + arg.Kind, + arg.Url, + arg.KeyB64url, + arg.EditToken, + arg.PlanFile, + arg.CreatedAt, + ) + return err +} diff --git a/internal/storage/sqlite/migrations/017_shares.sql b/internal/storage/sqlite/migrations/017_shares.sql new file mode 100644 index 0000000..bdd446c --- /dev/null +++ b/internal/storage/sqlite/migrations/017_shares.sql @@ -0,0 +1,16 @@ +-- +goose Up +CREATE TABLE shares ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('local', 'shared')), + url TEXT NOT NULL, + key_b64url TEXT NOT NULL, + edit_token TEXT NOT NULL, + plan_file TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_shares_created_at ON shares(created_at DESC); + +-- +goose Down +DROP INDEX IF EXISTS idx_shares_created_at; +DROP TABLE IF EXISTS shares; diff --git a/internal/storage/sqlite/shares.go b/internal/storage/sqlite/shares.go new file mode 100644 index 0000000..81895ff --- /dev/null +++ b/internal/storage/sqlite/shares.go @@ -0,0 +1,118 @@ +// Package sqlite implements the storage interface using SQLite. +// This file handles share keyring operations. +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/sentiolabs/arc/internal/storage" + "github.com/sentiolabs/arc/internal/storage/sqlite/db" + "github.com/sentiolabs/arc/internal/types" +) + +// UpsertShare inserts or replaces a share record. Stamps CreatedAt to now +// when callers omit it, so HTTP handlers and the legacy import path don't +// each need their own default. +func (s *Store) UpsertShare(ctx context.Context, share *types.Share) error { + return s.upsertShareWith(ctx, s.queries, share) +} + +// UpsertShares atomically upserts a batch of shares in a single transaction. +// All-or-nothing: a validation or constraint failure on any entry rolls the +// whole batch back so callers can fix the bad entry and retry without first +// having to clean up partial state. +func (s *Store) UpsertShares(ctx context.Context, shares []*types.Share) error { + if len(shares) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + qtx := s.queries.WithTx(tx) + for _, share := range shares { + if err := s.upsertShareWith(ctx, qtx, share); err != nil { + return err + } + } + return tx.Commit() +} + +// upsertShareWith is the shared body of UpsertShare and UpsertShares. The qtx +// argument lets the caller pick between the bare queries and a transactional +// view (queries.WithTx). +func (s *Store) upsertShareWith(ctx context.Context, qtx *db.Queries, share *types.Share) error { + if share.CreatedAt.IsZero() { + share.CreatedAt = time.Now().UTC() + } + if err := share.Validate(); err != nil { + return fmt.Errorf("upsert share: %w", err) + } + err := qtx.UpsertShare(ctx, db.UpsertShareParams{ + ID: share.ID, + Kind: string(share.Kind), + Url: share.URL, + KeyB64url: share.KeyB64Url, + EditToken: share.EditToken, + PlanFile: toNullString(share.PlanFile), + CreatedAt: share.CreatedAt.UTC(), + }) + if err != nil { + return fmt.Errorf("upsert share: %w", err) + } + return nil +} + +// GetShare retrieves a share by ID. +// Returns storage.ErrShareNotFound if the ID does not exist. +func (s *Store) GetShare(ctx context.Context, id string) (*types.Share, error) { + row, err := s.queries.GetShare(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, storage.ErrShareNotFound + } + return nil, fmt.Errorf("get share: %w", err) + } + return rowToShare(row), nil +} + +// ListShares returns all shares ordered by created_at DESC (newest first). +func (s *Store) ListShares(ctx context.Context) ([]*types.Share, error) { + rows, err := s.queries.ListShares(ctx) + if err != nil { + return nil, fmt.Errorf("list shares: %w", err) + } + out := make([]*types.Share, len(rows)) + for i, r := range rows { + out[i] = rowToShare(r) + } + return out, nil +} + +// DeleteShare removes a share by ID. +// Idempotent: no error is returned if the ID does not exist. +func (s *Store) DeleteShare(ctx context.Context, id string) error { + if err := s.queries.DeleteShare(ctx, id); err != nil { + return fmt.Errorf("delete share: %w", err) + } + return nil +} + +// rowToShare converts a db.Share row to a types.Share. +func rowToShare(r *db.Share) *types.Share { + return &types.Share{ + ID: r.ID, + Kind: types.ShareKind(r.Kind), + URL: r.Url, + KeyB64Url: r.KeyB64url, + EditToken: r.EditToken, + PlanFile: fromNullString(r.PlanFile), + CreatedAt: r.CreatedAt.UTC(), + } +} diff --git a/internal/storage/sqlite/shares_test.go b/internal/storage/sqlite/shares_test.go new file mode 100644 index 0000000..30950af --- /dev/null +++ b/internal/storage/sqlite/shares_test.go @@ -0,0 +1,275 @@ +package sqlite_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/sentiolabs/arc/internal/storage" + "github.com/sentiolabs/arc/internal/types" +) + +func makeTestShare(id string) *types.Share { + return &types.Share{ + ID: id, + Kind: types.ShareKindLocal, + URL: "https://example.com/paste/" + id, + KeyB64Url: "dGVzdGtleQ==", + EditToken: "edit-token-" + id, + CreatedAt: time.Now().UTC().Truncate(time.Second), + } +} + +func TestUpsertShare_Insert(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + share := makeTestShare("share-insert-1") + + err := store.UpsertShare(ctx, share) + if err != nil { + t.Fatalf("UpsertShare() error = %v", err) + } + + got, err := store.GetShare(ctx, share.ID) + if err != nil { + t.Fatalf("GetShare() after insert error = %v", err) + } + + if got.ID != share.ID { + t.Errorf("ID = %q, want %q", got.ID, share.ID) + } + if got.Kind != share.Kind { + t.Errorf("Kind = %q, want %q", got.Kind, share.Kind) + } + if got.URL != share.URL { + t.Errorf("URL = %q, want %q", got.URL, share.URL) + } + if got.KeyB64Url != share.KeyB64Url { + t.Errorf("KeyB64Url = %q, want %q", got.KeyB64Url, share.KeyB64Url) + } + if got.EditToken != share.EditToken { + t.Errorf("EditToken = %q, want %q", got.EditToken, share.EditToken) + } + if got.PlanFile != share.PlanFile { + t.Errorf("PlanFile = %q, want %q", got.PlanFile, share.PlanFile) + } + if !got.CreatedAt.Equal(share.CreatedAt) { + t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, share.CreatedAt) + } +} + +func TestUpsertShare_Replace(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + share := makeTestShare("share-replace-1") + + // Insert first + if err := store.UpsertShare(ctx, share); err != nil { + t.Fatalf("UpsertShare() first insert error = %v", err) + } + + // Update the same ID with different fields + updated := &types.Share{ + ID: share.ID, + Kind: types.ShareKindShared, + URL: "https://example.com/paste/updated", + KeyB64Url: "dXBkYXRlZGtleQ==", + EditToken: "updated-edit-token", + PlanFile: "/path/to/plan.md", + CreatedAt: share.CreatedAt.Add(time.Minute), + } + if err := store.UpsertShare(ctx, updated); err != nil { + t.Fatalf("UpsertShare() second upsert error = %v", err) + } + + // GetShare should return the second version + got, err := store.GetShare(ctx, share.ID) + if err != nil { + t.Fatalf("GetShare() after upsert error = %v", err) + } + + if got.Kind != updated.Kind { + t.Errorf("Kind = %q, want %q", got.Kind, updated.Kind) + } + if got.URL != updated.URL { + t.Errorf("URL = %q, want %q", got.URL, updated.URL) + } + if got.KeyB64Url != updated.KeyB64Url { + t.Errorf("KeyB64Url = %q, want %q", got.KeyB64Url, updated.KeyB64Url) + } + if got.EditToken != updated.EditToken { + t.Errorf("EditToken = %q, want %q", got.EditToken, updated.EditToken) + } + if got.PlanFile != updated.PlanFile { + t.Errorf("PlanFile = %q, want %q", got.PlanFile, updated.PlanFile) + } +} + +func TestUpsertShare_ValidationError(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + + // Empty ID should fail validation + invalid := &types.Share{ + ID: "", + Kind: types.ShareKindLocal, + URL: "https://example.com/paste/x", + KeyB64Url: "dGVzdA==", + EditToken: "tok", + } + err := store.UpsertShare(ctx, invalid) + if err == nil { + t.Fatal("UpsertShare() expected error for empty ID, got nil") + } +} + +func TestGetShare_Found(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + share := makeTestShare("share-get-1") + + if err := store.UpsertShare(ctx, share); err != nil { + t.Fatalf("UpsertShare() error = %v", err) + } + + got, err := store.GetShare(ctx, share.ID) + if err != nil { + t.Fatalf("GetShare() error = %v", err) + } + if got.ID != share.ID { + t.Errorf("ID = %q, want %q", got.ID, share.ID) + } +} + +func TestGetShare_NotFound(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + + _, err := store.GetShare(ctx, "nonexistent-share-id") + if err == nil { + t.Fatal("GetShare() expected error for missing ID, got nil") + } + if !errors.Is(err, storage.ErrShareNotFound) { + t.Errorf("GetShare() error = %v, want errors.Is(err, storage.ErrShareNotFound) to be true", err) + } +} + +func TestListShares_OrderedByCreatedAtDesc(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + + older := &types.Share{ + ID: "share-older", + Kind: types.ShareKindLocal, + URL: "https://example.com/paste/older", + KeyB64Url: "b2xkZXJrZXk=", + EditToken: "edit-older", + CreatedAt: baseTime, + } + newer := &types.Share{ + ID: "share-newer", + Kind: types.ShareKindShared, + URL: "https://example.com/paste/newer", + KeyB64Url: "bmV3ZXJrZXk=", + EditToken: "edit-newer", + CreatedAt: baseTime.Add(time.Hour), + } + + // Insert older first, then newer + if err := store.UpsertShare(ctx, older); err != nil { + t.Fatalf("UpsertShare(older) error = %v", err) + } + if err := store.UpsertShare(ctx, newer); err != nil { + t.Fatalf("UpsertShare(newer) error = %v", err) + } + + list, err := store.ListShares(ctx) + if err != nil { + t.Fatalf("ListShares() error = %v", err) + } + if len(list) != 2 { + t.Fatalf("ListShares() returned %d items, want 2", len(list)) + } + + // Newest first + if list[0].ID != newer.ID { + t.Errorf("list[0].ID = %q, want %q (newest first)", list[0].ID, newer.ID) + } + if list[1].ID != older.ID { + t.Errorf("list[1].ID = %q, want %q (oldest last)", list[1].ID, older.ID) + } +} + +func TestListShares_Empty(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + + list, err := store.ListShares(ctx) + if err != nil { + t.Fatalf("ListShares() empty table error = %v", err) + } + if list == nil { + t.Error("ListShares() returned nil slice, want empty non-nil slice") + } + if len(list) != 0 { + t.Errorf("ListShares() returned %d items, want 0", len(list)) + } +} + +func TestDeleteShare_Removes(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + share := makeTestShare("share-delete-1") + + if err := store.UpsertShare(ctx, share); err != nil { + t.Fatalf("UpsertShare() error = %v", err) + } + + // Verify it exists + if _, err := store.GetShare(ctx, share.ID); err != nil { + t.Fatalf("GetShare() before delete error = %v", err) + } + + // Delete + if err := store.DeleteShare(ctx, share.ID); err != nil { + t.Fatalf("DeleteShare() error = %v", err) + } + + // Verify it's gone + _, err := store.GetShare(ctx, share.ID) + if !errors.Is(err, storage.ErrShareNotFound) { + t.Errorf("GetShare() after delete: got %v, want storage.ErrShareNotFound", err) + } +} + +func TestDeleteShare_Idempotent(t *testing.T) { + store, cleanup := setupTestStore(t) + defer cleanup() + + ctx := context.Background() + + // Delete an ID that never existed — should not error + err := store.DeleteShare(ctx, "nonexistent-share-id") + if err != nil { + t.Errorf("DeleteShare() on missing ID error = %v, want nil", err) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 75f73f5..faee4cd 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -3,10 +3,14 @@ package storage import ( "context" + "errors" "github.com/sentiolabs/arc/internal/types" ) +// ErrShareNotFound is returned when a requested share does not exist. +var ErrShareNotFound = errors.New("share not found") + //nolint:interfacebloat // Storage interface intentionally covers all operations as a single contract type Storage interface { // Projects @@ -87,6 +91,13 @@ type Storage interface { ListAIAgents(ctx context.Context, sessionID string) ([]*types.AIAgent, error) GetAgentSummariesForSessions(ctx context.Context, sessionIDs []string) (map[string]*types.AgentSummary, error) + // Shares (author-side keyring) + UpsertShare(ctx context.Context, share *types.Share) error + UpsertShares(ctx context.Context, shares []*types.Share) error + GetShare(ctx context.Context, id string) (*types.Share, error) + ListShares(ctx context.Context) ([]*types.Share, error) + DeleteShare(ctx context.Context, id string) error + // Events (audit trail) GetEvents(ctx context.Context, issueID string, limit int) ([]*types.Event, error) diff --git a/internal/types/shares_test.go b/internal/types/shares_test.go new file mode 100644 index 0000000..270219b --- /dev/null +++ b/internal/types/shares_test.go @@ -0,0 +1,79 @@ +package types_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/sentiolabs/arc/internal/types" +) + +// --- Contract assertions --- +// Verify Share JSON tag stability — wire format must stay backward-compatible +// with the legacy shares.json field names so the one-shot import is 1:1. +func TestShareJSONTags(t *testing.T) { + b, _ := json.Marshal(types.Share{ + ID: "x", Kind: types.ShareKindLocal, URL: "u", + KeyB64Url: "k", EditToken: "t", PlanFile: "p", + }) + for _, want := range []string{ + `"id"`, `"kind"`, `"url"`, `"key_b64url"`, + `"edit_token"`, `"plan_file"`, `"created_at"`, + } { + if !strings.Contains(string(b), want) { + t.Errorf("missing JSON tag %s in %s", want, b) + } + } +} + +func TestShareKindIsValid(t *testing.T) { + cases := []struct { + kind types.ShareKind + want bool + }{ + {types.ShareKindLocal, true}, + {types.ShareKindShared, true}, + {"", false}, + {"bogus", false}, + } + for _, tc := range cases { + if got := tc.kind.IsValid(); got != tc.want { + t.Errorf("ShareKind(%q).IsValid() = %v, want %v", tc.kind, got, tc.want) + } + } +} + +func TestShareValidate(t *testing.T) { + valid := types.Share{ID: "id", Kind: types.ShareKindLocal, URL: "u", KeyB64Url: "k", EditToken: "t"} + if err := valid.Validate(); err != nil { + t.Fatalf("valid share: unexpected error: %v", err) + } + cases := map[string]types.Share{ + "missing id": {Kind: types.ShareKindLocal, URL: "u", KeyB64Url: "k", EditToken: "t"}, + "invalid kind": {ID: "id", Kind: "x", URL: "u", KeyB64Url: "k", EditToken: "t"}, + "missing url": {ID: "id", Kind: types.ShareKindLocal, KeyB64Url: "k", EditToken: "t"}, + "missing key_b64url": {ID: "id", Kind: types.ShareKindLocal, URL: "u", EditToken: "t"}, + "missing edit_token": {ID: "id", Kind: types.ShareKindLocal, URL: "u", KeyB64Url: "k"}, + } + for name, s := range cases { + if err := s.Validate(); err == nil { + t.Errorf("%s: expected error, got nil", name) + } + } +} + +func TestAllShareKinds(t *testing.T) { + kinds := types.AllShareKinds() + if len(kinds) != 2 { + t.Fatalf("expected 2 kinds, got %d", len(kinds)) + } + found := map[types.ShareKind]bool{} + for _, k := range kinds { + found[k] = true + } + for _, want := range []types.ShareKind{types.ShareKindLocal, types.ShareKindShared} { + if !found[want] { + t.Errorf("AllShareKinds missing %q", want) + } + } +} diff --git a/internal/types/types.go b/internal/types/types.go index be85c1e..d5a0413 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -345,6 +345,55 @@ type MergeResult struct { SourcesDeleted []string `json:"sources_deleted"` } +// ShareKind distinguishes local-only shares from hosted (published) shares. +type ShareKind string + +const ( + ShareKindLocal ShareKind = "local" + ShareKindShared ShareKind = "shared" +) + +// IsValid checks if the share kind value is valid. +func (k ShareKind) IsValid() bool { + return k == ShareKindLocal || k == ShareKindShared +} + +// AllShareKinds returns all valid share kind values. +func AllShareKinds() []ShareKind { + return []ShareKind{ShareKindLocal, ShareKindShared} +} + +// Share represents an entry in the author-side keyring of paste shares created on this machine. +type Share struct { + ID string `json:"id"` + Kind ShareKind `json:"kind"` + URL string `json:"url"` + KeyB64Url string `json:"key_b64url"` + EditToken string `json:"edit_token"` + PlanFile string `json:"plan_file,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Validate checks if the share has valid field values. +func (s *Share) Validate() error { + if s.ID == "" { + return errors.New("share: id is required") + } + if !s.Kind.IsValid() { + return fmt.Errorf("share: invalid kind %q", s.Kind) + } + if s.URL == "" { + return errors.New("share: url is required") + } + if s.KeyB64Url == "" { + return errors.New("share: key_b64url is required") + } + if s.EditToken == "" { + return errors.New("share: edit_token is required") + } + return nil +} + // Workspace represents a directory path associated with a project. // Multiple workspaces can be linked to a single project to support multi-directory projects. // Previously named WorkspacePath; renamed because this IS the workspace (a directory where work happens). diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index e992ef9..5fc81e0 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -488,6 +488,45 @@ export interface paths { patch?: never; trace?: never; }; + "/shares": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List authored shares from the local keyring */ + get: operations["listShares"]; + put?: never; + /** Insert or replace a share keyring entry */ + post: operations["upsertShare"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/shares/{shareId}": { + parameters: { + query?: never; + header?: never; + path: { + /** @description Share ID (server-generated by the paste host) */ + shareId: string; + }; + cookie?: never; + }; + /** Get a single share keyring entry */ + get: operations["getShare"]; + put?: never; + post?: never; + /** Remove a share from the keyring (idempotent) */ + delete: operations["deleteShare"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/projects/{projectId}/issues/{issueId}/labels": { parameters: { query?: never; @@ -874,6 +913,26 @@ export interface components { AddLabelToIssueRequest: { label: string; }; + Share: { + id: string; + kind: components["schemas"]["ShareKind"]; + url: string; + key_b64url: string; + edit_token: string; + plan_file?: string; + /** Format: date-time */ + created_at: string; + }; + /** @enum {string} */ + ShareKind: "local" | "shared"; + UpsertShareRequest: { + id: string; + kind: components["schemas"]["ShareKind"]; + url: string; + key_b64url: string; + edit_token: string; + plan_file?: string; + }; Comment: { /** Format: int64 */ id: number; @@ -2058,6 +2117,100 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + listShares: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of shares (newest first) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Share"][]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + upsertShare: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpsertShareRequest"]; + }; + }; + responses: { + /** @description Share stored */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Share"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 500: components["responses"]["InternalError"]; + }; + }; + getShare: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Share ID (server-generated by the paste host) */ + shareId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Share record */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Share"]; + }; + }; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalError"]; + }; + }; + deleteShare: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Share ID (server-generated by the paste host) */ + shareId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Share removed (or absent — same response) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 500: components["responses"]["InternalError"]; + }; + }; addLabelToIssue: { parameters: { query?: never;