diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index 668803e..8a61019 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -3,7 +3,7 @@ "info": { "title": "FOSSBilling Extensions API (v2)", "version": "2.0.0", - "description": "Self-service extension submission, ownership, moderation, and public browsing. v1 (/extensions/v1) remains available for existing integrations." + "description": "Self-service extension publishing, ownership, moderation, and public browsing. v1 (/extensions/v1) remains available for existing integrations." }, "servers": [ { @@ -18,13 +18,13 @@ } }, "schemas": { - "ExtensionListResponse": { + "OwnedExtensionListResponse": { "type": "object", "properties": { "result": { "type": "array", "items": { - "$ref": "#/components/schemas/ExtensionListItem" + "$ref": "#/components/schemas/OwnedExtensionListItem" } }, "pagination": { @@ -33,7 +33,102 @@ }, "required": ["result", "pagination"] }, - "ExtensionListItem": { + "OwnedExtensionListItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "developer": { + "$ref": "#/components/schemas/PublicDeveloper" + }, + "published": { + "type": ["object", "null"], + "properties": { + "type": { + "type": "string", + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "website": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "license": { + "$ref": "#/components/schemas/License" + }, + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "source": { + "$ref": "#/components/schemas/Repository" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "download_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + } + }, + "required": [ + "type", + "name", + "description", + "website", + "license", + "source", + "version", + "download_url" + ] + }, + "pending_revision": { + "$ref": "#/components/schemas/PendingRevisionRef" + }, + "last_review": { + "$ref": "#/components/schemas/RevisionReview" + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "developer", + "published", + "pending_revision", + "last_review", + "created_at", + "updated_at" + ] + }, + "PublicDeveloper": { "type": "object", "properties": { "id": { @@ -42,69 +137,31 @@ }, "type": { "type": "string", - "enum": [ - "mod", - "theme", - "payment-gateway", - "server-manager", - "domain-registrar", - "hook", - "translation" - ] + "enum": ["user", "organization"] }, "name": { "type": "string", "minLength": 1, "maxLength": 120 }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 4000 - }, - "website": { + "URL": { "type": "string", "maxLength": 2048, "format": "uri" }, - "license": { - "$ref": "#/components/schemas/License" - }, - "icon_url": { + "avatar_url": { "type": "string", "maxLength": 2048, "format": "uri" }, - "source": { - "$ref": "#/components/schemas/Repository" - }, - "version": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "download_url": { - "type": "string", - "maxLength": 2048, - "format": "uri" + "approved": { + "type": "boolean" }, - "developer": { - "$ref": "#/components/schemas/PublicDeveloper" + "unclaimed": { + "type": "boolean" } }, - "required": [ - "id", - "type", - "name", - "description", - "website", - "license", - "source", - "version", - "download_url", - "developer" - ], - "additionalProperties": false + "required": ["id", "type", "name", "approved", "unclaimed"] }, "License": { "type": "object", @@ -139,40 +196,36 @@ "required": ["type", "repo"], "additionalProperties": false }, - "PublicDeveloper": { - "type": "object", + "PendingRevisionRef": { + "type": ["object", "null"], "properties": { "id": { - "type": "string", - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" - }, - "type": { - "type": "string", - "enum": ["user", "organization"] - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 120 + "type": "string" }, - "URL": { - "type": "string", - "maxLength": 2048, - "format": "uri" + "created_at": { + "type": "string" + } + }, + "required": ["id", "created_at"] + }, + "RevisionReview": { + "type": ["object", "null"], + "properties": { + "revision_id": { + "type": "string" }, - "avatar_url": { + "status": { "type": "string", - "maxLength": 2048, - "format": "uri" + "enum": ["approved", "rejected"] }, - "approved": { - "type": "boolean" + "review_note": { + "type": ["string", "null"] }, - "unclaimed": { - "type": "boolean" + "reviewed_at": { + "type": ["string", "null"] } }, - "required": ["id", "type", "name", "approved", "unclaimed"] + "required": ["revision_id", "status", "review_note", "reviewed_at"] }, "Pagination": { "type": "object", @@ -217,20 +270,50 @@ }, "required": ["error"] }, - "Extension": { + "OwnedExtension": { "allOf": [ { - "$ref": "#/components/schemas/ExtensionPayload" + "$ref": "#/components/schemas/OwnedExtensionListItem" }, { "type": "object", "properties": { - "developer": { - "$ref": "#/components/schemas/PublicDeveloper" + "published": { + "allOf": [ + { + "$ref": "#/components/schemas/ExtensionContent" + }, + { + "type": ["object", "null"], + "properties": { + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Release" + }, + "maxItems": 100 + } + } + } + ] + }, + "pending_revision": { + "allOf": [ + { + "$ref": "#/components/schemas/PendingRevisionRef" + }, + { + "type": ["object", "null"], + "properties": { + "content": { + "$ref": "#/components/schemas/StoredExtensionContent" + } + }, + "required": ["content"] + } + ] } - }, - "required": ["developer"], - "additionalProperties": false + } } ] }, @@ -266,13 +349,9 @@ "required": ["tag", "date", "download_url", "min_fossbilling_version"], "additionalProperties": false }, - "ExtensionPayload": { + "ExtensionContent": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" - }, "type": { "type": "string", "enum": [ @@ -336,7 +415,6 @@ } }, "required": [ - "id", "type", "name", "description", @@ -347,131 +425,244 @@ "source", "version", "download_url" - ], - "additionalProperties": false + ] }, - "User": { + "StoredExtensionContent": { "type": "object", "properties": { - "display_name": { - "type": ["string", "null"] + "type": { + "type": "string", + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] }, - "is_moderator": { - "type": "boolean" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 }, - "github_linked": { - "type": "boolean" + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 }, - "active": { - "type": "boolean" - } - }, - "required": ["display_name", "is_moderator", "github_linked", "active"] - }, - "UserIdentityInput": { - "type": "object", - "properties": { - "name": { - "type": ["string", "null"], - "maxLength": 200 + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Release" + }, + "maxItems": 100 }, - "email": { - "type": ["string", "null"], - "maxLength": 254, - "format": "email" + "website": { + "type": "string", + "maxLength": 2048, + "format": "uri" }, - "email_verified": { - "type": "boolean" + "license": { + "$ref": "#/components/schemas/License" }, - "picture": { - "type": ["string", "null"], - "maxLength": 2048 + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" }, - "github_login": { - "type": ["string", "null"], - "maxLength": 200 + "readme": { + "type": "string", + "minLength": 1, + "maxLength": 100000 }, - "github_orgs": { - "type": ["array", "null"], - "items": { - "type": "string", - "maxLength": 200 - }, - "maxItems": 500 + "source": { + "$ref": "#/components/schemas/Repository" }, - "github_orgs_expires_at": { - "type": ["string", "null"], - "maxLength": 64 + "version": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "download_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" } - }, - "required": [ - "name", - "email", - "email_verified", - "picture", - "github_login", - "github_orgs", - "github_orgs_expires_at" - ], - "additionalProperties": false + } }, - "UserProfileUpdate": { + "ExtensionCreate": { "type": "object", "properties": { - "display_name": { - "type": ["string", "null"], + "type": { + "type": "string", + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] + }, + "name": { + "type": "string", + "minLength": 1, "maxLength": 120 - } - }, - "required": ["display_name"], - "additionalProperties": false - }, - "SubmissionPayload": { - "type": "object", - "properties": { - "developer": { - "$ref": "#/components/schemas/SubmissionDeveloper" }, - "extension": { - "$ref": "#/components/schemas/ExtensionPayload" + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Release" + }, + "minItems": 1, + "maxItems": 100 + }, + "website": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "license": { + "$ref": "#/components/schemas/License" + }, + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "readme": { + "type": "string", + "minLength": 1, + "maxLength": 100000 + }, + "source": { + "$ref": "#/components/schemas/Repository" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "download_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" } }, - "required": ["developer", "extension"], + "required": [ + "type", + "name", + "description", + "releases", + "website", + "license", + "readme", + "source", + "version", + "download_url", + "id" + ], "additionalProperties": false }, - "SubmissionDeveloper": { + "ExtensionUpdate": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" - }, "type": { "type": "string", - "enum": ["user", "organization"] + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] }, "name": { "type": "string", "minLength": 1, "maxLength": 120 }, - "URL": { + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4000 + }, + "releases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Release" + }, + "minItems": 1, + "maxItems": 100 + }, + "website": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "license": { + "$ref": "#/components/schemas/License" + }, + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "readme": { + "type": "string", + "minLength": 1, + "maxLength": 100000 + }, + "source": { + "$ref": "#/components/schemas/Repository" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "download_url": { "type": "string", "maxLength": 2048, "format": "uri" } }, - "required": ["id", "type", "name"], + "required": [ + "type", + "name", + "description", + "releases", + "website", + "license", + "readme", + "source", + "version", + "download_url" + ], "additionalProperties": false }, - "Submission": { + "ExtensionRevision": { "type": "object", "properties": { "id": { "type": "string" }, "extension_id": { - "type": ["string", "null"] + "type": "string" }, "developer_id": { "type": "string" @@ -483,8 +674,8 @@ "type": "string", "enum": ["pending", "approved", "rejected"] }, - "payload": { - "$ref": "#/components/schemas/SubmissionPayload" + "content": { + "$ref": "#/components/schemas/StoredExtensionContent" }, "reviewer_id": { "type": ["string", "null"] @@ -505,57 +696,238 @@ "developer_id", "submitted_by", "status", - "payload", + "content", "reviewer_id", "review_note", "created_at", "reviewed_at" ] }, - "DeveloperClaim": { + "ExtensionListResponse": { "type": "object", "properties": { - "id": { - "type": "string" + "result": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionListItem" + } }, - "developer_id": { - "type": "string" + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": ["result", "pagination"] + }, + "ExtensionListItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] }, - "claimant_id": { - "type": "string" + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 }, - "status": { + "description": { "type": "string", - "enum": ["pending", "approved", "rejected"] + "minLength": 1, + "maxLength": 4000 }, - "note": { - "type": "string" + "website": { + "type": "string", + "maxLength": 2048, + "format": "uri" }, - "review_note": { - "type": "string" + "license": { + "$ref": "#/components/schemas/License" }, - "reviewer_id": { - "type": "string" + "icon_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" }, - "created_at": { - "type": "string" + "source": { + "$ref": "#/components/schemas/Repository" }, - "reviewed_at": { - "type": "string" + "version": { + "type": "string", + "minLength": 1, + "maxLength": 100 }, - "github_org_verified": { - "type": "boolean" + "download_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" }, - "github_verification_note": { + "id": { "type": "string" + }, + "developer": { + "$ref": "#/components/schemas/PublicDeveloper" } }, "required": [ + "type", + "name", + "description", + "website", + "license", + "source", + "version", + "download_url", "id", - "developer_id", - "claimant_id", - "status", - "created_at" + "developer" + ] + }, + "Extension": { + "allOf": [ + { + "$ref": "#/components/schemas/ExtensionContent" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "developer": { + "$ref": "#/components/schemas/PublicDeveloper" + } + }, + "required": ["id", "developer"] + } + ] + }, + "User": { + "type": "object", + "properties": { + "display_name": { + "type": ["string", "null"] + }, + "is_moderator": { + "type": "boolean" + }, + "github_linked": { + "type": "boolean" + }, + "active": { + "type": "boolean" + } + }, + "required": ["display_name", "is_moderator", "github_linked", "active"] + }, + "UserIdentityInput": { + "type": "object", + "properties": { + "name": { + "type": ["string", "null"], + "maxLength": 200 + }, + "email": { + "type": ["string", "null"], + "maxLength": 254, + "format": "email" + }, + "email_verified": { + "type": "boolean" + }, + "picture": { + "type": ["string", "null"], + "maxLength": 2048 + }, + "github_login": { + "type": ["string", "null"], + "maxLength": 200 + }, + "github_orgs": { + "type": ["array", "null"], + "items": { + "type": "string", + "maxLength": 200 + }, + "maxItems": 500 + }, + "github_orgs_expires_at": { + "type": ["string", "null"], + "maxLength": 64 + } + }, + "required": [ + "name", + "email", + "email_verified", + "picture", + "github_login", + "github_orgs", + "github_orgs_expires_at" + ], + "additionalProperties": false + }, + "UserProfileUpdate": { + "type": "object", + "properties": { + "display_name": { + "type": ["string", "null"], + "maxLength": 120 + } + }, + "required": ["display_name"], + "additionalProperties": false + }, + "DeveloperClaim": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "developer_id": { + "type": "string" + }, + "claimant_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending", "approved", "rejected"] + }, + "note": { + "type": "string" + }, + "review_note": { + "type": "string" + }, + "reviewer_id": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "reviewed_at": { + "type": "string" + }, + "github_org_verified": { + "type": "boolean" + }, + "github_verification_note": { + "type": "string" + } + }, + "required": [ + "id", + "developer_id", + "claimant_id", + "status", + "created_at" ] }, "ClaimNote": { @@ -784,56 +1156,558 @@ }, "required": ["has_pending_transfer"] } - ] + ] + }, + { + "type": "null" + } + ] + }, + "DeveloperInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "type": { + "type": "string", + "enum": ["user", "organization"] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "URL": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "avatar_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "contact_email": { + "type": "string", + "maxLength": 254, + "format": "email" + } + }, + "required": ["id", "type", "name"], + "additionalProperties": false + } + }, + "parameters": {} + }, + "paths": { + "/extensions/mine": { + "get": { + "tags": ["Extensions"], + "summary": "List the caller's extensions, published or not", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Opaque cursor returned by the previous page" + }, + "required": false, + "description": "Opaque cursor returned by the previous page", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Every extension under the caller's developer profile, each with its live content, any unreviewed edit, and the last moderator decision", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnedExtensionListResponse" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Pagination query failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/extensions/mine/{id}": { + "get": { + "tags": ["Extensions"], + "summary": "Get one of the caller's extensions, published or not", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The extension's live content, its unreviewed edit if any, and the last moderator decision", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/OwnedExtension" + } + }, + "required": ["result"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The account is inactive, or the caller does not own this extension", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No extension with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "id param failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/extensions": { + "post": { + "tags": ["Extensions"], + "summary": "Create an extension and submit its first version for review", + "security": [ + { + "Bearer": [] + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Extension created. It holds the id immediately but stays out of the public catalogue until a moderator approves the revision.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending"] + } + }, + "required": ["id", "revision_id", "status"] + } + }, + "required": ["result"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The account is inactive, or the caller has no developer profile to publish under", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "The id is taken, ownership changed, or the pending-revision limit was reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Body failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "get": { + "tags": ["Extensions"], + "summary": "List published extensions", + "parameters": [ + { + "schema": { + "type": "string", + "enum": [ + "mod", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "hook", + "translation" + ] + }, + "required": false, + "name": "type", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "developer_id", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Opaque cursor returned by the previous page" + }, + "required": false, + "description": "Opaque cursor returned by the previous page", + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Extensions matching the given filters", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionListResponse" + } + } + } + }, + "422": { + "description": "Filter or pagination query failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/extensions/{id}": { + "put": { + "tags": ["Extensions"], + "summary": "Submit an edit to an extension the caller owns", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionUpdate" + } + } + } + }, + "responses": { + "202": { + "description": "Edit accepted as a pending revision. The published content is unchanged until a moderator approves it.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending"] + } + }, + "required": ["id", "revision_id", "status"] + } + }, + "required": ["result"] + } + } + } }, - { - "type": "null" - } - ] - }, - "DeveloperInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "type": { - "type": "string", - "enum": ["user", "organization"] + "403": { + "description": "The account is inactive, or the caller does not own this extension", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 120 + "404": { + "description": "No extension with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "URL": { - "type": "string", - "maxLength": 2048, - "format": "uri" + "409": { + "description": "An edit is already awaiting review, or the pending-revision limit was reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "avatar_url": { - "type": "string", - "maxLength": 2048, - "format": "uri" + "422": { + "description": "Body failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } }, - "contact_email": { - "type": "string", - "maxLength": 254, - "format": "email" + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } - }, - "required": ["id", "type", "name"], - "additionalProperties": false - } - }, - "parameters": {} - }, - "paths": { - "/extensions/mine": { - "get": { + } + }, + "delete": { "tags": ["Extensions"], - "summary": "List extensions published under the caller's developer profile", + "summary": "Withdraw an extension that has never been published", "security": [ { "Bearer": [] @@ -843,51 +1717,36 @@ { "schema": { "type": "string", - "enum": [ - "mod", - "theme", - "payment-gateway", - "server-manager", - "domain-registrar", - "hook", - "translation" - ] - }, - "required": false, - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Opaque cursor returned by the previous page" + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" }, - "required": false, - "description": "Opaque cursor returned by the previous page", - "name": "cursor", - "in": "query" + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "The caller's published extensions", + "description": "Extension and its revisions deleted, and the id released", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtensionListResponse" + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "deleted": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["id", "deleted"] + } + }, + "required": ["result"] } } } @@ -903,7 +1762,7 @@ } }, "403": { - "description": "The bearer is valid but the account is inactive", + "description": "The account is inactive, or the caller does not own this extension", "content": { "application/json": { "schema": { @@ -912,8 +1771,18 @@ } } }, - "422": { - "description": "Pagination query failed validation", + "404": { + "description": "No extension with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "The extension is published and cannot be withdrawn", "content": { "application/json": { "schema": { @@ -933,75 +1802,50 @@ } } } - } - }, - "/extensions": { + }, "get": { "tags": ["Extensions"], - "summary": "List published extensions", + "summary": "Get a single published extension", "parameters": [ { "schema": { "type": "string", - "enum": [ - "mod", - "theme", - "payment-gateway", - "server-manager", - "domain-registrar", - "hook", - "translation" - ] - }, - "required": false, - "name": "type", - "in": "query" - }, - { - "schema": { - "type": "string" - }, - "required": false, - "name": "developer_id", - "in": "query" - }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 1000, - "description": "Opaque cursor returned by the previous page" + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" }, - "required": false, - "description": "Opaque cursor returned by the previous page", - "name": "cursor", - "in": "query" + "required": true, + "name": "id", + "in": "path" } ], "responses": { "200": { - "description": "Extensions matching the given filters", + "description": "The extension", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExtensionListResponse" + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/Extension" + } + }, + "required": ["result"] + } + } + } + }, + "404": { + "description": "No extension with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } } }, "422": { - "description": "Filter or pagination query failed validation", + "description": "id param failed validation", "content": { "application/json": { "schema": { @@ -1023,10 +1867,15 @@ } } }, - "/extensions/{id}": { + "/extensions/{id}/revisions": { "get": { "tags": ["Extensions"], - "summary": "Get a single published extension", + "summary": "List an extension's revisions, newest first", + "security": [ + { + "Bearer": [] + } + ], "parameters": [ { "schema": { @@ -1036,21 +1885,68 @@ "required": true, "name": "id", "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "required": false, + "name": "cursor", + "in": "query" } ], "responses": { "200": { - "description": "The extension", + "description": "Every version proposed for this extension, with its review outcome", "content": { "application/json": { "schema": { "type": "object", "properties": { "result": { - "$ref": "#/components/schemas/Extension" + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionRevision" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" } }, - "required": ["result"] + "required": ["result", "pagination"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The account is inactive, or the caller neither owns this extension nor moderates", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } } @@ -1066,7 +1962,7 @@ } }, "422": { - "description": "id param failed validation", + "description": "Pagination query failed validation", "content": { "application/json": { "schema": { @@ -1381,202 +2277,6 @@ } } }, - "/submissions": { - "post": { - "tags": ["Submissions"], - "summary": "Submit a new extension, or an edit to one you own", - "security": [ - { - "Bearer": [] - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SubmissionPayload" - } - } - } - }, - "responses": { - "201": { - "description": "Submission created and pending moderator review", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "result": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["pending"] - } - }, - "required": ["id", "status"] - } - }, - "required": ["result"] - } - } - } - }, - "401": { - "description": "Missing or invalid bearer token", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "The account is inactive, or the caller does not own the target developer or extension", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "409": { - "description": "Ownership or target changed, a duplicate is pending, or the pending limit was reached", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "Payload failed validation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Database error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, - "/submissions/mine": { - "get": { - "tags": ["Submissions"], - "summary": "List the caller's own submissions, in any status", - "security": [ - { - "Bearer": [] - } - ], - "parameters": [ - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 1000 - }, - "required": false, - "name": "cursor", - "in": "query" - } - ], - "responses": { - "200": { - "description": "The caller's submissions", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "result": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Submission" - } - }, - "pagination": { - "$ref": "#/components/schemas/Pagination" - } - }, - "required": ["result", "pagination"] - } - } - } - }, - "401": { - "description": "Missing or invalid bearer token", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "403": { - "description": "The bearer is valid but the account is inactive", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "422": { - "description": "Pagination query failed validation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "500": { - "description": "Database error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - } - } - } - }, "/developers/{id}/claim": { "post": { "tags": ["Developers"], @@ -2417,10 +3117,10 @@ } } }, - "/submissions/queue": { + "/moderation/extensions": { "get": { "tags": ["Moderation"], - "summary": "List submissions in the moderation queue", + "summary": "List extension revisions awaiting review", "security": [ { "Bearer": [] @@ -2460,7 +3160,7 @@ ], "responses": { "200": { - "description": "Submissions matching the requested status (default: pending)", + "description": "Revisions matching the requested status (default: pending), oldest first", "content": { "application/json": { "schema": { @@ -2469,7 +3169,7 @@ "result": { "type": "array", "items": { - "$ref": "#/components/schemas/Submission" + "$ref": "#/components/schemas/ExtensionRevision" } }, "pagination": { @@ -2524,10 +3224,10 @@ } } }, - "/submissions/{id}/approve": { + "/extensions/{id}/revisions/{revisionId}/approve": { "post": { "tags": ["Moderation"], - "summary": "Approve a pending submission", + "summary": "Approve a pending revision and publish it", "security": [ { "Bearer": [] @@ -2537,11 +3237,20 @@ { "schema": { "type": "string", - "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + "example": "acme-gateway" }, "required": true, "name": "id", "in": "path" + }, + { + "schema": { + "type": "string", + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }, + "required": true, + "name": "revisionId", + "in": "path" } ], "requestBody": { @@ -2555,7 +3264,7 @@ }, "responses": { "200": { - "description": "Submission approved and written through to the live extension/developer", + "description": "Revision approved and published as the extension's live content", "content": { "application/json": { "schema": { @@ -2601,7 +3310,7 @@ } }, "404": { - "description": "No submission with that id", + "description": "No such revision on that extension", "content": { "application/json": { "schema": { @@ -2611,7 +3320,7 @@ } }, "409": { - "description": "Submission is not pending, or ownership has changed since it was submitted", + "description": "Revision is not pending, or ownership has changed since it was proposed", "content": { "application/json": { "schema": { @@ -2621,7 +3330,7 @@ } }, "422": { - "description": "id param or review_note body failed validation", + "description": "Path params or review_note body failed validation", "content": { "application/json": { "schema": { @@ -2643,10 +3352,10 @@ } } }, - "/submissions/{id}/reject": { + "/extensions/{id}/revisions/{revisionId}/reject": { "post": { "tags": ["Moderation"], - "summary": "Reject a pending submission", + "summary": "Reject a pending revision", "security": [ { "Bearer": [] @@ -2656,11 +3365,20 @@ { "schema": { "type": "string", - "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + "example": "acme-gateway" }, "required": true, "name": "id", "in": "path" + }, + { + "schema": { + "type": "string", + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }, + "required": true, + "name": "revisionId", + "in": "path" } ], "requestBody": { @@ -2674,7 +3392,7 @@ }, "responses": { "200": { - "description": "Submission rejected", + "description": "Revision rejected. The extension's published content is unchanged.", "content": { "application/json": { "schema": { @@ -2720,7 +3438,7 @@ } }, "404": { - "description": "No submission with that id", + "description": "No such revision on that extension", "content": { "application/json": { "schema": { @@ -2730,7 +3448,7 @@ } }, "409": { - "description": "Submission is not pending", + "description": "Revision is not pending", "content": { "application/json": { "schema": { @@ -3318,7 +4036,7 @@ } }, "409": { - "description": "Profile still has published extensions, or has a pending submission awaiting review", + "description": "Profile still has extensions attached, published or not", "content": { "application/json": { "schema": { diff --git a/src/components/ConfirmDeleteDialog.astro b/src/components/ConfirmDeleteDialog.astro index abbee9e..7107fde 100644 --- a/src/components/ConfirmDeleteDialog.astro +++ b/src/components/ConfirmDeleteDialog.astro @@ -7,6 +7,7 @@ interface Props { description: string; confirmText: string; intentField?: { name: string; value: string }; + formAction?: string; submitLabel?: string; } @@ -16,6 +17,7 @@ const { description, confirmText, intentField, + formAction, submitLabel = 'Delete', } = Astro.props; --- @@ -28,7 +30,7 @@ const { onclick="if (arguments[0].target === this) this.close()" onclose="this.querySelector('form').reset(); this.querySelector('[data-confirm-submit]').disabled = true;" > -
+ { intentField && ( - {isEdit ? 'Add a new release (optional)' : 'Initial release'} + { + requireRelease + ? 'Initial release' + : 'Add a new release (optional)' + }
@@ -291,7 +300,7 @@ const isEdit = Boolean(extension); type="text" id="download_url" name="download_url" - required={!isEdit} + required={requireRelease} />
@@ -312,14 +321,14 @@ const isEdit = Boolean(extension);
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 64b7cfa..d7df016 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,5 +1,6 @@ import { deleteDevelopersMe, + deleteExtensionsById, getDevelopers, getDevelopersById, getDevelopersByIdHistory, @@ -9,9 +10,10 @@ import { getDevelopersUnapproved, getExtensions, getExtensionsById, + getExtensionsByIdRevisions, getExtensionsMine, - getSubmissionsMine, - getSubmissionsQueue, + getExtensionsMineById, + getModerationExtensions, getUsersMe, patchUsersMe, postDevelopersByIdApprove, @@ -23,11 +25,12 @@ import { postDevelopersClaimsByIdReject, postDevelopersMeReverify, postDevelopersTransfersAccept, - postSubmissions, - postSubmissionsByIdApprove, - postSubmissionsByIdReject, + postExtensions, + postExtensionsByIdRevisionsByRevisionIdApprove, + postExtensionsByIdRevisionsByRevisionIdReject, deleteUsersMe, putDevelopersMe, + putExtensionsById, putUsersMeIdentity, type Developer, type DeveloperApproval, @@ -37,21 +40,25 @@ import { type DeveloperTransfer, type Error as ApiErrorBody, type Extension, + type ExtensionCreate, type ExtensionListItem, type ExtensionListResponse, + type ExtensionRevision, + type ExtensionUpdate, + type GetExtensionsByIdRevisionsData, + type GetExtensionsByIdRevisionsResponse, type GetExtensionsData, type GetExtensionsMineData, - type GetSubmissionsMineResponse, - type GetSubmissionsMineData, - type GetSubmissionsQueueData, - type GetSubmissionsQueueResponse, + type GetModerationExtensionsData, + type GetModerationExtensionsResponse, type OwnedDeveloperProfile, + type OwnedExtension, + type OwnedExtensionListItem, + type OwnedExtensionListResponse, type PendingDeveloperClaim, type User, type UserIdentityInput, type PutDevelopersMeData, - type Submission, - type SubmissionPayload, } from '@/lib/api/generated/extensions-v2'; import { createClient, @@ -66,8 +73,10 @@ export const MAX_API_PAGE_LIMIT = 100; type ExtensionListQuery = NonNullable; type ExtensionMineQuery = NonNullable; -type SubmissionPageQuery = NonNullable; -type SubmissionQueueQuery = NonNullable; +type RevisionHistoryQuery = NonNullable< + GetExtensionsByIdRevisionsData['query'] +>; +type ModerationQueueQuery = NonNullable; export type ExtensionCatalogueFilters = Pick< ExtensionListQuery, @@ -79,19 +88,21 @@ export type ExtensionMineFilters = Pick< 'type' | 'limit' | 'cursor' >; -export type SubmissionPageOptions = Pick< - SubmissionPageQuery, +export type RevisionHistoryOptions = Pick< + RevisionHistoryQuery, 'cursor' | 'limit' >; -export type SubmissionPage = GetSubmissionsMineResponse; -export type SubmissionQueuePage = GetSubmissionsQueueResponse; -export type DeveloperProfileInput = NonNullable; -export type SubmissionStatus = Exclude< - SubmissionQueueQuery['status'], - undefined +export type ModerationQueueOptions = Pick< + ModerationQueueQuery, + 'cursor' | 'limit' >; +export type RevisionHistoryPage = GetExtensionsByIdRevisionsResponse; +export type ModerationQueuePage = GetModerationExtensionsResponse; +export type DeveloperProfileInput = NonNullable; +export type RevisionStatus = Exclude; + export type AccountUser = User; export type IdentitySyncInput = UserIdentityInput; export type OwnedDeveloper = OwnedDeveloperProfile; @@ -104,11 +115,15 @@ export type { DeveloperProfile, DeveloperTransfer, Extension, + ExtensionCreate, ExtensionListItem, ExtensionListResponse, + ExtensionRevision, + ExtensionUpdate, + OwnedExtension, + OwnedExtensionListItem, + OwnedExtensionListResponse, PendingDeveloperClaim, - Submission, - SubmissionPayload, }; export class ApiRequestError extends Error { @@ -219,9 +234,9 @@ async function unwrap(result: { } function pageQuery( - options: SubmissionPageOptions = {}, -): NonNullable { - const query: NonNullable = { + options: RevisionHistoryOptions | ModerationQueueOptions = {}, +): { limit: number; cursor?: string } { + const query: { limit: number; cursor?: string } = { limit: clampApiPageLimit(options.limit), }; @@ -334,7 +349,7 @@ export function createApiClient(env: ApplicationEnv, subject: string) { listMyExtensions: async ( options: ExtensionMineFilters = {}, - ): Promise => + ): Promise => unwrap( await getExtensionsMine({ client, @@ -342,47 +357,95 @@ export function createApiClient(env: ApplicationEnv, subject: string) { }), ), - submitExtension: async (payload: SubmissionPayload) => - (await unwrap(await postSubmissions({ client, body: payload }))).result, + getMyExtension: async (id: string): Promise => + ( + await unwrap( + await getExtensionsMineById({ + client, + path: { id }, + }), + ) + ).result, + + createExtension: async (payload: ExtensionCreate) => + ( + await unwrap( + await postExtensions({ + client, + body: payload, + }), + ) + ).result, - listMySubmissions: async ( - options: SubmissionPageOptions = {}, - ): Promise => + updateExtension: async (id: string, payload: ExtensionUpdate) => + ( + await unwrap( + await putExtensionsById({ + client, + path: { id }, + body: payload, + }), + ) + ).result, + + withdrawExtension: async (id: string) => + ( + await unwrap( + await deleteExtensionsById({ + client, + path: { id }, + }), + ) + ).result, + + listExtensionRevisions: async ( + id: string, + options: RevisionHistoryOptions = {}, + ): Promise => unwrap( - await getSubmissionsMine({ + await getExtensionsByIdRevisions({ client, + path: { id }, query: pageQuery(options), }), ), - listQueue: async ( - status: SubmissionStatus = 'pending', - options: SubmissionPageOptions = {}, - ): Promise => + listModerationQueue: async ( + status: RevisionStatus = 'pending', + options: ModerationQueueOptions = {}, + ): Promise => unwrap( - await getSubmissionsQueue({ + await getModerationExtensions({ client, query: { status, ...pageQuery(options) }, }), ), - approveSubmission: async (id: string, reviewNote?: string) => + approveRevision: async ( + extensionId: string, + revisionId: string, + reviewNote?: string, + ) => ( await unwrap( - await postSubmissionsByIdApprove({ + await postExtensionsByIdRevisionsByRevisionIdApprove({ client, - path: { id }, + path: { id: extensionId, revisionId }, ...(reviewNote ? { body: { review_note: reviewNote } } : {}), }), ) ).result, - rejectSubmission: async (id: string, reviewNote: string) => + rejectRevision: async ( + extensionId: string, + revisionId: string, + reviewNote: string, + ) => ( await unwrap( - await postSubmissionsByIdReject({ + await postExtensionsByIdRevisionsByRevisionIdReject({ client, - path: { id }, + path: { id: extensionId, revisionId }, body: { review_note: reviewNote }, }), ) diff --git a/src/lib/api/generated/extensions-v2/index.ts b/src/lib/api/generated/extensions-v2/index.ts index fcccb8b..107b0df 100644 --- a/src/lib/api/generated/extensions-v2/index.ts +++ b/src/lib/api/generated/extensions-v2/index.ts @@ -2,6 +2,7 @@ export { deleteDevelopersMe, + deleteExtensionsById, deleteUsersMe, getDevelopers, getDevelopersById, @@ -12,9 +13,10 @@ export { getDevelopersUnapproved, getExtensions, getExtensionsById, + getExtensionsByIdRevisions, getExtensionsMine, - getSubmissionsMine, - getSubmissionsQueue, + getExtensionsMineById, + getModerationExtensions, getUsersMe, type Options, patchUsersMe, @@ -27,10 +29,11 @@ export { postDevelopersClaimsByIdReject, postDevelopersMeReverify, postDevelopersTransfersAccept, - postSubmissions, - postSubmissionsByIdApprove, - postSubmissionsByIdReject, + postExtensions, + postExtensionsByIdRevisionsByRevisionIdApprove, + postExtensionsByIdRevisionsByRevisionIdReject, putDevelopersMe, + putExtensionsById, putUsersMeIdentity, } from './sdk.gen'; export type { @@ -41,6 +44,11 @@ export type { DeleteDevelopersMeErrors, DeleteDevelopersMeResponse, DeleteDevelopersMeResponses, + DeleteExtensionsByIdData, + DeleteExtensionsByIdError, + DeleteExtensionsByIdErrors, + DeleteExtensionsByIdResponse, + DeleteExtensionsByIdResponses, DeleteUsersMeData, DeleteUsersMeError, DeleteUsersMeErrors, @@ -55,9 +63,12 @@ export type { DeveloperTransfer, Error, Extension, + ExtensionContent, + ExtensionCreate, ExtensionListItem, ExtensionListResponse, - ExtensionPayload, + ExtensionRevision, + ExtensionUpdate, GetDevelopersByIdData, GetDevelopersByIdError, GetDevelopersByIdErrors, @@ -98,9 +109,19 @@ export type { GetExtensionsByIdErrors, GetExtensionsByIdResponse, GetExtensionsByIdResponses, + GetExtensionsByIdRevisionsData, + GetExtensionsByIdRevisionsError, + GetExtensionsByIdRevisionsErrors, + GetExtensionsByIdRevisionsResponse, + GetExtensionsByIdRevisionsResponses, GetExtensionsData, GetExtensionsError, GetExtensionsErrors, + GetExtensionsMineByIdData, + GetExtensionsMineByIdError, + GetExtensionsMineByIdErrors, + GetExtensionsMineByIdResponse, + GetExtensionsMineByIdResponses, GetExtensionsMineData, GetExtensionsMineError, GetExtensionsMineErrors, @@ -108,16 +129,11 @@ export type { GetExtensionsMineResponses, GetExtensionsResponse, GetExtensionsResponses, - GetSubmissionsMineData, - GetSubmissionsMineError, - GetSubmissionsMineErrors, - GetSubmissionsMineResponse, - GetSubmissionsMineResponses, - GetSubmissionsQueueData, - GetSubmissionsQueueError, - GetSubmissionsQueueErrors, - GetSubmissionsQueueResponse, - GetSubmissionsQueueResponses, + GetModerationExtensionsData, + GetModerationExtensionsError, + GetModerationExtensionsErrors, + GetModerationExtensionsResponse, + GetModerationExtensionsResponses, GetUsersMeData, GetUsersMeError, GetUsersMeErrors, @@ -125,6 +141,9 @@ export type { GetUsersMeResponses, License, OwnedDeveloperProfile, + OwnedExtension, + OwnedExtensionListItem, + OwnedExtensionListResponse, Pagination, PatchUsersMeData, PatchUsersMeError, @@ -132,6 +151,7 @@ export type { PatchUsersMeResponse, PatchUsersMeResponses, PendingDeveloperClaim, + PendingRevisionRef, PostDevelopersByIdApproveData, PostDevelopersByIdApproveError, PostDevelopersByIdApproveErrors, @@ -177,27 +197,32 @@ export type { PostDevelopersTransfersAcceptErrors, PostDevelopersTransfersAcceptResponse, PostDevelopersTransfersAcceptResponses, - PostSubmissionsByIdApproveData, - PostSubmissionsByIdApproveError, - PostSubmissionsByIdApproveErrors, - PostSubmissionsByIdApproveResponse, - PostSubmissionsByIdApproveResponses, - PostSubmissionsByIdRejectData, - PostSubmissionsByIdRejectError, - PostSubmissionsByIdRejectErrors, - PostSubmissionsByIdRejectResponse, - PostSubmissionsByIdRejectResponses, - PostSubmissionsData, - PostSubmissionsError, - PostSubmissionsErrors, - PostSubmissionsResponse, - PostSubmissionsResponses, + PostExtensionsByIdRevisionsByRevisionIdApproveData, + PostExtensionsByIdRevisionsByRevisionIdApproveError, + PostExtensionsByIdRevisionsByRevisionIdApproveErrors, + PostExtensionsByIdRevisionsByRevisionIdApproveResponse, + PostExtensionsByIdRevisionsByRevisionIdApproveResponses, + PostExtensionsByIdRevisionsByRevisionIdRejectData, + PostExtensionsByIdRevisionsByRevisionIdRejectError, + PostExtensionsByIdRevisionsByRevisionIdRejectErrors, + PostExtensionsByIdRevisionsByRevisionIdRejectResponse, + PostExtensionsByIdRevisionsByRevisionIdRejectResponses, + PostExtensionsData, + PostExtensionsError, + PostExtensionsErrors, + PostExtensionsResponse, + PostExtensionsResponses, PublicDeveloper, PutDevelopersMeData, PutDevelopersMeError, PutDevelopersMeErrors, PutDevelopersMeResponse, PutDevelopersMeResponses, + PutExtensionsByIdData, + PutExtensionsByIdError, + PutExtensionsByIdErrors, + PutExtensionsByIdResponse, + PutExtensionsByIdResponses, PutUsersMeIdentityData, PutUsersMeIdentityError, PutUsersMeIdentityErrors, @@ -207,9 +232,8 @@ export type { Repository, ReviewNoteOptional, ReviewNoteRequired, - Submission, - SubmissionDeveloper, - SubmissionPayload, + RevisionReview, + StoredExtensionContent, TransferAcceptance, User, UserIdentityInput, diff --git a/src/lib/api/generated/extensions-v2/sdk.gen.ts b/src/lib/api/generated/extensions-v2/sdk.gen.ts index 26fc833..034d566 100644 --- a/src/lib/api/generated/extensions-v2/sdk.gen.ts +++ b/src/lib/api/generated/extensions-v2/sdk.gen.ts @@ -12,6 +12,9 @@ import type { DeleteDevelopersMeData, DeleteDevelopersMeErrors, DeleteDevelopersMeResponses, + DeleteExtensionsByIdData, + DeleteExtensionsByIdErrors, + DeleteExtensionsByIdResponses, DeleteUsersMeData, DeleteUsersMeErrors, DeleteUsersMeResponses, @@ -39,18 +42,21 @@ import type { GetExtensionsByIdData, GetExtensionsByIdErrors, GetExtensionsByIdResponses, + GetExtensionsByIdRevisionsData, + GetExtensionsByIdRevisionsErrors, + GetExtensionsByIdRevisionsResponses, GetExtensionsData, GetExtensionsErrors, + GetExtensionsMineByIdData, + GetExtensionsMineByIdErrors, + GetExtensionsMineByIdResponses, GetExtensionsMineData, GetExtensionsMineErrors, GetExtensionsMineResponses, GetExtensionsResponses, - GetSubmissionsMineData, - GetSubmissionsMineErrors, - GetSubmissionsMineResponses, - GetSubmissionsQueueData, - GetSubmissionsQueueErrors, - GetSubmissionsQueueResponses, + GetModerationExtensionsData, + GetModerationExtensionsErrors, + GetModerationExtensionsResponses, GetUsersMeData, GetUsersMeErrors, GetUsersMeResponses, @@ -84,18 +90,21 @@ import type { PostDevelopersTransfersAcceptData, PostDevelopersTransfersAcceptErrors, PostDevelopersTransfersAcceptResponses, - PostSubmissionsByIdApproveData, - PostSubmissionsByIdApproveErrors, - PostSubmissionsByIdApproveResponses, - PostSubmissionsByIdRejectData, - PostSubmissionsByIdRejectErrors, - PostSubmissionsByIdRejectResponses, - PostSubmissionsData, - PostSubmissionsErrors, - PostSubmissionsResponses, + PostExtensionsByIdRevisionsByRevisionIdApproveData, + PostExtensionsByIdRevisionsByRevisionIdApproveErrors, + PostExtensionsByIdRevisionsByRevisionIdApproveResponses, + PostExtensionsByIdRevisionsByRevisionIdRejectData, + PostExtensionsByIdRevisionsByRevisionIdRejectErrors, + PostExtensionsByIdRevisionsByRevisionIdRejectResponses, + PostExtensionsData, + PostExtensionsErrors, + PostExtensionsResponses, PutDevelopersMeData, PutDevelopersMeErrors, PutDevelopersMeResponses, + PutExtensionsByIdData, + PutExtensionsByIdErrors, + PutExtensionsByIdResponses, PutUsersMeIdentityData, PutUsersMeIdentityErrors, PutUsersMeIdentityResponses, @@ -120,7 +129,7 @@ export type Options< }; /** - * List extensions published under the caller's developer profile + * List the caller's extensions, published or not */ export const getExtensionsMine = ( options?: Options, @@ -139,6 +148,26 @@ export const getExtensionsMine = ( ...options, }); +/** + * Get one of the caller's extensions, published or not + */ +export const getExtensionsMineById = ( + options: Options, +): RequestResult< + GetExtensionsMineByIdResponses, + GetExtensionsMineByIdErrors, + ThrowOnError +> => + (options.client ?? client).get< + GetExtensionsMineByIdResponses, + GetExtensionsMineByIdErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/extensions/mine/{id}', + ...options, + }); + /** * List published extensions */ @@ -151,6 +180,46 @@ export const getExtensions = ( ThrowOnError >({ url: '/extensions', ...options }); +/** + * Create an extension and submit its first version for review + */ +export const postExtensions = ( + options?: Options, +): RequestResult => + (options?.client ?? client).post< + PostExtensionsResponses, + PostExtensionsErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/extensions', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + +/** + * Withdraw an extension that has never been published + */ +export const deleteExtensionsById = ( + options: Options, +): RequestResult< + DeleteExtensionsByIdResponses, + DeleteExtensionsByIdErrors, + ThrowOnError +> => + (options.client ?? client).delete< + DeleteExtensionsByIdResponses, + DeleteExtensionsByIdErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/extensions/{id}', + ...options, + }); + /** * Get a single published extension */ @@ -167,6 +236,52 @@ export const getExtensionsById = ( ThrowOnError >({ url: '/extensions/{id}', ...options }); +/** + * Submit an edit to an extension the caller owns + */ +export const putExtensionsById = ( + options: Options, +): RequestResult< + PutExtensionsByIdResponses, + PutExtensionsByIdErrors, + ThrowOnError +> => + (options.client ?? client).put< + PutExtensionsByIdResponses, + PutExtensionsByIdErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/extensions/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + +/** + * List an extension's revisions, newest first + */ +export const getExtensionsByIdRevisions = < + ThrowOnError extends boolean = false, +>( + options: Options, +): RequestResult< + GetExtensionsByIdRevisionsResponses, + GetExtensionsByIdRevisionsErrors, + ThrowOnError +> => + (options.client ?? client).get< + GetExtensionsByIdRevisionsResponses, + GetExtensionsByIdRevisionsErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/extensions/{id}/revisions', + ...options, + }); + /** * Synchronize the caller's OIDC identity projection */ @@ -243,50 +358,6 @@ export const patchUsersMe = ( }, }); -/** - * Submit a new extension, or an edit to one you own - */ -export const postSubmissions = ( - options?: Options, -): RequestResult< - PostSubmissionsResponses, - PostSubmissionsErrors, - ThrowOnError -> => - (options?.client ?? client).post< - PostSubmissionsResponses, - PostSubmissionsErrors, - ThrowOnError - >({ - security: [{ scheme: 'bearer', type: 'http' }], - url: '/submissions', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers, - }, - }); - -/** - * List the caller's own submissions, in any status - */ -export const getSubmissionsMine = ( - options?: Options, -): RequestResult< - GetSubmissionsMineResponses, - GetSubmissionsMineErrors, - ThrowOnError -> => - (options?.client ?? client).get< - GetSubmissionsMineResponses, - GetSubmissionsMineErrors, - ThrowOnError - >({ - security: [{ scheme: 'bearer', type: 'http' }], - url: '/submissions/mine', - ...options, - }); - /** * Request ownership of an unowned developer profile */ @@ -492,44 +563,47 @@ export const postDevelopersTransfersAccept = < }); /** - * List submissions in the moderation queue + * List extension revisions awaiting review */ -export const getSubmissionsQueue = ( - options?: Options, +export const getModerationExtensions = ( + options?: Options, ): RequestResult< - GetSubmissionsQueueResponses, - GetSubmissionsQueueErrors, + GetModerationExtensionsResponses, + GetModerationExtensionsErrors, ThrowOnError > => (options?.client ?? client).get< - GetSubmissionsQueueResponses, - GetSubmissionsQueueErrors, + GetModerationExtensionsResponses, + GetModerationExtensionsErrors, ThrowOnError >({ security: [{ scheme: 'bearer', type: 'http' }], - url: '/submissions/queue', + url: '/moderation/extensions', ...options, }); /** - * Approve a pending submission + * Approve a pending revision and publish it */ -export const postSubmissionsByIdApprove = < +export const postExtensionsByIdRevisionsByRevisionIdApprove = < ThrowOnError extends boolean = false, >( - options: Options, + options: Options< + PostExtensionsByIdRevisionsByRevisionIdApproveData, + ThrowOnError + >, ): RequestResult< - PostSubmissionsByIdApproveResponses, - PostSubmissionsByIdApproveErrors, + PostExtensionsByIdRevisionsByRevisionIdApproveResponses, + PostExtensionsByIdRevisionsByRevisionIdApproveErrors, ThrowOnError > => (options.client ?? client).post< - PostSubmissionsByIdApproveResponses, - PostSubmissionsByIdApproveErrors, + PostExtensionsByIdRevisionsByRevisionIdApproveResponses, + PostExtensionsByIdRevisionsByRevisionIdApproveErrors, ThrowOnError >({ security: [{ scheme: 'bearer', type: 'http' }], - url: '/submissions/{id}/approve', + url: '/extensions/{id}/revisions/{revisionId}/approve', ...options, headers: { 'Content-Type': 'application/json', @@ -538,22 +612,27 @@ export const postSubmissionsByIdApprove = < }); /** - * Reject a pending submission + * Reject a pending revision */ -export const postSubmissionsByIdReject = ( - options: Options, +export const postExtensionsByIdRevisionsByRevisionIdReject = < + ThrowOnError extends boolean = false, +>( + options: Options< + PostExtensionsByIdRevisionsByRevisionIdRejectData, + ThrowOnError + >, ): RequestResult< - PostSubmissionsByIdRejectResponses, - PostSubmissionsByIdRejectErrors, + PostExtensionsByIdRevisionsByRevisionIdRejectResponses, + PostExtensionsByIdRevisionsByRevisionIdRejectErrors, ThrowOnError > => (options.client ?? client).post< - PostSubmissionsByIdRejectResponses, - PostSubmissionsByIdRejectErrors, + PostExtensionsByIdRevisionsByRevisionIdRejectResponses, + PostExtensionsByIdRevisionsByRevisionIdRejectErrors, ThrowOnError >({ security: [{ scheme: 'bearer', type: 'http' }], - url: '/submissions/{id}/reject', + url: '/extensions/{id}/revisions/{revisionId}/reject', ...options, headers: { 'Content-Type': 'application/json', diff --git a/src/lib/api/generated/extensions-v2/types.gen.ts b/src/lib/api/generated/extensions-v2/types.gen.ts index 380f9b1..e0cd71e 100644 --- a/src/lib/api/generated/extensions-v2/types.gen.ts +++ b/src/lib/api/generated/extensions-v2/types.gen.ts @@ -4,30 +4,46 @@ export type ClientOptions = { baseUrl: `${string}://${string}/extensions/v2` | (string & {}); }; -export type ExtensionListResponse = { - result: Array; +export type OwnedExtensionListResponse = { + result: Array; pagination: Pagination; }; -export type ExtensionListItem = { +export type OwnedExtensionListItem = { id: string; - type: - | 'mod' - | 'theme' - | 'payment-gateway' - | 'server-manager' - | 'domain-registrar' - | 'hook' - | 'translation'; - name: string; - description: string; - website: string; - license: License; - icon_url?: string; - source: Repository; - version: string; - download_url: string; developer: PublicDeveloper; + published: { + type: + | 'mod' + | 'theme' + | 'payment-gateway' + | 'server-manager' + | 'domain-registrar' + | 'hook' + | 'translation'; + name: string; + description: string; + website: string; + license: License; + icon_url?: string; + source: Repository; + version: string; + download_url: string; + } | null; + pending_revision: PendingRevisionRef; + last_review: RevisionReview; + created_at: string; + updated_at: string; +}; + +export type PublicDeveloper = { + id: string; + type: 'user' | 'organization'; + name: string; + URL?: string; + avatar_url?: string; + approved: boolean; + unclaimed: boolean; }; export type License = { @@ -40,15 +56,17 @@ export type Repository = { repo: string; }; -export type PublicDeveloper = { +export type PendingRevisionRef = { id: string; - type: 'user' | 'organization'; - name: string; - URL?: string; - avatar_url?: string; - approved: boolean; - unclaimed: boolean; -}; + created_at: string; +} | null; + +export type RevisionReview = { + revision_id: string; + status: 'approved' | 'rejected'; + review_note: string | null; + reviewed_at: string | null; +} | null; export type Pagination = { next_cursor: string | null; @@ -72,8 +90,15 @@ export type Error = { }; }; -export type Extension = ExtensionPayload & { - developer: PublicDeveloper; +export type OwnedExtension = OwnedExtensionListItem & { + published?: ExtensionContent & + ({ + releases?: Array; + } | null); + pending_revision?: PendingRevisionRef & + ({ + content: StoredExtensionContent; + } | null); }; export type Release = { @@ -84,8 +109,71 @@ export type Release = { min_fossbilling_version: string; }; -export type ExtensionPayload = { +export type ExtensionContent = { + type: + | 'mod' + | 'theme' + | 'payment-gateway' + | 'server-manager' + | 'domain-registrar' + | 'hook' + | 'translation'; + name: string; + description: string; + releases: Array; + website: string; + license: License; + icon_url?: string; + readme: string; + source: Repository; + version: string; + download_url: string; +}; + +export type StoredExtensionContent = { + type?: + | 'mod' + | 'theme' + | 'payment-gateway' + | 'server-manager' + | 'domain-registrar' + | 'hook' + | 'translation'; + name?: string; + description?: string; + releases?: Array; + website?: string; + license?: License; + icon_url?: string; + readme?: string; + source?: Repository; + version?: string; + download_url?: string; +}; + +export type ExtensionCreate = { + type: + | 'mod' + | 'theme' + | 'payment-gateway' + | 'server-manager' + | 'domain-registrar' + | 'hook' + | 'translation'; + name: string; + description: string; + releases: Array; + website: string; + license: License; + icon_url?: string; + readme: string; + source: Repository; + version: string; + download_url: string; id: string; +}; + +export type ExtensionUpdate = { type: | 'mod' | 'theme' @@ -106,6 +194,50 @@ export type ExtensionPayload = { download_url: string; }; +export type ExtensionRevision = { + id: string; + extension_id: string; + developer_id: string; + submitted_by: string; + status: 'pending' | 'approved' | 'rejected'; + content: StoredExtensionContent; + reviewer_id: string | null; + review_note: string | null; + created_at: string; + reviewed_at: string | null; +}; + +export type ExtensionListResponse = { + result: Array; + pagination: Pagination; +}; + +export type ExtensionListItem = { + type: + | 'mod' + | 'theme' + | 'payment-gateway' + | 'server-manager' + | 'domain-registrar' + | 'hook' + | 'translation'; + name: string; + description: string; + website: string; + license: License; + icon_url?: string; + source: Repository; + version: string; + download_url: string; + id: string; + developer: PublicDeveloper; +}; + +export type Extension = ExtensionContent & { + id: string; + developer: PublicDeveloper; +}; + export type User = { display_name: string | null; is_moderator: boolean; @@ -127,31 +259,6 @@ export type UserProfileUpdate = { display_name: string | null; }; -export type SubmissionPayload = { - developer: SubmissionDeveloper; - extension: ExtensionPayload; -}; - -export type SubmissionDeveloper = { - id: string; - type: 'user' | 'organization'; - name: string; - URL?: string; -}; - -export type Submission = { - id: string; - extension_id: string | null; - developer_id: string; - submitted_by: string; - status: 'pending' | 'approved' | 'rejected'; - payload: SubmissionPayload; - reviewer_id: string | null; - review_note: string | null; - created_at: string; - reviewed_at: string | null; -}; - export type DeveloperClaim = { id: string; developer_id: string; @@ -289,14 +396,61 @@ export type GetExtensionsMineError = export type GetExtensionsMineResponses = { /** - * The caller's published extensions + * Every extension under the caller's developer profile, each with its live content, any unreviewed edit, and the last moderator decision */ - 200: ExtensionListResponse; + 200: OwnedExtensionListResponse; }; export type GetExtensionsMineResponse = GetExtensionsMineResponses[keyof GetExtensionsMineResponses]; +export type GetExtensionsMineByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/extensions/mine/{id}'; +}; + +export type GetExtensionsMineByIdErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The account is inactive, or the caller does not own this extension + */ + 403: Error; + /** + * No extension with that id + */ + 404: Error; + /** + * id param failed validation + */ + 422: Error; + /** + * Database error + */ + 500: Error; +}; + +export type GetExtensionsMineByIdError = + GetExtensionsMineByIdErrors[keyof GetExtensionsMineByIdErrors]; + +export type GetExtensionsMineByIdResponses = { + /** + * The extension's live content, its unreviewed edit if any, and the last moderator decision + */ + 200: { + result: OwnedExtension; + }; +}; + +export type GetExtensionsMineByIdResponse = + GetExtensionsMineByIdResponses[keyof GetExtensionsMineByIdResponses]; + export type GetExtensionsData = { body?: never; path?: never; @@ -342,6 +496,105 @@ export type GetExtensionsResponses = { export type GetExtensionsResponse = GetExtensionsResponses[keyof GetExtensionsResponses]; +export type PostExtensionsData = { + body?: ExtensionCreate; + path?: never; + query?: never; + url: '/extensions'; +}; + +export type PostExtensionsErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The account is inactive, or the caller has no developer profile to publish under + */ + 403: Error; + /** + * The id is taken, ownership changed, or the pending-revision limit was reached + */ + 409: Error; + /** + * Body failed validation + */ + 422: Error; + /** + * Database error + */ + 500: Error; +}; + +export type PostExtensionsError = + PostExtensionsErrors[keyof PostExtensionsErrors]; + +export type PostExtensionsResponses = { + /** + * Extension created. It holds the id immediately but stays out of the public catalogue until a moderator approves the revision. + */ + 201: { + result: { + id: string; + revision_id: string; + status: 'pending'; + }; + }; +}; + +export type PostExtensionsResponse = + PostExtensionsResponses[keyof PostExtensionsResponses]; + +export type DeleteExtensionsByIdData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/extensions/{id}'; +}; + +export type DeleteExtensionsByIdErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The account is inactive, or the caller does not own this extension + */ + 403: Error; + /** + * No extension with that id + */ + 404: Error; + /** + * The extension is published and cannot be withdrawn + */ + 409: Error; + /** + * Database error + */ + 500: Error; +}; + +export type DeleteExtensionsByIdError = + DeleteExtensionsByIdErrors[keyof DeleteExtensionsByIdErrors]; + +export type DeleteExtensionsByIdResponses = { + /** + * Extension and its revisions deleted, and the id released + */ + 200: { + result: { + id: string; + deleted: true; + }; + }; +}; + +export type DeleteExtensionsByIdResponse = + DeleteExtensionsByIdResponses[keyof DeleteExtensionsByIdResponses]; + export type GetExtensionsByIdData = { body?: never; path: { @@ -381,6 +634,112 @@ export type GetExtensionsByIdResponses = { export type GetExtensionsByIdResponse = GetExtensionsByIdResponses[keyof GetExtensionsByIdResponses]; +export type PutExtensionsByIdData = { + body?: ExtensionUpdate; + path: { + id: string; + }; + query?: never; + url: '/extensions/{id}'; +}; + +export type PutExtensionsByIdErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The account is inactive, or the caller does not own this extension + */ + 403: Error; + /** + * No extension with that id + */ + 404: Error; + /** + * An edit is already awaiting review, or the pending-revision limit was reached + */ + 409: Error; + /** + * Body failed validation + */ + 422: Error; + /** + * Database error + */ + 500: Error; +}; + +export type PutExtensionsByIdError = + PutExtensionsByIdErrors[keyof PutExtensionsByIdErrors]; + +export type PutExtensionsByIdResponses = { + /** + * Edit accepted as a pending revision. The published content is unchanged until a moderator approves it. + */ + 202: { + result: { + id: string; + revision_id: string; + status: 'pending'; + }; + }; +}; + +export type PutExtensionsByIdResponse = + PutExtensionsByIdResponses[keyof PutExtensionsByIdResponses]; + +export type GetExtensionsByIdRevisionsData = { + body?: never; + path: { + id: string; + }; + query?: { + limit?: number; + cursor?: string; + }; + url: '/extensions/{id}/revisions'; +}; + +export type GetExtensionsByIdRevisionsErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The account is inactive, or the caller neither owns this extension nor moderates + */ + 403: Error; + /** + * No extension with that id + */ + 404: Error; + /** + * Pagination query failed validation + */ + 422: Error; + /** + * Database error + */ + 500: Error; +}; + +export type GetExtensionsByIdRevisionsError = + GetExtensionsByIdRevisionsErrors[keyof GetExtensionsByIdRevisionsErrors]; + +export type GetExtensionsByIdRevisionsResponses = { + /** + * Every version proposed for this extension, with its review outcome + */ + 200: { + result: Array; + pagination: Pagination; + }; +}; + +export type GetExtensionsByIdRevisionsResponse = + GetExtensionsByIdRevisionsResponses[keyof GetExtensionsByIdRevisionsResponses]; + export type PutUsersMeIdentityData = { body?: UserIdentityInput; path?: never; @@ -541,99 +900,6 @@ export type PatchUsersMeResponses = { export type PatchUsersMeResponse = PatchUsersMeResponses[keyof PatchUsersMeResponses]; -export type PostSubmissionsData = { - body?: SubmissionPayload; - path?: never; - query?: never; - url: '/submissions'; -}; - -export type PostSubmissionsErrors = { - /** - * Missing or invalid bearer token - */ - 401: Error; - /** - * The account is inactive, or the caller does not own the target developer or extension - */ - 403: Error; - /** - * Ownership or target changed, a duplicate is pending, or the pending limit was reached - */ - 409: Error; - /** - * Payload failed validation - */ - 422: Error; - /** - * Database error - */ - 500: Error; -}; - -export type PostSubmissionsError = - PostSubmissionsErrors[keyof PostSubmissionsErrors]; - -export type PostSubmissionsResponses = { - /** - * Submission created and pending moderator review - */ - 201: { - result: { - id: string; - status: 'pending'; - }; - }; -}; - -export type PostSubmissionsResponse = - PostSubmissionsResponses[keyof PostSubmissionsResponses]; - -export type GetSubmissionsMineData = { - body?: never; - path?: never; - query?: { - limit?: number; - cursor?: string; - }; - url: '/submissions/mine'; -}; - -export type GetSubmissionsMineErrors = { - /** - * Missing or invalid bearer token - */ - 401: Error; - /** - * The bearer is valid but the account is inactive - */ - 403: Error; - /** - * Pagination query failed validation - */ - 422: Error; - /** - * Database error - */ - 500: Error; -}; - -export type GetSubmissionsMineError = - GetSubmissionsMineErrors[keyof GetSubmissionsMineErrors]; - -export type GetSubmissionsMineResponses = { - /** - * The caller's submissions - */ - 200: { - result: Array; - pagination: Pagination; - }; -}; - -export type GetSubmissionsMineResponse = - GetSubmissionsMineResponses[keyof GetSubmissionsMineResponses]; - export type PostDevelopersByIdClaimData = { body?: ClaimNote; path: { @@ -1061,7 +1327,7 @@ export type PostDevelopersTransfersAcceptResponses = { export type PostDevelopersTransfersAcceptResponse = PostDevelopersTransfersAcceptResponses[keyof PostDevelopersTransfersAcceptResponses]; -export type GetSubmissionsQueueData = { +export type GetModerationExtensionsData = { body?: never; path?: never; query?: { @@ -1069,10 +1335,10 @@ export type GetSubmissionsQueueData = { limit?: number; cursor?: string; }; - url: '/submissions/queue'; + url: '/moderation/extensions'; }; -export type GetSubmissionsQueueErrors = { +export type GetModerationExtensionsErrors = { /** * Missing or invalid bearer token */ @@ -1091,32 +1357,33 @@ export type GetSubmissionsQueueErrors = { 500: Error; }; -export type GetSubmissionsQueueError = - GetSubmissionsQueueErrors[keyof GetSubmissionsQueueErrors]; +export type GetModerationExtensionsError = + GetModerationExtensionsErrors[keyof GetModerationExtensionsErrors]; -export type GetSubmissionsQueueResponses = { +export type GetModerationExtensionsResponses = { /** - * Submissions matching the requested status (default: pending) + * Revisions matching the requested status (default: pending), oldest first */ 200: { - result: Array; + result: Array; pagination: Pagination; }; }; -export type GetSubmissionsQueueResponse = - GetSubmissionsQueueResponses[keyof GetSubmissionsQueueResponses]; +export type GetModerationExtensionsResponse = + GetModerationExtensionsResponses[keyof GetModerationExtensionsResponses]; -export type PostSubmissionsByIdApproveData = { +export type PostExtensionsByIdRevisionsByRevisionIdApproveData = { body?: ReviewNoteOptional; path: { id: string; + revisionId: string; }; query?: never; - url: '/submissions/{id}/approve'; + url: '/extensions/{id}/revisions/{revisionId}/approve'; }; -export type PostSubmissionsByIdApproveErrors = { +export type PostExtensionsByIdRevisionsByRevisionIdApproveErrors = { /** * Missing or invalid bearer token */ @@ -1126,15 +1393,15 @@ export type PostSubmissionsByIdApproveErrors = { */ 403: Error; /** - * No submission with that id + * No such revision on that extension */ 404: Error; /** - * Submission is not pending, or ownership has changed since it was submitted + * Revision is not pending, or ownership has changed since it was proposed */ 409: Error; /** - * id param or review_note body failed validation + * Path params or review_note body failed validation */ 422: Error; /** @@ -1143,12 +1410,12 @@ export type PostSubmissionsByIdApproveErrors = { 500: Error; }; -export type PostSubmissionsByIdApproveError = - PostSubmissionsByIdApproveErrors[keyof PostSubmissionsByIdApproveErrors]; +export type PostExtensionsByIdRevisionsByRevisionIdApproveError = + PostExtensionsByIdRevisionsByRevisionIdApproveErrors[keyof PostExtensionsByIdRevisionsByRevisionIdApproveErrors]; -export type PostSubmissionsByIdApproveResponses = { +export type PostExtensionsByIdRevisionsByRevisionIdApproveResponses = { /** - * Submission approved and written through to the live extension/developer + * Revision approved and published as the extension's live content */ 200: { result: { @@ -1158,19 +1425,20 @@ export type PostSubmissionsByIdApproveResponses = { }; }; -export type PostSubmissionsByIdApproveResponse = - PostSubmissionsByIdApproveResponses[keyof PostSubmissionsByIdApproveResponses]; +export type PostExtensionsByIdRevisionsByRevisionIdApproveResponse = + PostExtensionsByIdRevisionsByRevisionIdApproveResponses[keyof PostExtensionsByIdRevisionsByRevisionIdApproveResponses]; -export type PostSubmissionsByIdRejectData = { +export type PostExtensionsByIdRevisionsByRevisionIdRejectData = { body?: ReviewNoteRequired; path: { id: string; + revisionId: string; }; query?: never; - url: '/submissions/{id}/reject'; + url: '/extensions/{id}/revisions/{revisionId}/reject'; }; -export type PostSubmissionsByIdRejectErrors = { +export type PostExtensionsByIdRevisionsByRevisionIdRejectErrors = { /** * Missing or invalid bearer token */ @@ -1180,11 +1448,11 @@ export type PostSubmissionsByIdRejectErrors = { */ 403: Error; /** - * No submission with that id + * No such revision on that extension */ 404: Error; /** - * Submission is not pending + * Revision is not pending */ 409: Error; /** @@ -1197,12 +1465,12 @@ export type PostSubmissionsByIdRejectErrors = { 500: Error; }; -export type PostSubmissionsByIdRejectError = - PostSubmissionsByIdRejectErrors[keyof PostSubmissionsByIdRejectErrors]; +export type PostExtensionsByIdRevisionsByRevisionIdRejectError = + PostExtensionsByIdRevisionsByRevisionIdRejectErrors[keyof PostExtensionsByIdRevisionsByRevisionIdRejectErrors]; -export type PostSubmissionsByIdRejectResponses = { +export type PostExtensionsByIdRevisionsByRevisionIdRejectResponses = { /** - * Submission rejected + * Revision rejected. The extension's published content is unchanged. */ 200: { result: { @@ -1212,8 +1480,8 @@ export type PostSubmissionsByIdRejectResponses = { }; }; -export type PostSubmissionsByIdRejectResponse = - PostSubmissionsByIdRejectResponses[keyof PostSubmissionsByIdRejectResponses]; +export type PostExtensionsByIdRevisionsByRevisionIdRejectResponse = + PostExtensionsByIdRevisionsByRevisionIdRejectResponses[keyof PostExtensionsByIdRevisionsByRevisionIdRejectResponses]; export type GetDevelopersData = { body?: never; @@ -1406,7 +1674,7 @@ export type DeleteDevelopersMeErrors = { */ 404: Error; /** - * Profile still has published extensions, or has a pending submission awaiting review + * Profile still has extensions attached, published or not */ 409: Error; /** diff --git a/src/lib/extension-form.ts b/src/lib/extension-form.ts new file mode 100644 index 0000000..d5b15d8 --- /dev/null +++ b/src/lib/extension-form.ts @@ -0,0 +1,127 @@ +import { isExtensionType, isSourceType } from '@/types'; +import type { Extension, Release } from '@/types'; +import type { ExtensionCreate, ExtensionUpdate } from '@/lib/api/client'; +import { formString } from './form'; +import { withHttpsScheme } from './url-prefix'; + +// Thrown for form-level validation failures that should be shown to the +// user, distinct from ApiRequestError (the api rejecting an otherwise +// well-formed payload) — see the try/catch in new.astro/edit.astro. +export class ExtensionValidationError extends Error {} + +// Shared by both builders below. A new release is only appended when +// version_tag is filled — required when there are no existing releases to +// carry through (a brand new extension, or one resubmitting after rejection +// with nothing ever published), optional otherwise, since the api replaces +// the whole releases array on approval and existing releases are always +// carried through unchanged. +function buildContent( + form: FormData, + existing: { releases: Release[]; version: string; download_url: string }, + requireRelease: boolean, +) { + const str = (name: string) => formString(form, name); + + const releases = [...existing.releases]; + let version = existing.version; + let downloadUrl = existing.download_url; + + const newReleaseTag = str('version_tag'); + if (requireRelease && !newReleaseTag) { + throw new ExtensionValidationError( + 'An initial release (version, release date, and download URL) is required.', + ); + } + if (newReleaseTag) { + // The api stores at most 100 releases, and doesn't itself reject a tag + // collision — since it replaces the whole array verbatim on approval, a + // collision would silently duplicate the entry. Catch both here instead + // of only one of them surfacing as a 422 from the api. + if (releases.some((r) => r.tag === newReleaseTag)) { + throw new ExtensionValidationError( + `A release tagged "${newReleaseTag}" already exists — use a different version tag.`, + ); + } + if (releases.length >= 100) { + throw new ExtensionValidationError( + 'This extension already has the maximum of 100 releases.', + ); + } + + const releaseDate = str('release_date'); + const releaseDownloadUrl = withHttpsScheme(str('download_url')); + const minVersion = str('min_fossbilling_version'); + if (!releaseDate || !releaseDownloadUrl || !minVersion) { + throw new ExtensionValidationError( + 'To add a new release, fill in the release date, download URL, and minimum FOSSBilling version.', + ); + } + + const release: Release = { + tag: newReleaseTag, + date: releaseDate, + download_url: releaseDownloadUrl, + changelog_url: withHttpsScheme(str('changelog_url')), + min_fossbilling_version: minVersion, + }; + releases.push(release); + version = release.tag; + downloadUrl = release.download_url; + } + + const typeInput = str('type'); + const sourceTypeInput = str('source_type'); + if (!isExtensionType(typeInput) || !isSourceType(sourceTypeInput)) { + throw new ExtensionValidationError( + 'Choose a valid extension type and repository host.', + ); + } + + return { + type: typeInput, + name: str('name'), + description: str('description'), + releases, + website: withHttpsScheme(str('website')) ?? '', + license: { + name: str('license_name'), + URL: withHttpsScheme(str('license_url')), + }, + icon_url: withHttpsScheme(str('icon_url')), + readme: str('readme'), + source: { + type: sourceTypeInput, + repo: str('source_repo'), + }, + version, + download_url: downloadUrl, + }; +} + +// Builds the payload for POST /extensions. The id is only ever set here — +// PUT can't rename, since the id comes from the path and is immutable. +export function buildExtensionCreatePayload(form: FormData): ExtensionCreate { + return { + id: formString(form, 'extension_id').toLowerCase(), + ...buildContent( + form, + { releases: [], version: '', download_url: '' }, + true, + ), + }; +} + +// Builds the payload for PUT /extensions/{id}. `existing` is the extension's +// current published content, when it has any — an extension can be edited +// with nothing published yet (rejected and never resubmitted), in which case +// a release is required same as creation. +export function buildExtensionUpdatePayload( + form: FormData, + existing: Extension | null, +): ExtensionUpdate { + return buildContent( + form, + existing ?? { releases: [], version: '', download_url: '' }, + !existing?.releases.length, + ); +} diff --git a/src/lib/extensions-data.ts b/src/lib/extensions-data.ts index 7b165c6..a93b9a8 100644 --- a/src/lib/extensions-data.ts +++ b/src/lib/extensions-data.ts @@ -4,17 +4,20 @@ import { createApiClient, getDeveloperById as getDeveloperByIdFromApi, - getExtensionById as getExtensionByIdFromApi, type DeveloperProfile as ApiDeveloperProfile, type Extension as ApiExtension, - type ExtensionListItem as ApiExtensionListItem, + type OwnedExtensionListItem, } from './api/client'; import type { PublicDeveloper } from './api/generated/extensions-v2'; import type { ApplicationEnv } from './runtime'; import type { DeveloperProfile, Extension, + ExtensionType, + License, PublicDeveloperProfile, + Release, + Repository, } from '@/types'; export interface OwnerReadOptions { @@ -26,6 +29,48 @@ export interface OwnerReadOptions { failSoft?: boolean; } +// The content of a proposed revision. Revision history predates current +// validation rules, so every field is optional here — unlike Extension, +// which describes only published content and is always complete. +export interface StoredExtensionFields { + type?: ExtensionType; + name?: string; + description?: string; + website?: string; + license?: License; + icon_url?: string; + readme?: string; + source?: Repository; + version?: string; + download_url?: string; + releases?: Release[]; +} + +export interface PendingRevision { + id: string; + createdAt: string; + content: StoredExtensionFields; +} + +export interface LastReview { + revisionId: string; + status: 'approved' | 'rejected'; + reviewNote: string | null; + reviewedAt: string | null; +} + +// The owner's view of an extension: its live content (if any), any edit +// awaiting review, and the last moderator decision. These three fields are +// independent — see the Extensions v2 README's state table — so callers +// must not collapse them into a single derived status. +export interface OwnedExtensionDetail { + id: string; + developer: PublicDeveloperProfile; + published: Extension | null; + pendingRevision: PendingRevision | null; + lastReview: LastReview | null; +} + function toDeveloperProfile( developer: ApiDeveloperProfile & { has_pending_transfer?: boolean }, ): DeveloperProfile & { has_pending_transfer?: boolean } { @@ -79,16 +124,45 @@ function toExtension(extension: ApiExtension): Extension { }; } -export async function getExtensionForSubmission( +export async function getOwnedExtension( env: ApplicationEnv, + userId: string, id: string, -): Promise { +): Promise { try { - return toExtension(await getExtensionByIdFromApi(env, id)); + const owned = await createApiClient(env, userId).getMyExtension(id); + + return { + id: owned.id, + developer: toPublicDeveloper(owned.developer), + published: owned.published + ? toExtension({ + ...owned.published, + id: owned.id, + developer: owned.developer, + }) + : null, + pendingRevision: owned.pending_revision + ? { + id: owned.pending_revision.id, + createdAt: owned.pending_revision.created_at, + content: owned.pending_revision.content, + } + : null, + lastReview: owned.last_review + ? { + revisionId: owned.last_review.revision_id, + status: owned.last_review.status, + reviewNote: owned.last_review.review_note, + reviewedAt: owned.last_review.reviewed_at, + } + : null, + }; } catch { - // These adapters back page reads, not authorization or writes. Preserve - // their fail-soft contract so a temporary API outage is rendered as a - // missing resource instead of an unhandled page error. + // 404 (no such extension) and 403 (not the owner) both resolve to "not + // found" here — this adapter backs an owner-only page that already + // redirects on a missing resource, and it must not distinguish "doesn't + // exist" from "isn't yours" to an unauthenticated prober. return null; } } @@ -97,10 +171,10 @@ export async function getExtensionsByOwner( env: ApplicationEnv, userId: string, options: OwnerReadOptions = {}, -): Promise { +): Promise { try { const api = createApiClient(env, userId); - const extensions: ApiExtensionListItem[] = []; + const extensions: OwnedExtensionListItem[] = []; let cursor: string | undefined; do { const page = await api.listMyExtensions({ limit: 100, cursor }); diff --git a/src/lib/submission-form.ts b/src/lib/submission-form.ts deleted file mode 100644 index 71b56e5..0000000 --- a/src/lib/submission-form.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { isExtensionType, isSourceType } from '@/types'; -import type { Developer, Extension, Release } from '@/types'; -import type { SubmissionPayload } from '@/lib/api/client'; -import { formString } from './form'; -import { withHttpsScheme } from './url-prefix'; - -// Thrown for form-level validation failures that should be shown to the -// user, distinct from ApiRequestError (the api rejecting an otherwise -// well-formed payload) — see the try/catch in new.astro/edit.astro. -export class SubmissionValidationError extends Error {} - -// Builds a SubmissionPayload from the ExtensionSubmissionForm component's -// fields. A new release is only appended when version_tag is filled — for -// edits that's optional (see the form's required={!isEdit}), and the api -// replaces the whole releases array on approval, so existing releases are -// always carried through unchanged. -export function buildSubmissionPayload( - form: FormData, - developer: Developer, - existingExtension?: Extension, -): SubmissionPayload { - const str = (name: string) => formString(form, name); - - const releases = existingExtension ? [...existingExtension.releases] : []; - let version = existingExtension?.version ?? ''; - let downloadUrl = existingExtension?.download_url ?? ''; - - const newReleaseTag = str('version_tag'); - if (!existingExtension && !newReleaseTag) { - throw new SubmissionValidationError( - 'An initial release (version, release date, and download URL) is required.', - ); - } - if (newReleaseTag) { - const releaseDate = str('release_date'); - const releaseDownloadUrl = withHttpsScheme(str('download_url')); - const minVersion = str('min_fossbilling_version'); - if (!releaseDate || !releaseDownloadUrl || !minVersion) { - throw new SubmissionValidationError( - 'To add a new release, fill in the release date, download URL, and minimum FOSSBilling version.', - ); - } - - const release: Release = { - tag: newReleaseTag, - date: releaseDate, - download_url: releaseDownloadUrl, - changelog_url: withHttpsScheme(str('changelog_url')), - min_fossbilling_version: minVersion, - }; - releases.push(release); - version = release.tag; - downloadUrl = release.download_url; - } - - const typeInput = str('type'); - const sourceTypeInput = str('source_type'); - if (!isExtensionType(typeInput) || !isSourceType(sourceTypeInput)) { - throw new SubmissionValidationError( - 'Choose a valid extension type and repository host.', - ); - } - - return { - developer: { - id: developer.id, - type: developer.type, - name: developer.name, - ...(developer.URL ? { URL: developer.URL } : {}), - }, - extension: { - // In edit mode, always use the id of the already-verified extension — - // never the form's own extension_id — so the payload can't be aimed at - // a different extension than the one the caller was confirmed to own. - id: existingExtension?.id ?? str('extension_id').toLowerCase(), - type: typeInput, - name: str('name'), - description: str('description'), - releases, - website: withHttpsScheme(str('website')) ?? '', - license: { - name: str('license_name'), - URL: withHttpsScheme(str('license_url')), - }, - icon_url: withHttpsScheme(str('icon_url')), - readme: str('readme'), - source: { - type: sourceTypeInput, - repo: str('source_repo'), - }, - version, - download_url: downloadUrl, - }, - }; -} diff --git a/src/pages/account/delete.astro b/src/pages/account/delete.astro index f1745a9..6f03a13 100644 --- a/src/pages/account/delete.astro +++ b/src/pages/account/delete.astro @@ -63,7 +63,7 @@ if (Astro.request.method === 'POST' && extensions.length === 0) { { extensions.length > 0 ? (

- You have {extensions.length} published extension + You have {extensions.length} extension {extensions.length === 1 ? '' : 's'} under your developer profile. Transfer ownership or remove them from{' '} your developer profile before diff --git a/src/pages/account/developer/index.astro b/src/pages/account/developer/index.astro index af09db6..7920ebf 100644 --- a/src/pages/account/developer/index.astro +++ b/src/pages/account/developer/index.astro @@ -33,8 +33,8 @@ let developer = await getDeveloperByOwner(env, user.sub); const isEdit = Boolean(developer); // Gates both the delete-profile action below and full account deletion -// (/account/delete) — a developer profile with published extensions can't -// be removed without orphaning them first. +// (/account/delete) — a developer profile with any extensions attached, +// published or not, can't be removed without orphaning them first. const extensions = isEdit ? await getExtensionsByOwner(env, user.sub) : []; // Drives whether "Generate"/"Re-generate" wording and the "Revoke" button @@ -573,7 +573,7 @@ const fields = submitted ?? developer;

{extensions.length > 0 ? (

- You have {extensions.length} published extension + You have {extensions.length} extension {extensions.length === 1 ? '' : 's'} under this profile. Transfer ownership or remove them before deleting it.

diff --git a/src/pages/account/extensions/[id]/edit.astro b/src/pages/account/extensions/[id]/edit.astro index 8b149e0..f7c5a34 100644 --- a/src/pages/account/extensions/[id]/edit.astro +++ b/src/pages/account/extensions/[id]/edit.astro @@ -1,17 +1,16 @@ --- import Base from '../../../../layouts/Base.astro'; -import ExtensionSubmissionForm from '@/components/ExtensionSubmissionForm.astro'; +import ExtensionForm from '@/components/ExtensionForm.astro'; +import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.astro'; import { requireUser } from '@/lib/auth-guard'; import { createApiClient, ApiRequestError } from '@/lib/api/client'; +import { getOwnedExtension } from '@/lib/extensions-data'; import { - getDeveloperByOwner, - getExtensionForSubmission, -} from '@/lib/extensions-data'; -import { - buildSubmissionPayload, - SubmissionValidationError, -} from '@/lib/submission-form'; + buildExtensionUpdatePayload, + ExtensionValidationError, +} from '@/lib/extension-form'; import { formString } from '@/lib/form'; +import { formatDate } from '@/lib/format-date'; import { setFlash } from '@/lib/flash'; import type { Extension } from '@/types'; @@ -25,25 +24,46 @@ if (!id) { return Astro.redirect('/404'); } -const extension = await getExtensionForSubmission(env, id); -if (!extension) { +// getMyExtension enforces ownership itself (403 for anyone else), so a +// missing result here already covers both "no such extension" and "not +// yours" — no separate developer-ownership check is needed. +const owned = await getOwnedExtension(env, user.sub, id); +if (!owned) { return Astro.redirect('/404'); } -const developer = await getDeveloperByOwner(env, user.sub); -if (!developer || developer.id !== extension.developer.id) { - return Astro.redirect('/404'); -} +// The base to edit: the current published content, or — for an extension +// that's never been published (rejected and not yet resubmitted) — a blank +// starting point carrying only the fixed id and developer. +const base: Extension = + owned.published ?? + ({ + id: owned.id, + type: 'mod', + name: '', + description: '', + releases: [], + website: '', + license: { name: '' }, + readme: '', + source: { type: 'github', repo: '' }, + version: '', + download_url: '', + developer: owned.developer, + } satisfies Extension); let error: string | null = null; // Falls back to the just-submitted values on a rejected edit, so the form -// doesn't reset to the currently-published extension and discard the edit. +// doesn't reset to the previous content and discard the edit. let submitted: Extension | null = null; -if (Astro.request.method === 'POST') { +// Only one revision may be unreviewed at a time — the api rejects a second +// edit with 409 while one is pending, so this page doesn't even offer the +// form in that state (see the pendingRevision branch below). +if (!owned.pendingRevision && Astro.request.method === 'POST') { const form = await Astro.request.formData(); submitted = { - ...extension, + ...base, type: formString(form, 'type') as Extension['type'], name: formString(form, 'name'), description: formString(form, 'description'), @@ -62,33 +82,82 @@ if (Astro.request.method === 'POST') { const api = createApiClient(env, user.sub); try { - const payload = buildSubmissionPayload(form, developer, extension); - await api.submitExtension(payload); + const payload = buildExtensionUpdatePayload(form, owned.published); + await api.updateExtension(owned.id, payload); setFlash(Astro.session, { title: 'Submitted for Review.' }); return Astro.redirect('/account'); } catch (e) { error = - e instanceof ApiRequestError || e instanceof SubmissionValidationError + e instanceof ApiRequestError || e instanceof ExtensionValidationError ? e.message : 'Something went wrong submitting your edit. Please try again.'; } } -const fields = submitted ?? extension; +const fields = submitted ?? base; +const title = base.name || owned.id; --- - +
-

- Edit {extension.name} -

-

- Edits are reviewed by a moderator before replacing the published listing. -

- +

Edit {title}

+ + { + owned.pendingRevision ? ( +
+
+ An edit submitted {formatDate(owned.pendingRevision.createdAt)} is + still awaiting review. You can submit another edit once a moderator + has reviewed this one. +
+
+ ) : ( + +

+ Edits are reviewed by a moderator before replacing the published + listing. +

+ +
+ ) + } + + { + !owned.published && ( +
+
+

Withdraw Extension

+
+
+

+ This extension has never been published, so it can still be + withdrawn. Permanently deletes it and its revision history, and + releases its id. This can't be undone. +

+ + +
+
+ ) + }
diff --git a/src/pages/account/extensions/[id]/withdraw.ts b/src/pages/account/extensions/[id]/withdraw.ts new file mode 100644 index 0000000..b6d1db8 --- /dev/null +++ b/src/pages/account/extensions/[id]/withdraw.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from 'astro'; +import { requireUser } from '@/lib/auth-guard'; +import { createApiClient, ApiRequestError } from '@/lib/api/client'; +import { setFlash } from '@/lib/flash'; + +export const POST: APIRoute = async (context) => { + const env = context.locals.env; + const guard = await requireUser(context, env); + if (guard instanceof Response) return guard; + const user = guard; + + const { id } = context.params; + if (!id) return context.redirect('/account'); + + const api = createApiClient(env, user.sub); + try { + await api.withdrawExtension(id); + } catch (e) { + const message = + e instanceof ApiRequestError + ? e.message + : 'Unable to withdraw extension.'; + setFlash(context.session, { category: 'error', title: message }); + return context.redirect(`/account/extensions/${id}/edit`); + } + + setFlash(context.session, { title: 'Extension withdrawn.' }); + return context.redirect('/account'); +}; diff --git a/src/pages/account/extensions/new.astro b/src/pages/account/extensions/new.astro index e50b913..dad4cd7 100644 --- a/src/pages/account/extensions/new.astro +++ b/src/pages/account/extensions/new.astro @@ -1,13 +1,13 @@ --- import Base from '../../../layouts/Base.astro'; -import ExtensionSubmissionForm from '@/components/ExtensionSubmissionForm.astro'; +import ExtensionForm from '@/components/ExtensionForm.astro'; import { requireUser } from '@/lib/auth-guard'; import { createApiClient, ApiRequestError } from '@/lib/api/client'; import { getDeveloperByOwner } from '@/lib/extensions-data'; import { - buildSubmissionPayload, - SubmissionValidationError, -} from '@/lib/submission-form'; + buildExtensionCreatePayload, + ExtensionValidationError, +} from '@/lib/extension-form'; import { setFlash } from '@/lib/flash'; const env = Astro.locals.env; @@ -26,13 +26,13 @@ if (Astro.request.method === 'POST') { const form = await Astro.request.formData(); const api = createApiClient(env, user.sub); try { - const payload = buildSubmissionPayload(form, developer); - await api.submitExtension(payload); + const payload = buildExtensionCreatePayload(form); + await api.createExtension(payload); setFlash(Astro.session, { title: 'Submitted for Review.' }); return Astro.redirect('/account'); } catch (e) { error = - e instanceof ApiRequestError || e instanceof SubmissionValidationError + e instanceof ApiRequestError || e instanceof ExtensionValidationError ? e.message : 'Something went wrong submitting your extension. Please try again.'; } @@ -46,6 +46,6 @@ if (Astro.request.method === 'POST') { Submissions are reviewed by a moderator before appearing in the public directory.

- + diff --git a/src/pages/account/index.astro b/src/pages/account/index.astro index 90b4e7a..2a08ef8 100644 --- a/src/pages/account/index.astro +++ b/src/pages/account/index.astro @@ -11,12 +11,10 @@ import { getExtensionsByOwner, } from '@/lib/extensions-data'; import { ISSUER } from '@/lib/oauth'; -import type { DeveloperClaim, Submission } from '@/lib/api/client'; +import type { DeveloperClaim } from '@/lib/api/client'; import { CircleAlert } from '@lucide/astro'; import ProfileTrustBadge from '@/components/ProfileTrustBadge.astro'; import DeveloperAvatar from '@/components/DeveloperAvatar.astro'; -import { formatDate } from '@/lib/format-date'; -import { cursorPageUrl } from '@/lib/pagination'; import { getOptimizedImageUrl } from '@/lib/image-url'; const env = Astro.locals.env; @@ -50,30 +48,6 @@ const developerError = : null; const api = createApiClient(env, user.sub); -let submissions: Submission[] = []; -let submissionsNextCursor: string | null = null; -let submissionsHasMore = false; -let submissionsError: string | null = null; -try { - const submissionsPage = await api.listMySubmissions({ - cursor: Astro.url.searchParams.get('submissions_cursor') ?? undefined, - }); - submissions = submissionsPage.result; - submissionsNextCursor = submissionsPage.pagination.next_cursor; - submissionsHasMore = submissionsPage.pagination.has_more; -} catch (error) { - submissionsError = - error instanceof ApiRequestError - ? getApiErrorMessage(error) - : 'Unable to load your submissions right now.'; -} - -const nextSubmissionsUrl = - submissionsHasMore && - submissionsNextCursor !== null && - submissionsNextCursor.length > 0 - ? cursorPageUrl(Astro.url, 'submissions_cursor', submissionsNextCursor) - : null; let myClaims: DeveloperClaim[] = []; let claimsError: string | null = null; @@ -109,12 +83,6 @@ if (reverifyCooldownUntil <= Date.now()) { reverifyCooldownUntil = 0; } -const statusBadgeVariant: Record = { - pending: 'secondary', - approved: undefined, - rejected: 'destructive', -}; - // After linking GitHub, send the user back through our own OIDC login // (rather than stranding them on auth's own /account page) so the ID token // is re-issued with the freshly linked github_login/github_orgs claims and @@ -285,10 +253,9 @@ const reconnectUrl = `${ISSUER}/account?callbackURL=${encodeURIComponent(
Your linked GitHub identity no longer matches this profile. - This doesn't affect your submissions or ownership — it's - only shown to moderators as a trust signal. Re-verify if - this should still match (e.g. you rejoined the - organization). + This doesn't affect your extensions or ownership — it's only + shown to moderators as a trust signal. Re-verify if this + should still match (e.g. you rejoined the organization).
)} @@ -356,28 +323,60 @@ const reconnectUrl = `${ISSUER}/account?callbackURL=${encodeURIComponent(

{ownedExtensionsError}

) : ownedExtensions.length === 0 ? (

- You don't have any published extensions yet. + You don't have any extensions yet.

) : (
    {ownedExtensions.map((ext) => { + // last_review is just the most recent decision on an edit, + // independent of whether the original content is still live + // — a rejected edit doesn't take the extension down, so only + // "rejected with nothing ever published" gets the destructive + // treatment. Otherwise it's the older content still serving, + // with just the latest edit attempt rejected. + const rejectedEdit = + !ext.pending_revision && + ext.last_review?.status === 'rejected'; + const rejectedNeverPublished = rejectedEdit && !ext.published; return (
  • -

    {ext.name}

    +

    {ext.published?.name ?? ext.id}

    - v{ext.version} + {ext.published ? `v${ext.published.version}` : ext.id}

    + {rejectedEdit && ext.last_review?.review_note && ( +

    + {ext.last_review.review_note} +

    + )}
    -
    - - View - +
    + {ext.pending_revision ? ( + + Awaiting review + + ) : rejectedNeverPublished ? ( + + Rejected + + ) : rejectedEdit ? ( + + Edit rejected + + ) : null} + {ext.published && ( + + View + + )}
-
-
-

Your Submissions

-
-
- { - submissionsError ? ( -

{submissionsError}

- ) : ( - <> - {submissions.length === 0 ? ( -

No submissions yet.

- ) : ( -
    - {submissions.map((s) => ( -
  • -
    -

    {s.payload.extension.name}

    -

    - {s.extension_id ? 'Edit' : 'New extension'} · - submitted {formatDate(s.created_at)} -

    - {s.status === 'rejected' && s.review_note && ( -

    - {s.review_note} -

    - )} -
    - - {s.status} - -
  • - ))} -
- )} - {nextSubmissionsUrl && ( -
- Next submissions page - - )} - - ) - } -
-
-
Delete Account diff --git a/src/pages/account/moderate/[id]/approve.ts b/src/pages/account/moderate/[id]/[revisionId]/approve.ts similarity index 72% rename from src/pages/account/moderate/[id]/approve.ts rename to src/pages/account/moderate/[id]/[revisionId]/approve.ts index 2b199f7..4859b3a 100644 --- a/src/pages/account/moderate/[id]/approve.ts +++ b/src/pages/account/moderate/[id]/[revisionId]/approve.ts @@ -9,17 +9,15 @@ export const POST: APIRoute = async (context) => { if (guard instanceof Response) return guard; const user = guard; - const { id } = context.params; - if (!id) return context.redirect('/account/moderate'); + const { id, revisionId } = context.params; + if (!id || !revisionId) return context.redirect('/account/moderate'); const api = createApiClient(env, user.sub); try { - await api.approveSubmission(id); + await api.approveRevision(id, revisionId); } catch (e) { const message = - e instanceof ApiRequestError - ? e.message - : 'Unable to approve submission.'; + e instanceof ApiRequestError ? e.message : 'Unable to approve revision.'; setFlash(context.session, { category: 'error', title: message }); } diff --git a/src/pages/account/moderate/[id]/reject.ts b/src/pages/account/moderate/[id]/[revisionId]/reject.ts similarity index 82% rename from src/pages/account/moderate/[id]/reject.ts rename to src/pages/account/moderate/[id]/[revisionId]/reject.ts index 6d9aaf3..c9b134c 100644 --- a/src/pages/account/moderate/[id]/reject.ts +++ b/src/pages/account/moderate/[id]/[revisionId]/reject.ts @@ -10,8 +10,8 @@ export const POST: APIRoute = async (context) => { if (guard instanceof Response) return guard; const user = guard; - const { id } = context.params; - if (!id) return context.redirect('/account/moderate'); + const { id, revisionId } = context.params; + if (!id || !revisionId) return context.redirect('/account/moderate'); let form: FormData; try { @@ -27,17 +27,17 @@ export const POST: APIRoute = async (context) => { if (!reviewNote) { setFlash(context.session, { category: 'error', - title: 'A reason is required to reject a submission.', + title: 'A reason is required to reject a revision.', }); return context.redirect('/account/moderate'); } const api = createApiClient(env, user.sub); try { - await api.rejectSubmission(id, reviewNote); + await api.rejectRevision(id, revisionId, reviewNote); } catch (e) { const message = - e instanceof ApiRequestError ? e.message : 'Unable to reject submission.'; + e instanceof ApiRequestError ? e.message : 'Unable to reject revision.'; setFlash(context.session, { category: 'error', title: message }); } diff --git a/src/pages/account/moderate/index.astro b/src/pages/account/moderate/index.astro index 441ce03..5d6297c 100644 --- a/src/pages/account/moderate/index.astro +++ b/src/pages/account/moderate/index.astro @@ -6,7 +6,7 @@ import { createApiClient, getApiErrorMessage, } from '@/lib/api/client'; -import type { Submission, SubmissionStatus } from '@/lib/api/client'; +import type { ExtensionRevision, RevisionStatus } from '@/lib/api/client'; import RejectDialog from '@/components/RejectDialog.astro'; import { formatDate } from '@/lib/format-date'; import { cursorPageUrl } from '@/lib/pagination'; @@ -17,21 +17,21 @@ if (guard instanceof Response) return guard; const user = guard; const statusParam = Astro.url.searchParams.get('status'); -const status: SubmissionStatus = +const status: RevisionStatus = statusParam === 'approved' || statusParam === 'rejected' ? statusParam : 'pending'; const api = createApiClient(env, user.sub); -let submissions: Submission[] = []; +let revisions: ExtensionRevision[] = []; let nextCursor: string | null = null; let hasMore = false; let loadError: string | null = null; try { - const page = await api.listQueue(status, { + const page = await api.listModerationQueue(status, { cursor: Astro.url.searchParams.get('cursor') ?? undefined, }); - submissions = page.result; + revisions = page.result; nextCursor = page.pagination.next_cursor; hasMore = page.pagination.has_more; } catch (error) { @@ -76,42 +76,45 @@ const nextPageUrl =

{loadError}

) : ( <> - {submissions.length === 0 ? ( -

- No {status} submissions. -

+ {revisions.length === 0 ? ( +

No {status} revisions.

) : (
    - {submissions.map((s) => ( + {revisions.map((r) => (
  • -

    {s.payload.extension.name}

    +

    + {r.content.name ?? r.extension_id} +

    - {s.extension_id ? 'Edit' : 'New extension'} by{' '} - {s.payload.developer.name} ({s.developer_id}) · - submitted {formatDate(s.created_at)} + {r.extension_id} by {r.developer_id} · submitted{' '} + {formatDate(r.created_at)}

    - - {s.payload.extension.type} - + {r.content.type && ( + + {r.content.type} + + )}
    -

    {s.payload.extension.description}

    + {r.content.description && ( +

    {r.content.description}

    + )}
    - Full Payload + Full Content
    -                        {JSON.stringify(s.payload, null, 2)}
    +                        {JSON.stringify(r.content, null, 2)}
                           
    - {s.status === 'pending' && ( + {r.status === 'pending' && (
    )} - {s.status === 'pending' && ( + {r.status === 'pending' && ( )} - {s.status !== 'pending' && s.review_note && ( + {r.status !== 'pending' && r.review_note && (

    - Note: {s.review_note} + Note: {r.review_note}

    )}
    diff --git a/test/extension-form.test.ts b/test/extension-form.test.ts new file mode 100644 index 0000000..cbcf496 --- /dev/null +++ b/test/extension-form.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { + buildExtensionCreatePayload, + buildExtensionUpdatePayload, + ExtensionValidationError, +} from '@/lib/extension-form'; +import type { Extension } from '@/types'; + +function extensionForm(overrides: Record = {}): FormData { + const form = new FormData(); + form.set('extension_id', 'example'); + form.set('type', 'mod'); + form.set('name', 'Example'); + form.set('description', 'An example extension.'); + form.set('website', 'example.test'); + form.set('license_name', 'MIT'); + form.set('license_url', 'https://example.test/license'); + form.set('readme', '# Example'); + form.set('source_type', 'github'); + form.set('source_repo', 'fossbilling/example'); + form.set('version_tag', '1.0.0'); + form.set('release_date', '2026-01-01'); + form.set('download_url', 'https://example.test/example.zip'); + form.set('min_fossbilling_version', '0.6.0'); + for (const [key, value] of Object.entries(overrides)) { + form.set(key, value); + } + return form; +} + +const publishedExtension: Extension = { + id: 'example', + type: 'mod', + name: 'Example', + description: 'An example extension.', + releases: [ + { + tag: '0.9.0', + date: '2025-12-01', + download_url: 'https://example.test/example-0.9.0.zip', + min_fossbilling_version: '0.5.0', + }, + ], + website: 'https://example.test', + license: { name: 'MIT' }, + readme: '# Example', + source: { type: 'github', repo: 'fossbilling/example' }, + version: '0.9.0', + download_url: 'https://example.test/example-0.9.0.zip', + developer: { + id: 'developer', + type: 'organization', + name: 'Example developer', + }, +}; + +describe('buildExtensionCreatePayload', () => { + it('builds a POST /extensions payload with no developer field', () => { + const payload = buildExtensionCreatePayload(extensionForm()); + + expect(payload).not.toHaveProperty('developer'); + expect(payload.id).toBe('example'); + expect(payload.name).toBe('Example'); + expect(payload.releases).toEqual([ + { + tag: '1.0.0', + date: '2026-01-01', + download_url: 'https://example.test/example.zip', + changelog_url: undefined, + min_fossbilling_version: '0.6.0', + }, + ]); + }); + + it('lowercases the extension id', () => { + const payload = buildExtensionCreatePayload( + extensionForm({ extension_id: 'Example-ID' }), + ); + + expect(payload.id).toBe('example-id'); + }); + + it('requires an initial release', () => { + const form = extensionForm({ version_tag: '' }); + form.delete('release_date'); + form.delete('download_url'); + form.delete('min_fossbilling_version'); + + expect(() => buildExtensionCreatePayload(form)).toThrow( + ExtensionValidationError, + ); + }); +}); + +describe('buildExtensionUpdatePayload', () => { + it('carries existing releases through unchanged when no new release is added', () => { + const form = extensionForm(); + form.delete('version_tag'); + form.delete('release_date'); + form.delete('download_url'); + form.delete('min_fossbilling_version'); + + const payload = buildExtensionUpdatePayload(form, publishedExtension); + + expect(payload).not.toHaveProperty('developer'); + expect(payload).not.toHaveProperty('id'); + expect(payload.releases).toEqual(publishedExtension.releases); + expect(payload.version).toBe('0.9.0'); + }); + + it('appends a new release and updates version/download_url when provided', () => { + const payload = buildExtensionUpdatePayload( + extensionForm(), + publishedExtension, + ); + + expect(payload.releases).toHaveLength(2); + expect(payload.version).toBe('1.0.0'); + expect(payload.download_url).toBe('https://example.test/example.zip'); + }); + + it('requires an initial release when nothing has ever been published', () => { + const form = extensionForm({ version_tag: '' }); + form.delete('release_date'); + form.delete('download_url'); + form.delete('min_fossbilling_version'); + + expect(() => buildExtensionUpdatePayload(form, null)).toThrow( + ExtensionValidationError, + ); + }); + + it('rejects a version_tag that duplicates an existing release instead of appending a second copy', () => { + const form = extensionForm({ version_tag: '0.9.0' }); + + expect(() => buildExtensionUpdatePayload(form, publishedExtension)).toThrow( + ExtensionValidationError, + ); + }); + + it('rejects a new release once the extension already has 100', () => { + const atLimit: Extension = { + ...publishedExtension, + releases: Array.from({ length: 100 }, (_, i) => ({ + tag: `0.${i}.0`, + date: '2025-01-01', + download_url: `https://example.test/example-0.${i}.0.zip`, + min_fossbilling_version: '0.5.0', + })), + }; + + expect(() => buildExtensionUpdatePayload(extensionForm(), atLimit)).toThrow( + ExtensionValidationError, + ); + }); +}); diff --git a/test/extensions-api.test.ts b/test/extensions-api.test.ts index 911a641..9344309 100644 --- a/test/extensions-api.test.ts +++ b/test/extensions-api.test.ts @@ -20,11 +20,13 @@ import { getExtensionById, listExtensions, type Extension, + type ExtensionCreate, type ExtensionListItem, type ExtensionListResponse, - type SubmissionQueuePage, - type SubmissionPayload, - type SubmissionPage, + type ExtensionRevision, + type ModerationQueuePage, + type OwnedExtensionListResponse, + type RevisionHistoryPage, } from '@/lib/api/client'; import { mintBearerAssertion } from '@/lib/assertion'; import { isCatalogueCardPage } from '@/scripts/extension-catalogue'; @@ -95,14 +97,18 @@ function page( return { result, pagination: { next_cursor, has_more } }; } -function submissionPage( +function ownedExtensionsPage( next_cursor: string | null, has_more: boolean, -): SubmissionPage { - return { - result: [], - pagination: { next_cursor, has_more }, - }; +): OwnedExtensionListResponse { + return { result: [], pagination: { next_cursor, has_more } }; +} + +function moderationPage( + next_cursor: string | null, + has_more: boolean, +): ModerationQueuePage { + return { result: [], pagination: { next_cursor, has_more } }; } function requestFrom(fetchMock: ReturnType, index = 0): Request { @@ -205,10 +211,10 @@ describe('generated Extensions v2 façade', () => { it('keeps authenticated binding and HTTP transports request-compatible', async () => { const bindingFetch = vi .fn() - .mockResolvedValue(apiResponse(submissionPage(null, false))); + .mockResolvedValue(apiResponse(ownedExtensionsPage(null, false))); const httpFetch = vi .fn() - .mockResolvedValue(apiResponse(submissionPage(null, false))); + .mockResolvedValue(apiResponse(ownedExtensionsPage(null, false))); const options = { limit: 25, cursor: 'opaque-cursor' }; await createApiClient( @@ -220,7 +226,7 @@ describe('generated Extensions v2 façade', () => { }, }, 'user-id', - ).listMySubmissions(options); + ).listMyExtensions(options); await createApiClient( { ...authenticatedEnv, @@ -230,7 +236,7 @@ describe('generated Extensions v2 façade', () => { }, }, 'user-id', - ).listMySubmissions(options); + ).listMyExtensions(options); const bindingRequest = requestFrom(bindingFetch); const httpRequest = requestFrom(httpFetch); @@ -340,7 +346,7 @@ describe('generated Extensions v2 façade', () => { const fetchMock = vi .fn() .mockImplementation(() => - Promise.resolve(apiResponse(submissionPage(null, false))), + Promise.resolve(apiResponse(ownedExtensionsPage(null, false))), ); vi.stubGlobal('fetch', fetchMock); vi.mocked(mintBearerAssertion) @@ -348,8 +354,8 @@ describe('generated Extensions v2 façade', () => { .mockResolvedValueOnce('token-two'); const api = createApiClient(authenticatedEnv, 'user-sub'); - await api.listMySubmissions({ limit: 100, cursor: 'opaque cursor' }); - await api.listMySubmissions({ limit: 100 }); + await api.listMyExtensions({ limit: 100, cursor: 'opaque cursor' }); + await api.listMyExtensions({ limit: 100 }); expect(mintBearerAssertion).toHaveBeenCalledTimes(2); expect(mintBearerAssertion).toHaveBeenNthCalledWith( @@ -364,7 +370,7 @@ describe('generated Extensions v2 façade', () => { 'Bearer token-two', ); expect(requestUrl(fetchMock, 0).pathname).toBe( - '/extensions/v2/submissions/mine', + '/extensions/v2/extensions/mine', ); expect(requestUrl(fetchMock, 0).searchParams.get('limit')).toBe('100'); expect(requestUrl(fetchMock, 0).searchParams.get('cursor')).toBe( @@ -393,54 +399,184 @@ describe('generated Extensions v2 façade', () => { expect(url.searchParams.has('developer_id')).toBe(false); }); - it('serializes generated request bodies as well as paths and queries', async () => { + it('serializes a create payload with no developer field and reports the new revision', async () => { const payload = { - developer: { - id: 'fossbilling', - type: 'organization' as const, - name: 'FOSSBilling', - }, - extension: { - id: 'body-extension', - type: 'mod' as const, - name: 'Body extension', - description: 'Submitted through the generated client.', - releases: [], - website: 'https://example.test/body-extension', - license: { name: 'MIT' }, - readme: '# Body extension', - source: { type: 'github' as const, repo: 'fossbilling/body-extension' }, - version: '1.0.0', - download_url: 'https://example.test/body-extension.zip', - }, - } satisfies SubmissionPayload; - const fetchMock = vi - .fn() - .mockResolvedValue( - apiResponse({ result: { id: 'submission-1', status: 'pending' } }, 201), - ); + id: 'body-extension', + type: 'mod' as const, + name: 'Body extension', + description: 'Submitted through the generated client.', + releases: [], + website: 'https://example.test/body-extension', + license: { name: 'MIT' }, + readme: '# Body extension', + source: { type: 'github' as const, repo: 'fossbilling/body-extension' }, + version: '1.0.0', + download_url: 'https://example.test/body-extension.zip', + } satisfies ExtensionCreate; + const fetchMock = vi.fn().mockResolvedValue( + apiResponse( + { + result: { + id: 'body-extension', + revision_id: 'revision-1', + status: 'pending', + }, + }, + 201, + ), + ); vi.stubGlobal('fetch', fetchMock); - await createApiClient(authenticatedEnv, 'user-sub').submitExtension( - payload, - ); + const result = await createApiClient( + authenticatedEnv, + 'user-sub', + ).createExtension(payload); const request = requestFrom(fetchMock); - expect(new URL(request.url).pathname).toBe('/extensions/v2/submissions'); + expect(new URL(request.url).pathname).toBe('/extensions/v2/extensions'); + expect(request.method).toBe('POST'); expect(request.headers.get('content-type')).toBe('application/json'); expect(await request.json()).toEqual(payload); + expect(result).toEqual({ + id: 'body-extension', + revision_id: 'revision-1', + status: 'pending', + }); + }); + + it('sends an edit as PUT and reads the 202 pending-revision result', async () => { + const payload = { + type: 'mod' as const, + name: 'Body extension', + description: 'Edited through the generated client.', + releases: [], + website: 'https://example.test/body-extension', + license: { name: 'MIT' }, + readme: '# Body extension', + source: { type: 'github' as const, repo: 'fossbilling/body-extension' }, + version: '1.1.0', + download_url: 'https://example.test/body-extension-1.1.0.zip', + }; + const fetchMock = vi.fn().mockResolvedValue( + apiResponse( + { + result: { + id: 'body-extension', + revision_id: 'revision-2', + status: 'pending', + }, + }, + 202, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await createApiClient( + authenticatedEnv, + 'user-sub', + ).updateExtension('body-extension', payload); + + const request = requestFrom(fetchMock); + expect(new URL(request.url).pathname).toBe( + '/extensions/v2/extensions/body-extension', + ); + expect(request.method).toBe('PUT'); + expect(await request.json()).toEqual(payload); + expect(result.revision_id).toBe('revision-2'); }); - it('returns submission pagination and preserves moderation filters', async () => { + it('withdraws an unpublished extension with DELETE', async () => { + const fetchMock = vi.fn().mockResolvedValue( + apiResponse({ + result: { id: 'body-extension', deleted: true }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await createApiClient( + authenticatedEnv, + 'user-sub', + ).withdrawExtension('body-extension'); + + const request = requestFrom(fetchMock); + expect(new URL(request.url).pathname).toBe( + '/extensions/v2/extensions/body-extension', + ); + expect(request.method).toBe('DELETE'); + expect(result).toEqual({ id: 'body-extension', deleted: true }); + }); + + it('lists an extension revision history by id, oldest params preserved', async () => { + const historyPage: RevisionHistoryPage = { + result: [], + pagination: { next_cursor: 'history-2', has_more: true }, + }; + const fetchMock = vi.fn().mockResolvedValue(apiResponse(historyPage)); + vi.stubGlobal('fetch', fetchMock); + + const response = await createApiClient( + authenticatedEnv, + 'user-sub', + ).listExtensionRevisions('body-extension', { + limit: 25, + cursor: 'history-cursor', + }); + + const url = requestUrl(fetchMock); + expect(url.pathname).toBe( + '/extensions/v2/extensions/body-extension/revisions', + ); + expect(url.searchParams.get('limit')).toBe('25'); + expect(url.searchParams.get('cursor')).toBe('history-cursor'); + expect(response.pagination).toEqual(historyPage.pagination); + }); + + it('approves and rejects a revision by extension id + revision id', async () => { + const approveFetch = vi + .fn() + .mockResolvedValue( + apiResponse({ result: { id: 'r-1', status: 'approved' } }), + ); + vi.stubGlobal('fetch', approveFetch); + await createApiClient(authenticatedEnv, 'moderator-sub').approveRevision( + 'body-extension', + 'r-1', + 'looks good', + ); + const approveRequest = requestFrom(approveFetch); + expect(new URL(approveRequest.url).pathname).toBe( + '/extensions/v2/extensions/body-extension/revisions/r-1/approve', + ); + expect(await approveRequest.json()).toEqual({ review_note: 'looks good' }); + + const rejectFetch = vi + .fn() + .mockResolvedValue( + apiResponse({ result: { id: 'r-2', status: 'rejected' } }), + ); + vi.stubGlobal('fetch', rejectFetch); + await createApiClient(authenticatedEnv, 'moderator-sub').rejectRevision( + 'body-extension', + 'r-2', + 'needs work', + ); + const rejectRequest = requestFrom(rejectFetch); + expect(new URL(rejectRequest.url).pathname).toBe( + '/extensions/v2/extensions/body-extension/revisions/r-2/reject', + ); + expect(await rejectRequest.json()).toEqual({ review_note: 'needs work' }); + }); + + it('returns moderation queue pagination and preserves status/cursor filters', async () => { const fetchMock = vi .fn() - .mockResolvedValue(apiResponse(submissionPage('next-page', true))); + .mockResolvedValue(apiResponse(moderationPage('next-page', true))); vi.stubGlobal('fetch', fetchMock); const response = await createApiClient( authenticatedEnv, 'moderator-sub', - ).listQueue('approved', { limit: 100, cursor: 'queue-cursor' }); + ).listModerationQueue('approved', { limit: 100, cursor: 'queue-cursor' }); expect(response.pagination).toEqual({ next_cursor: 'next-page', @@ -521,14 +657,29 @@ describe('generated Extensions v2 façade', () => { >().toEqualTypeOf(); }); + it('keeps every revision content field optional, unlike published Extension content', () => { + expectTypeOf< + undefined extends ExtensionRevision['content']['name'] ? true : false + >().toEqualTypeOf(); + expectTypeOf< + undefined extends ExtensionRevision['content']['readme'] ? true : false + >().toEqualTypeOf(); + expectTypeOf< + undefined extends Extension['name'] ? true : false + >().toEqualTypeOf(); + expectTypeOf< + undefined extends Extension['readme'] ? true : false + >().toEqualTypeOf(); + }); + it('keeps façade pagination and payload types tied to generated responses', () => { - expectTypeOf().toEqualTypeOf<{ + expectTypeOf().toEqualTypeOf<{ next_cursor: string | null; has_more: boolean; }>(); expectTypeOf< - ReturnType['listQueue']> - >().resolves.toEqualTypeOf(); + ReturnType['listModerationQueue']> + >().resolves.toEqualTypeOf(); }); }); diff --git a/test/extensions-data.test.ts b/test/extensions-data.test.ts index 8ed4606..6441f8f 100644 --- a/test/extensions-data.test.ts +++ b/test/extensions-data.test.ts @@ -3,20 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ createApiClient: vi.fn(), getDeveloperById: vi.fn(), - getExtensionById: vi.fn(), })); vi.mock('@/lib/api/client', () => ({ createApiClient: mocks.createApiClient, getDeveloperById: mocks.getDeveloperById, - getExtensionById: mocks.getExtensionById, })); import { getDeveloperById, getDeveloperByOwner, - getExtensionForSubmission, getExtensionsByOwner, + getOwnedExtension, } from '@/lib/extensions-data'; import type { ApplicationEnv } from '@/lib/runtime'; @@ -31,19 +29,77 @@ const env: ApplicationEnv = { assertionSigningSecret: 'assertion-secret', }; +const developer = { + id: 'developer', + type: 'organization' as const, + name: 'Example developer', + approved: true, + unclaimed: false, +}; + beforeEach(() => { mocks.createApiClient.mockReset(); mocks.getDeveloperById.mockReset(); - mocks.getExtensionById.mockReset(); }); describe('API-backed extension data adapters', () => { - it('treats a failed detail read as a missing submission resource', async () => { - mocks.getExtensionById.mockRejectedValue(new Error('API unavailable')); + it('treats a failed or unauthorized owner detail read as a missing resource', async () => { + mocks.createApiClient.mockReturnValue({ + getMyExtension: vi.fn().mockRejectedValue(new Error('API unavailable')), + }); - await expect(getExtensionForSubmission(env, 'extension-id')).resolves.toBe( - null, - ); + await expect( + getOwnedExtension(env, 'user-subject', 'extension-id'), + ).resolves.toBe(null); + }); + + it('splits the owner detail response into published/pendingRevision/lastReview', async () => { + mocks.createApiClient.mockReturnValue({ + getMyExtension: vi.fn().mockResolvedValue({ + id: 'extension-id', + developer, + published: { + type: 'mod', + name: 'Example', + description: 'desc', + releases: [], + website: 'https://example.test', + license: { name: 'MIT' }, + readme: '# Example', + source: { type: 'github', repo: 'fossbilling/example' }, + version: '1.0.0', + download_url: 'https://example.test/example.zip', + }, + pending_revision: { + id: 'revision-1', + created_at: '2026-01-01T00:00:00Z', + content: { name: 'Example (edited)' }, + }, + last_review: { + revision_id: 'revision-0', + status: 'approved', + review_note: null, + reviewed_at: '2025-12-01T00:00:00Z', + }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }), + }); + + const detail = await getOwnedExtension(env, 'user-subject', 'extension-id'); + + expect(detail?.published?.name).toBe('Example'); + expect(detail?.pendingRevision).toEqual({ + id: 'revision-1', + createdAt: '2026-01-01T00:00:00Z', + content: { name: 'Example (edited)' }, + }); + expect(detail?.lastReview).toEqual({ + revisionId: 'revision-0', + status: 'approved', + reviewNote: null, + reviewedAt: '2025-12-01T00:00:00Z', + }); }); it('treats failed public developer reads as missing profiles', async () => { diff --git a/test/submission-form.test.ts b/test/submission-form.test.ts deleted file mode 100644 index 686ba96..0000000 --- a/test/submission-form.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildSubmissionPayload } from '@/lib/submission-form'; -import type { DeveloperProfile } from '@/types'; - -function submissionForm(): FormData { - const form = new FormData(); - form.set('extension_id', 'example'); - form.set('type', 'mod'); - form.set('name', 'Example'); - form.set('description', 'An example extension.'); - form.set('website', 'example.test'); - form.set('license_name', 'MIT'); - form.set('license_url', 'https://example.test/license'); - form.set('readme', '# Example'); - form.set('source_type', 'github'); - form.set('source_repo', 'fossbilling/example'); - form.set('version_tag', '1.0.0'); - form.set('release_date', '2026-01-01'); - form.set('download_url', 'https://example.test/example.zip'); - form.set('min_fossbilling_version', '0.6.0'); - return form; -} - -describe('extension submission payloads', () => { - it('projects the local developer profile to the v2 submission shape', () => { - const developer: DeveloperProfile = { - id: 'developer', - type: 'organization', - name: 'Example developer', - URL: 'https://example.test', - approved: true, - contact_email: 'developer@example.test', - content_revision: 1, - }; - - const payload = buildSubmissionPayload(submissionForm(), developer); - - expect(payload.developer).toEqual({ - id: 'developer', - type: 'organization', - name: 'Example developer', - URL: 'https://example.test', - }); - expect(payload.developer).not.toHaveProperty('approved'); - expect(payload.developer).not.toHaveProperty('contact_email'); - }); -});