diff --git a/src/Share/Web/Errors.hs b/src/Share/Web/Errors.hs
index 03a39095..238cf7fb 100644
--- a/src/Share/Web/Errors.hs
+++ b/src/Share/Web/Errors.hs
@@ -18,6 +18,7 @@ module Share.Web.Errors
MissingExpectedEntity (..),
Unimplemented (..),
BadRequest (..),
+ Forbidden (..),
InvalidParam (..),
NotAuthorized (..),
ErrorID (..),
@@ -314,6 +315,15 @@ instance ToServerError BadRequest where
instance Loggable BadRequest where
toLog (BadRequest msg) = withSeverity UserFault . textLog $ msg
+data Forbidden = Forbidden Text
+ deriving (Eq, Show)
+
+instance ToServerError Forbidden where
+ toServerError (Forbidden msg) = (ErrorID "forbidden", err403 {errBody = BL.fromStrict . Text.encodeUtf8 $ msg})
+
+instance Loggable Forbidden where
+ toLog (Forbidden msg) = withSeverity UserFault . textLog $ msg
+
data InvalidParam = InvalidParam {paramName :: Text, param :: Text, parseError :: Text}
instance ToServerError InvalidParam where
diff --git a/src/Share/Web/Share/Projects/Impl.hs b/src/Share/Web/Share/Projects/Impl.hs
index 1df6fe37..1278af0e 100644
--- a/src/Share/Web/Share/Projects/Impl.hs
+++ b/src/Share/Web/Share/Projects/Impl.hs
@@ -31,7 +31,7 @@ import Share.Postgres.Projects.Queries qualified as ProjectsQ
import Share.Postgres.Queries qualified as Q
import Share.Postgres.Releases.Queries qualified as RQ
import Share.Prelude
-import Share.Project (Project (..))
+import Share.Project (Project (..), ProjectVisibility (ProjectPrivate))
import Share.Release qualified as Release
import Share.User (User (..))
import Share.Utils.API ((:++) (..))
@@ -48,6 +48,8 @@ import Share.Web.Share.Branches.Impl (branchesServer, getProjectBranchReadmeEndp
import Share.Web.Share.Contributions.Impl (contributionsByProjectServer)
import Share.Web.Share.Diffs.Impl qualified as Diffs
import Share.Web.Share.Diffs.Types (ShareNamespaceDiffResponse (..), ShareNamespaceDiffStatus (..), ShareTermDiffResponse (..), ShareTypeDiffResponse (..))
+import Share.Web.Share.Orgs.Queries qualified as OrgQ
+import Share.Web.Share.Orgs.Types (Org (..))
import Share.Web.Share.Projects.API qualified as API
import Share.Web.Share.Projects.Types
import Share.Web.Share.Releases.Impl (getProjectReleaseReadmeEndpoint, releasesServer)
@@ -320,6 +322,13 @@ updateProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle projectS
addRequestTag "project-id" (IDs.toText projectId)
AuthZ.permissionGuard $ AuthZ.checkProjectUpdate callerUserId projectId
let UpdateProjectRequest {summary, tags, visibility} = req
+ when (visibility == Just ProjectPrivate) $ do
+ mayOrg <- PG.runTransaction (OrgQ.orgByUserHandle userHandle)
+ case mayOrg of
+ Just Org {isCommercial = False} -> do
+ respondError $ Forbidden "Please upgrade to a commercial org to enable private projects."
+ _ -> pure ()
+
success <- PG.runTransaction $ Q.updateProject projectId summary tags visibility
when (not success) $ respondError (EntityMissing (ErrorID "missing-project") "Project could not be found")
pure ()
diff --git a/src/Share/Web/UCM/Projects/Impl.hs b/src/Share/Web/UCM/Projects/Impl.hs
index 1c61dba6..935d139e 100644
--- a/src/Share/Web/UCM/Projects/Impl.hs
+++ b/src/Share/Web/UCM/Projects/Impl.hs
@@ -38,6 +38,8 @@ import Share.Web.Authorization qualified as AuthZ
import Share.Web.Errors (InternalServerError (..), respondError)
import Share.Web.Errors qualified as Errors
import Share.Web.Share.Contributions.MergeDetection qualified as MergeDetection
+import Share.Web.Share.Orgs.Queries qualified as OrgQ
+import Share.Web.Share.Orgs.Types (Org (..))
import Share.Web.UCM.Sync.HashJWT qualified as HashJWT
import Share.Web.UCM.Sync.Impl qualified as SyncQ
import Unison.Server.Orphans ()
@@ -87,10 +89,14 @@ getProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) mayUcmProjectId mayUcm
createProjectEndpoint :: Maybe Session -> UCMProjects.CreateProjectRequest -> WebApp UCMProjects.CreateProjectResponse
createProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) (UCMProjects.CreateProjectRequest {projectName}) = toResponse do
ProjectShortHand {userHandle, projectSlug} <- lift $ parseParam @ProjectShortHand "projectName" projectName
- User {user_id = targetUserId} <- pgT do
- UserQ.userByHandle userHandle `orThrow` UCMProjects.CreateProjectResponseNotFound (UCMProjects.NotFound "User not found")
+ (User {user_id = targetUserId}, mayOrg) <- pgT do
+ user@(User {user_id}) <- UserQ.userByHandle userHandle `orThrow` UCMProjects.CreateProjectResponseNotFound (UCMProjects.NotFound "User not found")
+ mayOrg <- OrgQ.orgByUserId user_id
+ pure (user, mayOrg)
AuthZ.checkProjectCreate callerUserId targetUserId `ifUnauthorized` UCMProjects.CreateProjectResponseUnauthorized
- let visibility = ProjectPrivate
+ let visibility = case mayOrg of
+ Nothing -> ProjectPrivate
+ Just (Org {isCommercial}) -> if isCommercial then ProjectPrivate else ProjectPublic
let summary = Nothing
let tags = mempty
projectId <- lift $ PGO.createProject targetUserId projectSlug summary tags visibility
diff --git a/transcripts/share-apis/orgs/commercial-org-projects.json b/transcripts/share-apis/orgs/commercial-org-projects.json
new file mode 100644
index 00000000..bafac4b6
--- /dev/null
+++ b/transcripts/share-apis/orgs/commercial-org-projects.json
@@ -0,0 +1,24 @@
+{
+ "body": [
+ {
+ "createdAt": "",
+ "isFaved": false,
+ "numFavs": 0,
+ "owner": {
+ "handle": "@acme",
+ "name": "ACME",
+ "type": "organization"
+ },
+ "slug": "proj",
+ "summary": null,
+ "tags": [],
+ "updatedAt": "",
+ "visibility": "private"
+ }
+ ],
+ "status": [
+ {
+ "status_code": 200
+ }
+ ]
+}
diff --git a/transcripts/share-apis/orgs/create-org-projects.md b/transcripts/share-apis/orgs/create-org-projects.md
new file mode 100644
index 00000000..f820cec5
--- /dev/null
+++ b/transcripts/share-apis/orgs/create-org-projects.md
@@ -0,0 +1,6 @@
+Create new projects in both the `@acme` and `@noncom` organizations.
+
+```ucm
+scratch/main> push @acme/proj
+scratch/main> push @noncom/proj
+```
diff --git a/transcripts/share-apis/orgs/create-org-projects.output.md b/transcripts/share-apis/orgs/create-org-projects.output.md
new file mode 100644
index 00000000..5baa1d77
--- /dev/null
+++ b/transcripts/share-apis/orgs/create-org-projects.output.md
@@ -0,0 +1,19 @@
+Create new projects in both the `@acme` and `@noncom` organizations.
+
+``` ucm
+scratch/main> push @acme/proj
+
+ Uploaded 1 entities.
+
+ I just created @acme/proj on http://localhost:5424
+
+ View it here: @acme/proj/main on http://localhost:5424
+
+scratch/main> push @noncom/proj
+
+ Uploaded 1 entities.
+
+ I just created @noncom/proj on http://localhost:5424
+
+ View it here: @noncom/proj/main on http://localhost:5424
+```
diff --git a/transcripts/share-apis/orgs/non-com-project-privatization.json b/transcripts/share-apis/orgs/non-com-project-privatization.json
new file mode 100644
index 00000000..bf9ad3ea
--- /dev/null
+++ b/transcripts/share-apis/orgs/non-com-project-privatization.json
@@ -0,0 +1,8 @@
+{
+ "body": "Please upgrade to a commercial org to enable private projects.",
+ "status": [
+ {
+ "status_code": 403
+ }
+ ]
+}
diff --git a/transcripts/share-apis/orgs/non-commercial-org-projects.json b/transcripts/share-apis/orgs/non-commercial-org-projects.json
new file mode 100644
index 00000000..e34dd953
--- /dev/null
+++ b/transcripts/share-apis/orgs/non-commercial-org-projects.json
@@ -0,0 +1,24 @@
+{
+ "body": [
+ {
+ "createdAt": "",
+ "isFaved": false,
+ "numFavs": 0,
+ "owner": {
+ "handle": "@noncom",
+ "name": "Noncom",
+ "type": "organization"
+ },
+ "slug": "proj",
+ "summary": null,
+ "tags": [],
+ "updatedAt": "",
+ "visibility": "public"
+ }
+ ],
+ "status": [
+ {
+ "status_code": 200
+ }
+ ]
+}
diff --git a/transcripts/share-apis/orgs/org-create-by-admin.json b/transcripts/share-apis/orgs/org-create-by-admin-commercial.json
similarity index 100%
rename from transcripts/share-apis/orgs/org-create-by-admin.json
rename to transcripts/share-apis/orgs/org-create-by-admin-commercial.json
diff --git a/transcripts/share-apis/orgs/org-create-by-admin-non-commercial.json b/transcripts/share-apis/orgs/org-create-by-admin-non-commercial.json
new file mode 100644
index 00000000..1cf78384
--- /dev/null
+++ b/transcripts/share-apis/orgs/org-create-by-admin-non-commercial.json
@@ -0,0 +1,17 @@
+{
+ "body": {
+ "isCommercial": false,
+ "orgId": "ORG-",
+ "user": {
+ "avatarUrl": "https://example.com/peace.png",
+ "handle": "noncom",
+ "name": "Noncom",
+ "userId": "U-"
+ }
+ },
+ "status": [
+ {
+ "status_code": 200
+ }
+ ]
+}
diff --git a/transcripts/share-apis/orgs/run.zsh b/transcripts/share-apis/orgs/run.zsh
index d6d55606..7eb83a08 100755
--- a/transcripts/share-apis/orgs/run.zsh
+++ b/transcripts/share-apis/orgs/run.zsh
@@ -16,11 +16,19 @@ fetch "$unauthorized_user" POST org-create-unauthorized '/orgs' '{
"avatarUrl": "https://example.com/anvil.png",
"owner": "unauthorized",
"email": "wile.e.coyote@example.com",
- "isCommercial": false
+ "isCommercial": true
}'
# Admin can create an org and assign any owner.
-fetch "$admin_user" POST org-create-by-admin '/orgs' '{
+fetch "$admin_user" POST org-create-by-admin-non-commercial '/orgs' '{
+ "name": "Noncom",
+ "handle": "noncom",
+ "avatarUrl": "https://example.com/peace.png",
+ "owner": "transcripts",
+ "isCommercial": false
+}'
+
+fetch "$admin_user" POST org-create-by-admin-commercial '/orgs' '{
"name": "ACME",
"handle": "acme",
"avatarUrl": "https://example.com/anvil.png",
@@ -74,3 +82,18 @@ fetch "$transcripts_user" DELETE org-remove-members '/orgs/acme/members' '{
}'
fetch "$transcripts_user" GET org-get-members-after-removing '/orgs/acme/members'
+
+# Create projects in each org.
+transcript_ucm transcript create-org-projects.md
+
+# Get projects for each org
+# Commercial projects should be private by default
+fetch "$transcripts_user" GET commercial-org-projects '/users/acme/projects'
+
+# Non-commercial projects must be public by default
+fetch "$transcripts_user" GET non-commercial-org-projects '/users/noncom/projects'
+
+# Updating a non-commercial org's project to private should fail
+fetch "$transcripts_user" PATCH non-com-project-privatization '/users/noncom/projects/proj' '{
+ "visibility": "private"
+}'