From 14e7ac992cd07de5b92b55baca4a662daec0d433 Mon Sep 17 00:00:00 2001 From: Boaz Poolman Date: Sun, 21 Jun 2026 17:33:29 +0200 Subject: [PATCH] chore: clean up package submission flow and add showcase submission --- .../workflow.json | 10 +- .../workflow.json | 8 +- .../workflow.json | 8 +- .../workflow.json | 10 +- .../scan-timeout-sweeper/workflow.json | 12 +- .../workflows/security-scan/workflow.json | 10 +- .../workflows/showcase-approved/workflow.json | 264 +++++++++++++++++ .../workflows/showcase-declined/workflow.json | 189 +++++++++++++ .../workflow.json | 264 +++++++++++++++++ apps/cms/config/plugins.ts | 24 +- .../content-types/showcase/schema.json | 2 +- .../plugins/moderation/server/src/config.ts | 2 + .../server/src/services/submission.ts | 18 +- apps/cms/src/seed/email-templates.ts | 55 +++- apps/cms/types/generated/contentTypes.d.ts | 13 +- .../route.ts | 16 +- apps/web/src/app/api/submit-showcase/route.ts | 130 +++++++++ .../SubmitPackageForm.tsx} | 61 ++-- .../app/submit/{plugin => package}/page.tsx | 6 +- .../submit/showcase/SubmitShowcaseForm.tsx | 265 ++++++++++++++++++ apps/web/src/app/submit/showcase/page.tsx | 29 ++ .../submit/template/SubmitTemplateForm.tsx | 1 + apps/web/src/features/submit/types.ts | 2 +- 23 files changed, 1311 insertions(+), 88 deletions(-) rename apps/automation/workflows/{plugin-approved => package-approved}/workflow.json (93%) rename apps/automation/workflows/{plugin-changes-requested => package-changes-requested}/workflow.json (92%) rename apps/automation/workflows/{plugin-declined => package-declined}/workflow.json (93%) rename apps/automation/workflows/{plugin-submission-received => package-submission-received}/workflow.json (92%) create mode 100644 apps/automation/workflows/showcase-approved/workflow.json create mode 100644 apps/automation/workflows/showcase-declined/workflow.json create mode 100644 apps/automation/workflows/showcase-submission-received/workflow.json rename apps/web/src/app/api/{submit-plugin => submit-package}/route.ts (91%) create mode 100644 apps/web/src/app/api/submit-showcase/route.ts rename apps/web/src/app/submit/{plugin/SubmitPluginForm.tsx => package/SubmitPackageForm.tsx} (84%) rename apps/web/src/app/submit/{plugin => package}/page.tsx (80%) create mode 100644 apps/web/src/app/submit/showcase/SubmitShowcaseForm.tsx create mode 100644 apps/web/src/app/submit/showcase/page.tsx diff --git a/apps/automation/workflows/plugin-approved/workflow.json b/apps/automation/workflows/package-approved/workflow.json similarity index 93% rename from apps/automation/workflows/plugin-approved/workflow.json rename to apps/automation/workflows/package-approved/workflow.json index c8538d5..44893bf 100644 --- a/apps/automation/workflows/plugin-approved/workflow.json +++ b/apps/automation/workflows/package-approved/workflow.json @@ -1,5 +1,5 @@ { - "name": "plugin-approved", + "name": "package-approved", "description": null, "nodes": [ { @@ -13,7 +13,7 @@ ], "parameters": { "httpMethod": "POST", - "path": "strapi/plugin-approved", + "path": "strapi/package-approved", "authentication": "headerAuth", "responseMode": "onReceived", "responseData": "noData", @@ -21,7 +21,7 @@ }, "onError": "continueRegularOutput", "alwaysOutputData": true, - "notes": "Fired by the CMS moderation plugin after plugin-submission.promoteToPackage. Payload includes package_id + package_slug (may be null).", + "notes": "Fired by the CMS moderation plugin after package-submission.promoteToPackage. Payload includes package_id + package_slug (may be null).", "webhookId": "5b4adc60-df90-4ca1-aca8-c2ea4ca513d7" }, { @@ -98,7 +98,7 @@ "id": "b1", "name": "template_key", "type": "string", - "value": "plugin-approved" + "value": "package-approved" }, { "id": "b2", @@ -165,7 +165,7 @@ "id": "s1", "name": "text", "type": "string", - "value": "=:white_check_mark: *Plugin* approved & published: *{{ $json.package_name }}* by {{ $json.author_name }}\nLive: {{ $json.marketplace_link }}" + "value": "=:white_check_mark: *Package* approved & published: *{{ $json.package_name }}* by {{ $json.author_name }}\nLive: {{ $json.marketplace_link }}" } ] }, diff --git a/apps/automation/workflows/plugin-changes-requested/workflow.json b/apps/automation/workflows/package-changes-requested/workflow.json similarity index 92% rename from apps/automation/workflows/plugin-changes-requested/workflow.json rename to apps/automation/workflows/package-changes-requested/workflow.json index 7f8042c..d625e31 100644 --- a/apps/automation/workflows/plugin-changes-requested/workflow.json +++ b/apps/automation/workflows/package-changes-requested/workflow.json @@ -1,5 +1,5 @@ { - "name": "plugin-changes-requested", + "name": "package-changes-requested", "description": null, "nodes": [ { @@ -13,7 +13,7 @@ ], "parameters": { "httpMethod": "POST", - "path": "strapi/plugin-changes-requested", + "path": "strapi/package-changes-requested", "authentication": "headerAuth", "responseMode": "onReceived", "responseData": "noData", @@ -21,7 +21,7 @@ }, "onError": "continueRegularOutput", "alwaysOutputData": true, - "notes": "Fired by the CMS moderation plugin after plugin-submission.rejectOrRequestChanges with status='changes_requested'. Payload: flat { submissionId, plugin_name, owner_email, reason, feedback, dashboard_link, ... }.", + "notes": "Fired by the CMS moderation plugin after package-submission.rejectOrRequestChanges with status='changes_requested'. Payload: flat { submissionId, package_name, owner_email, reason, feedback, dashboard_link, ... }.", "webhookId": "2f6e1cec-6e97-4e6d-b9f6-8b3fbda1e310" }, { @@ -98,7 +98,7 @@ "id": "b1", "name": "template_key", "type": "string", - "value": "plugin-changes-requested" + "value": "package-changes-requested" }, { "id": "b2", diff --git a/apps/automation/workflows/plugin-declined/workflow.json b/apps/automation/workflows/package-declined/workflow.json similarity index 93% rename from apps/automation/workflows/plugin-declined/workflow.json rename to apps/automation/workflows/package-declined/workflow.json index 4446d98..a7622f9 100644 --- a/apps/automation/workflows/plugin-declined/workflow.json +++ b/apps/automation/workflows/package-declined/workflow.json @@ -1,5 +1,5 @@ { - "name": "plugin-declined", + "name": "package-declined", "description": null, "nodes": [ { @@ -13,7 +13,7 @@ ], "parameters": { "httpMethod": "POST", - "path": "strapi/plugin-declined", + "path": "strapi/package-declined", "authentication": "headerAuth", "responseMode": "onReceived", "responseData": "noData", @@ -21,7 +21,7 @@ }, "onError": "continueRegularOutput", "alwaysOutputData": true, - "notes": "Fired by the CMS moderation plugin after plugin-submission.rejectOrRequestChanges with status='rejected'. Payload: flat { submissionId, plugin_name, owner_email, reason, feedback, ... }.", + "notes": "Fired by the CMS moderation plugin after package-submission.rejectOrRequestChanges with status='rejected'. Payload: flat { submissionId, package_name, owner_email, reason, feedback, ... }.", "webhookId": "37f3f735-2a1c-4c22-89e0-237bb2b5a245" }, { @@ -86,7 +86,7 @@ "id": "b1", "name": "template_key", "type": "string", - "value": "plugin-declined" + "value": "package-declined" }, { "id": "b2", diff --git a/apps/automation/workflows/plugin-submission-received/workflow.json b/apps/automation/workflows/package-submission-received/workflow.json similarity index 92% rename from apps/automation/workflows/plugin-submission-received/workflow.json rename to apps/automation/workflows/package-submission-received/workflow.json index c299182..0faff84 100644 --- a/apps/automation/workflows/plugin-submission-received/workflow.json +++ b/apps/automation/workflows/package-submission-received/workflow.json @@ -1,5 +1,5 @@ { - "name": "plugin-submission-received", + "name": "package-submission-received", "description": null, "nodes": [ { @@ -13,7 +13,7 @@ ], "parameters": { "httpMethod": "POST", - "path": "strapi/plugin-submission-received", + "path": "strapi/package-submission-received", "authentication": "headerAuth", "responseMode": "onReceived", "responseData": "noData", @@ -21,7 +21,7 @@ }, "onError": "continueRegularOutput", "alwaysOutputData": true, - "notes": "Fired by the CMS moderation plugin after plugin-submission.createSubmission. Payload: flat { submissionId, plugin_name, owner_name, owner_email, repository_url, dashboard_link, ... }.", + "notes": "Fired by the CMS moderation plugin after package-submission.createSubmission. Payload: flat { submissionId, package_name, owner_name, owner_email, repository_url, dashboard_link, ... }.", "webhookId": "e9741c13-e5b0-40a4-99a5-9a30031ea8a7" }, { @@ -98,7 +98,7 @@ "id": "b1", "name": "template_key", "type": "string", - "value": "plugin-submission-received" + "value": "package-submission-received" }, { "id": "b2", @@ -166,7 +166,7 @@ "id": "s1", "name": "text", "type": "string", - "value": "=:package: New *plugin* submission received: *{{ $json.package_name }}* by {{ $json.author_name }}\nRepo: {{ $json.git_repository }}\nReview: {{ $json.dashboard_link }}" + "value": "=:package: New *package* submission received: *{{ $json.package_name }}* by {{ $json.author_name }}\nRepo: {{ $json.git_repository }}\nReview: {{ $json.dashboard_link }}" } ] }, diff --git a/apps/automation/workflows/scan-timeout-sweeper/workflow.json b/apps/automation/workflows/scan-timeout-sweeper/workflow.json index da1024b..b9f551c 100644 --- a/apps/automation/workflows/scan-timeout-sweeper/workflow.json +++ b/apps/automation/workflows/scan-timeout-sweeper/workflow.json @@ -12,7 +12,7 @@ 60 ], "parameters": { - "content": "## Scan Timeout Sweeper\n\nRuns every 15 minutes. Finds submissions whose `security_scan_status='running'` with `updatedAt` older than 30 minutes \u2014 assumes they're stuck (workflow killed mid-run, n8n container restart, unrecoverable crash) \u2014 and PATCHes each to `security_scan_status='failed'` via the moderation content-api write-back route.\n\nHandles both plugin-submissions and template-submissions. Posts a Slack summary to `#integration-marketplace` when any stuck scans are swept, silent otherwise.\n\n**Required n8n env vars:**\n- `STRAPI_API_URL`\n\n**Required credentials:**\n- `httpHeaderAuth` \u2014 Strapi API token (shared with security-scan)", + "content": "## Scan Timeout Sweeper\n\nRuns every 15 minutes. Finds submissions whose `security_scan_status='running'` with `updatedAt` older than 30 minutes \u2014 assumes they're stuck (workflow killed mid-run, n8n container restart, unrecoverable crash) \u2014 and PATCHes each to `security_scan_status='failed'` via the moderation content-api write-back route.\n\nHandles both package-submissions and template-submissions. Posts a Slack summary to `#integration-marketplace` when any stuck scans are swept, silent otherwise.\n\n**Required n8n env vars:**\n- `STRAPI_API_URL`\n\n**Required credentials:**\n- `httpHeaderAuth` \u2014 Strapi API token (shared with security-scan)", "height": 300, "width": 760, "color": 6 @@ -54,8 +54,8 @@ "onError": "continueRegularOutput" }, { - "id": "fetch-plugins-1", - "name": "Find Stale Plugin Scans", + "id": "fetch-packages-1", + "name": "Find Stale Package Scans", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ @@ -159,7 +159,7 @@ ], "parameters": { "language": "javaScript", - "jsCode": "try {\n const pluginRes = $('Find Stale Plugin Scans').first().json || {};\n const templateRes = $('Find Stale Template Scans').first().json || {};\n\n function extract(res, kind) {\n const items = Array.isArray(res?.data) ? res.data : [];\n return items\n .map((item) => ({\n kind,\n documentId: item.documentId || item.id,\n name: item.plugin_name || item.template_name || 'unknown',\n started_at: item.security_scan_started_at || null,\n }))\n .filter((x) => x.documentId);\n }\n\n const stuck = [\n ...extract(pluginRes, 'plugin'),\n ...extract(templateRes, 'template'),\n ];\n\n if (stuck.length === 0) return [];\n return stuck.map((s) => ({ json: s }));\n} catch (err) {\n return [{ json: { _node_error: err.message || String(err) } }];\n}" + "jsCode": "try {\n const pluginRes = $('Find Stale Package Scans').first().json || {};\n const templateRes = $('Find Stale Template Scans').first().json || {};\n\n function extract(res, kind) {\n const items = Array.isArray(res?.data) ? res.data : [];\n return items\n .map((item) => ({\n kind,\n documentId: item.documentId || item.id,\n name: item.name || item.template_name || 'unknown',\n started_at: item.security_scan_started_at || null,\n }))\n .filter((x) => x.documentId);\n }\n\n const stuck = [\n ...extract(pluginRes, 'package'),\n ...extract(templateRes, 'template'),\n ];\n\n if (stuck.length === 0) return [];\n return stuck.map((s) => ({ json: s }));\n} catch (err) {\n return [{ json: { _node_error: err.message || String(err) } }];\n}" }, "onError": "continueRegularOutput", "notes": "Returns one item per stuck submission so downstream runs once per. Returns [] (no items) when everything's healthy \u2014 downstream branches short-circuit." @@ -258,7 +258,7 @@ "main": [ [ { - "node": "Find Stale Plugin Scans", + "node": "Find Stale Package Scans", "type": "main", "index": 0 }, @@ -270,7 +270,7 @@ ] ] }, - "Find Stale Plugin Scans": { + "Find Stale Package Scans": { "main": [ [ { diff --git a/apps/automation/workflows/security-scan/workflow.json b/apps/automation/workflows/security-scan/workflow.json index baccab7..45ed74e 100644 --- a/apps/automation/workflows/security-scan/workflow.json +++ b/apps/automation/workflows/security-scan/workflow.json @@ -12,7 +12,7 @@ 60 ], "parameters": { - "content": "## Security Scan (issue #10)\n\nManually triggered from the Strapi moderation admin (cost control \u2014 not automatic on submit).\n\n**Payload is pre-enriched by the CMS.** The moderation plugin's `get-package-security-info.js` service reuses `package-info`'s registry extract patterns to fetch metadata before firing this webhook \u2014 so this workflow does not hit any registry directly.\n\nSupports both `plugin` and `template` submissions:\n- **plugin:** `package_info` is populated when a registry URL is provided (phase 1 = npm only; packagist/pypi/rubygems/nuget stubbed with `notImplemented: true`)\n- **template:** `package_info` is always null; scan is repo-only + AI\n\nTwo parallel branches:\n1. **Package scan** \u2014 OSV.dev vuln lookup + repo package.json cross-check + install-script flag\n2. **AI analysis** \u2014 Claude Haiku 4.5 reads the published README (falls back to submitted description) and flags supply-chain risks\n\nPer-stage results PATCH back via `POST /api/moderation/-submissions/:documentId/security-scan-result`. Final summary sets `security_scan_status=completed`.\n\n**Inputs (POST body):**\n`{ submissionId, submission_kind: 'plugin'|'template', plugin_name, package_location, repository_url, readme, package_info | null }`\n\n**Required n8n credentials:**\n- `httpHeaderAuth` for the webhook (matches `N8N_WEBHOOK_AUTH_*` in CMS .env)\n- `httpHeaderAuth` separate for Strapi write-back (Strapi API token as `Authorization: Bearer ...`)\n- `httpHeaderAuth` separate for Anthropic API (`x-api-key: ...`)\n\n**Required n8n env vars:**\n- `STRAPI_API_URL` (base URL of the CMS)", + "content": "## Security Scan (issue #10)\n\nManually triggered from the Strapi moderation admin (cost control \u2014 not automatic on submit).\n\n**Payload is pre-enriched by the CMS.** The moderation plugin's `get-package-security-info.js` service reuses `package-info`'s registry extract patterns to fetch metadata before firing this webhook \u2014 so this workflow does not hit any registry directly.\n\nSupports both `package` and `template` submissions:\n- **package:** `package_info` is populated when a registry URL is provided (phase 1 = npm only; packagist/pypi/rubygems/nuget stubbed with `notImplemented: true`)\n- **template:** `package_info` is always null; scan is repo-only + AI\n\nTwo parallel branches:\n1. **Package scan** \u2014 OSV.dev vuln lookup + repo package.json cross-check + install-script flag\n2. **AI analysis** \u2014 Claude Haiku 4.5 reads the published README (falls back to submitted description) and flags supply-chain risks\n\nPer-stage results PATCH back via `POST /api/moderation/-submissions/:documentId/security-scan-result`. Final summary sets `security_scan_status=completed`.\n\n**Inputs (POST body):**\n`{ submissionId, submission_kind: 'package'|'template', package_name, package_location, repository_url, readme, package_info | null }`\n\n**Required n8n credentials:**\n- `httpHeaderAuth` for the webhook (matches `N8N_WEBHOOK_AUTH_*` in CMS .env)\n- `httpHeaderAuth` separate for Strapi write-back (Strapi API token as `Authorization: Bearer ...`)\n- `httpHeaderAuth` separate for Anthropic API (`x-api-key: ...`)\n\n**Required n8n env vars:**\n- `STRAPI_API_URL` (base URL of the CMS)", "height": 500, "width": 980, "color": 5 @@ -64,11 +64,11 @@ "id": "a2", "name": "submission_kind", "type": "string", - "value": "={{ $json.body.submission_kind || 'plugin' }}" + "value": "={{ $json.body.submission_kind || 'package' }}" }, { "id": "a3", - "name": "plugin_name", + "name": "package_name", "type": "string", "value": "={{ $json.body.name }}" }, @@ -157,7 +157,7 @@ ], "parameters": { "language": "javaScript", - "jsCode": "try {\nconst repoPkg = $input.first().json;\nconst payload = $('Parse Repo URL').item.json;\nconst info = payload.package_info || null;\nconst kind = payload.submission_kind || 'plugin';\n\nconst repoValid = repoPkg && typeof repoPkg === 'object' && !Array.isArray(repoPkg) && !repoPkg.error && Object.keys(repoPkg).length > 0;\nconst pkgAvailable = !!(info && info.available);\n\n// Dependencies source: published package first, repo fallback.\nconst pkgDeps = pkgAvailable ? { ...(info.dependencies || {}), ...(info.peerDependencies || {}) } : {};\nconst repoDeps = repoValid ? { ...(repoPkg.dependencies || {}), ...(repoPkg.peerDependencies || {}) } : {};\nconst depsSource = pkgAvailable ? 'registry' : (repoValid ? 'repo' : 'none');\nconst deps = depsSource === 'registry' ? pkgDeps : (depsSource === 'repo' ? repoDeps : {});\n\n// OSV ecosystem tag from the pre-detected registry, default to npm.\nconst ecosystem = info?.ecosystem || 'npm';\n\nfunction cleanVersion(raw) {\n if (typeof raw !== 'string') return null;\n const match = raw.match(/\\d+\\.\\d+\\.\\d+/);\n return match ? match[0] : null;\n}\n\nconst queries = Object.entries(deps)\n .map(([name, version]) => {\n const v = cleanVersion(version);\n if (!v) return null;\n return { package: { name, ecosystem }, version: v };\n })\n .filter(Boolean)\n .slice(0, 100);\n\n// Cross-check published vs repo (only when BOTH are present).\nconst mismatches = [];\nif (pkgAvailable && repoValid) {\n ['preinstall', 'install', 'postinstall'].forEach((k) => {\n const pkgVal = info.scripts?.[k] || null;\n const repoVal = repoPkg.scripts?.[k] || null;\n if (pkgVal !== repoVal) {\n mismatches.push({ kind: 'install_script_divergence', script: k, published: pkgVal, repo: repoVal });\n }\n });\n const pkgSet = new Set(Object.keys(pkgDeps));\n const repoSet = new Set(Object.keys(repoDeps));\n const onlyInPkg = [...pkgSet].filter((n) => !repoSet.has(n));\n const onlyInRepo = [...repoSet].filter((n) => !pkgSet.has(n));\n if (onlyInPkg.length > 0) mismatches.push({ kind: 'deps_only_in_published', names: onlyInPkg.slice(0, 20) });\n if (onlyInRepo.length > 0) mismatches.push({ kind: 'deps_only_in_repo', names: onlyInRepo.slice(0, 20) });\n const submitted = (payload.repository_url || '').toLowerCase();\n const declared = (info.declaredRepository || '').toLowerCase();\n const owner = payload.repo_info?.owner?.toLowerCase();\n if (declared && submitted && owner && !declared.includes(owner)) {\n mismatches.push({ kind: 'declared_repo_vs_submitted', submitted, declared });\n }\n}\n\nconst hasInstallScripts = pkgAvailable && Object.keys(info.installScripts || {}).length > 0;\n\nreturn [{\n json: {\n queries,\n _skipped: queries.length === 0,\n _reason: queries.length === 0 ? `No dependencies to scan (source: ${depsSource}).` : null,\n ecosystem,\n depsSource,\n kind,\n hasInstallScripts,\n install_scripts: pkgAvailable ? info.installScripts || {} : {},\n mismatches,\n registry_available: pkgAvailable,\n not_implemented: info?.notImplemented === true,\n registry: info?.registry || null,\n packageName: info?.packageName || null,\n version: info?.version || null,\n dist: info?.dist || null,\n },\n}];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: osv-build-1\n return [{ json: { queries: [], _skipped: true, _reason: \"Build Package Scan Data threw: \" + errMsg, ecosystem: \"npm\", depsSource: \"none\", kind: null, hasInstallScripts: false, install_scripts: {}, mismatches: [], registry_available: false, not_implemented: false, registry: null, packageName: null, version: null, dist: null, _node_error: errMsg } }];\n}" + "jsCode": "try {\nconst repoPkg = $input.first().json;\nconst payload = $('Parse Repo URL').item.json;\nconst info = payload.package_info || null;\nconst kind = payload.submission_kind || 'package';\n\nconst repoValid = repoPkg && typeof repoPkg === 'object' && !Array.isArray(repoPkg) && !repoPkg.error && Object.keys(repoPkg).length > 0;\nconst pkgAvailable = !!(info && info.available);\n\n// Dependencies source: published package first, repo fallback.\nconst pkgDeps = pkgAvailable ? { ...(info.dependencies || {}), ...(info.peerDependencies || {}) } : {};\nconst repoDeps = repoValid ? { ...(repoPkg.dependencies || {}), ...(repoPkg.peerDependencies || {}) } : {};\nconst depsSource = pkgAvailable ? 'registry' : (repoValid ? 'repo' : 'none');\nconst deps = depsSource === 'registry' ? pkgDeps : (depsSource === 'repo' ? repoDeps : {});\n\n// OSV ecosystem tag from the pre-detected registry, default to npm.\nconst ecosystem = info?.ecosystem || 'npm';\n\nfunction cleanVersion(raw) {\n if (typeof raw !== 'string') return null;\n const match = raw.match(/\\d+\\.\\d+\\.\\d+/);\n return match ? match[0] : null;\n}\n\nconst queries = Object.entries(deps)\n .map(([name, version]) => {\n const v = cleanVersion(version);\n if (!v) return null;\n return { package: { name, ecosystem }, version: v };\n })\n .filter(Boolean)\n .slice(0, 100);\n\n// Cross-check published vs repo (only when BOTH are present).\nconst mismatches = [];\nif (pkgAvailable && repoValid) {\n ['preinstall', 'install', 'postinstall'].forEach((k) => {\n const pkgVal = info.scripts?.[k] || null;\n const repoVal = repoPkg.scripts?.[k] || null;\n if (pkgVal !== repoVal) {\n mismatches.push({ kind: 'install_script_divergence', script: k, published: pkgVal, repo: repoVal });\n }\n });\n const pkgSet = new Set(Object.keys(pkgDeps));\n const repoSet = new Set(Object.keys(repoDeps));\n const onlyInPkg = [...pkgSet].filter((n) => !repoSet.has(n));\n const onlyInRepo = [...repoSet].filter((n) => !pkgSet.has(n));\n if (onlyInPkg.length > 0) mismatches.push({ kind: 'deps_only_in_published', names: onlyInPkg.slice(0, 20) });\n if (onlyInRepo.length > 0) mismatches.push({ kind: 'deps_only_in_repo', names: onlyInRepo.slice(0, 20) });\n const submitted = (payload.repository_url || '').toLowerCase();\n const declared = (info.declaredRepository || '').toLowerCase();\n const owner = payload.repo_info?.owner?.toLowerCase();\n if (declared && submitted && owner && !declared.includes(owner)) {\n mismatches.push({ kind: 'declared_repo_vs_submitted', submitted, declared });\n }\n}\n\nconst hasInstallScripts = pkgAvailable && Object.keys(info.installScripts || {}).length > 0;\n\nreturn [{\n json: {\n queries,\n _skipped: queries.length === 0,\n _reason: queries.length === 0 ? `No dependencies to scan (source: ${depsSource}).` : null,\n ecosystem,\n depsSource,\n kind,\n hasInstallScripts,\n install_scripts: pkgAvailable ? info.installScripts || {} : {},\n mismatches,\n registry_available: pkgAvailable,\n not_implemented: info?.notImplemented === true,\n registry: info?.registry || null,\n packageName: info?.packageName || null,\n version: info?.version || null,\n dist: info?.dist || null,\n },\n}];\n} catch (err) {\n const errMsg = err && err.message ? err.message : String(err);\n // Fallback shape so downstream nodes can proceed.\n // Stage: osv-build-1\n return [{ json: { queries: [], _skipped: true, _reason: \"Build Package Scan Data threw: \" + errMsg, ecosystem: \"npm\", depsSource: \"none\", kind: null, hasInstallScripts: false, install_scripts: {}, mismatches: [], registry_available: false, not_implemented: false, registry: null, packageName: null, version: null, dist: null, _node_error: errMsg } }];\n}" }, "notes": "Consolidates the pre-fetched package_info with the repo cross-check. Uses the package_info.ecosystem for the OSV ecosystem tag. For template submissions (no package_info), only repo-side deps feed OSV.", "onError": "continueRegularOutput" @@ -282,7 +282,7 @@ "sendBody": true, "contentType": "json", "specifyBody": "json", - "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, system: 'You are a security auditor reviewing a Strapi community submission. You are given package metadata, install-time scripts, the README, and ACTUAL SOURCE FILES from the published package (or source repo for templates). Read the source files carefully \u2014 runtime exfiltration, credential theft, obfuscation, and suspicious network calls often hide in main entry files or bin scripts, not the README. Prioritise supply-chain signals: install-time scripts, typosquatting, mismatches between the published artifact and its source repo, obfuscated or minified code, dynamic eval / Function constructor use, network calls to unknown hosts, filesystem writes outside package dir. Respond ONLY with valid JSON matching this schema: {\"risk_level\":\"low|medium|high\",\"summary\":\"\",\"concerns\":[\"\",...],\"red_flags\":[\"\",...],\"recommendation\":\"approve|request_changes|reject\"}. Do not include explanation text outside the JSON.', messages: [{ role: 'user', content: `Submission kind: ${$json.submission_kind}\\nName: ${$json.plugin_name}\\npackage_location: ${$json.package_location || '(none \u2014 template or no registry URL)'}\\nregistry: ${$json.package_info?.registry || 'n/a'}\\nregistry_available: ${$json.package_info?.available ? 'yes' : 'no'}${$json.package_info?.notImplemented ? ' (deep scan not yet implemented for this registry)' : ''}\\nVersion: ${$json.package_info?.version || 'n/a'}\\nSubmitted repo: ${$json.repository_url}\\nDeclared repo on published artifact: ${$json.package_info?.declaredRepository || '(none declared)'}\\n\\nInstall-time scripts on the published artifact (immediate red flag if any are non-trivial):\\n${JSON.stringify($json.package_info?.installScripts || {}, null, 2)}\\n\\nSource files scanned: ${$json.files_scanned} of ${$json.files_available} (${$json.bytes_scanned} bytes, source: ${$json.scan_source || 'none'}).\\n\\nREADME:\\n---\\n${(($json.package_info?.readme || $json.readme) || '(no README available)').toString().slice(0, 8000)}\\n---\\n\\nSOURCE FILES (truncated per-file + overall):\\n---\\n${$json.package_code || '(no source files could be fetched)'}\\n---\\n\\nReview the code above against the schema. Output JSON only.` }] }) }}", + "jsonBody": "={{ JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 1024, system: 'You are a security auditor reviewing a Strapi community submission. You are given package metadata, install-time scripts, the README, and ACTUAL SOURCE FILES from the published package (or source repo for templates). Read the source files carefully \u2014 runtime exfiltration, credential theft, obfuscation, and suspicious network calls often hide in main entry files or bin scripts, not the README. Prioritise supply-chain signals: install-time scripts, typosquatting, mismatches between the published artifact and its source repo, obfuscated or minified code, dynamic eval / Function constructor use, network calls to unknown hosts, filesystem writes outside package dir. Respond ONLY with valid JSON matching this schema: {\"risk_level\":\"low|medium|high\",\"summary\":\"\",\"concerns\":[\"\",...],\"red_flags\":[\"\",...],\"recommendation\":\"approve|request_changes|reject\"}. Do not include explanation text outside the JSON.', messages: [{ role: 'user', content: `Submission kind: ${$json.submission_kind}\\nName: ${$json.package_name}\\npackage_location: ${$json.package_location || '(none \u2014 template or no registry URL)'}\\nregistry: ${$json.package_info?.registry || 'n/a'}\\nregistry_available: ${$json.package_info?.available ? 'yes' : 'no'}${$json.package_info?.notImplemented ? ' (deep scan not yet implemented for this registry)' : ''}\\nVersion: ${$json.package_info?.version || 'n/a'}\\nSubmitted repo: ${$json.repository_url}\\nDeclared repo on published artifact: ${$json.package_info?.declaredRepository || '(none declared)'}\\n\\nInstall-time scripts on the published artifact (immediate red flag if any are non-trivial):\\n${JSON.stringify($json.package_info?.installScripts || {}, null, 2)}\\n\\nSource files scanned: ${$json.files_scanned} of ${$json.files_available} (${$json.bytes_scanned} bytes, source: ${$json.scan_source || 'none'}).\\n\\nREADME:\\n---\\n${(($json.package_info?.readme || $json.readme) || '(no README available)').toString().slice(0, 8000)}\\n---\\n\\nSOURCE FILES (truncated per-file + overall):\\n---\\n${$json.package_code || '(no source files could be fetched)'}\\n---\\n\\nReview the code above against the schema. Output JSON only.` }] }) }}", "options": { "timeout": 45000 } diff --git a/apps/automation/workflows/showcase-approved/workflow.json b/apps/automation/workflows/showcase-approved/workflow.json new file mode 100644 index 0000000..b15faa0 --- /dev/null +++ b/apps/automation/workflows/showcase-approved/workflow.json @@ -0,0 +1,264 @@ +{ + "name": "showcase-approved", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Showcase Approved", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/showcase-approved", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after showcase-submission.publish. Payload: flat { documentId, kind, name, owner_email, owner_name, dashboard_link, ... }.", + "webhookId": "e5c39b4a-6d0f-4a1e-c7b3-8f9a0b1c2d3e" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "showcase_id", + "type": "string", + "value": "={{ $json.body.documentId }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.name }}" + }, + { + "id": "a3", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a4", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a5", + "name": "dashboard_link", + "type": "string", + "value": "={{ $json.body.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 200 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "showcase-approved" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 200 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "build-slack-1", + "name": "Build Slack Message", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 400 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "s1", + "name": "text", + "type": "string", + "value": "=:white_check_mark: *Showcase* approved & published: *{{ $json.package_name }}* by {{ $json.author_name }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 900, + 400 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "f6d40c5b-7e1a-4b2f-d8c4-9a0b1c2d3e4f" + } + ], + "connections": { + "Webhook: Showcase Approved": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + }, + { + "node": "Build Slack Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Slack Message": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/showcase-declined/workflow.json b/apps/automation/workflows/showcase-declined/workflow.json new file mode 100644 index 0000000..354ad44 --- /dev/null +++ b/apps/automation/workflows/showcase-declined/workflow.json @@ -0,0 +1,189 @@ +{ + "name": "showcase-declined", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Showcase Declined", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/showcase-declined", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after showcase-submission.decide with status='rejected'. Payload: flat { documentId, kind, name, owner_email, owner_name, reason, feedback, ... }.", + "webhookId": "a7e51d6c-8f2b-4c3a-e9d5-0b1c2d3e4f5a" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.name }}" + }, + { + "id": "a2", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a3", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a4", + "name": "decline_reason", + "type": "string", + "value": "={{ $json.body.reason || $json.body.feedback || '' }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "showcase-declined" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name, decline_reason: $json.decline_reason } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 300 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + } + ], + "connections": { + "Webhook: Showcase Declined": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/automation/workflows/showcase-submission-received/workflow.json b/apps/automation/workflows/showcase-submission-received/workflow.json new file mode 100644 index 0000000..5ed342a --- /dev/null +++ b/apps/automation/workflows/showcase-submission-received/workflow.json @@ -0,0 +1,264 @@ +{ + "name": "showcase-submission-received", + "description": null, + "nodes": [ + { + "id": "webhook-1", + "name": "Webhook: Submission Received", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2.1, + "position": [ + 240, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "strapi/showcase-submission-received", + "authentication": "headerAuth", + "responseMode": "onReceived", + "responseData": "noData", + "options": {} + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true, + "notes": "Fired by the CMS moderation plugin after showcase-submission.createSubmission. Payload: flat { documentId, kind, name, owner_name, owner_email, dashboard_link, ... }.", + "webhookId": "c3a17f2e-4b8d-4e9c-a5f1-6d7e8f9a0b1c" + }, + { + "id": "extract-1", + "name": "Extract Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "a1", + "name": "showcase_id", + "type": "string", + "value": "={{ $json.body.documentId }}" + }, + { + "id": "a2", + "name": "package_name", + "type": "string", + "value": "={{ $json.body.name }}" + }, + { + "id": "a3", + "name": "author_email", + "type": "string", + "value": "={{ $json.body.owner_email }}" + }, + { + "id": "a4", + "name": "author_name", + "type": "string", + "value": "={{ $json.body.owner_name }}" + }, + { + "id": "a5", + "name": "dashboard_link", + "type": "string", + "value": "={{ $json.body.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "build-email-1", + "name": "Build Email Payload", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 200 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "b1", + "name": "template_key", + "type": "string", + "value": "showcase-submission-received" + }, + { + "id": "b2", + "name": "to_email", + "type": "string", + "value": "={{ $json.author_email }}" + }, + { + "id": "b3", + "name": "to_name", + "type": "string", + "value": "={{ $json.author_name }}" + }, + { + "id": "b4", + "name": "variables", + "type": "object", + "value": "={{ { package_name: $json.package_name, author_name: $json.author_name } }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "send-email-1", + "name": "Send Developer Email", + "type": "n8n-nodes-base.executeWorkflow", + "typeVersion": 1.1, + "position": [ + 900, + 200 + ], + "parameters": { + "source": "database", + "workflowId": { + "__rl": true, + "mode": "id", + "value": "1MgLRIjI37T2Kd5W" + }, + "mode": "once", + "options": {} + }, + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000 + }, + { + "id": "build-slack-1", + "name": "Build Slack Message", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 680, + 400 + ], + "parameters": { + "mode": "manual", + "duplicateItem": false, + "assignments": { + "assignments": [ + { + "id": "s1", + "name": "text", + "type": "string", + "value": "=:frame_with_picture: New *showcase* submission received: *{{ $json.package_name }}* by {{ $json.author_name }}\nReview: {{ $json.dashboard_link }}" + } + ] + }, + "includeOtherFields": false, + "options": {} + } + }, + { + "id": "slack-1", + "name": "Notify #integration-marketplace", + "type": "n8n-nodes-base.slack", + "typeVersion": 2.4, + "position": [ + 900, + 400 + ], + "parameters": { + "resource": "message", + "operation": "post", + "select": "channel", + "channelId": { + "__rl": true, + "mode": "name", + "value": "integration-marketplace" + }, + "text": "={{ $json.text }}", + "otherOptions": {} + }, + "onError": "continueRegularOutput", + "retryOnFail": true, + "maxTries": 2, + "waitBetweenTries": 3000, + "webhookId": "d4b28a3f-5c9e-4f0d-b6a2-7e8f9b0c1d2e" + } + ], + "connections": { + "Webhook: Submission Received": { + "main": [ + [ + { + "node": "Extract Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Extract Payload": { + "main": [ + [ + { + "node": "Build Email Payload", + "type": "main", + "index": 0 + }, + { + "node": "Build Slack Message", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email Payload": { + "main": [ + [ + { + "node": "Send Developer Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Slack Message": { + "main": [ + [ + { + "node": "Notify #integration-marketplace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "saveDataErrorExecution": "all", + "saveDataSuccessExecution": "all", + "saveManualExecutions": true, + "saveExecutionProgress": true, + "callerPolicy": "workflowsFromSameOwner", + "availableInMCP": false, + "errorWorkflow": "SYm72hu6NXus7iah" + }, + "staticData": null, + "activeVersionId": null, + "versionCounter": 1, + "tags": [], + "activeVersion": null +} diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index ab3c202..5f0f8a9 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -24,7 +24,7 @@ export default ({ env }) => ({ uid: "api::package.package", singularName: "package", pluralName: "packages", - label: "Plugins", + label: "Packages", categoryUid: "api::package-category.package-category", defaultFieldValues: { labels: { official: false, featured: false, paid: false }, @@ -37,10 +37,10 @@ export default ({ env }) => ({ "enterprise_competition", ], webhooks: { - submissionReceived: "strapi/plugin-submission-received", - approved: "strapi/plugin-approved", - declined: "strapi/plugin-declined", - changesRequested: "strapi/plugin-changes-requested", + submissionReceived: "strapi/package-submission-received", + approved: "strapi/package-approved", + declined: "strapi/package-declined", + changesRequested: "strapi/package-changes-requested", }, }, { @@ -59,6 +59,20 @@ export default ({ env }) => ({ declined: "strapi/template-declined", }, }, + { + uid: "api::showcase.showcase", + singularName: "showcase", + pluralName: "showcases", + label: "Showcases", + nameField: "title", + categoryUid: "api::showcase-category.showcase-category", + checks: [], + webhooks: { + submissionReceived: "strapi/showcase-submission-received", + approved: "strapi/showcase-approved", + declined: "strapi/showcase-declined", + }, + }, ], }, }, diff --git a/apps/cms/src/api/showcase/content-types/showcase/schema.json b/apps/cms/src/api/showcase/content-types/showcase/schema.json index cc8c3c7..c8df54f 100644 --- a/apps/cms/src/api/showcase/content-types/showcase/schema.json +++ b/apps/cms/src/api/showcase/content-types/showcase/schema.json @@ -7,7 +7,7 @@ "displayName": "Showcases" }, "options": { - "draftAndPublish": false + "draftAndPublish": true }, "pluginOptions": { "webtools": { diff --git a/apps/cms/src/plugins/moderation/server/src/config.ts b/apps/cms/src/plugins/moderation/server/src/config.ts index b4abc3b..b1e106f 100644 --- a/apps/cms/src/plugins/moderation/server/src/config.ts +++ b/apps/cms/src/plugins/moderation/server/src/config.ts @@ -11,6 +11,8 @@ export interface ModerationContentTypeConfig { pluralName: string; /** Label shown in the admin UI tab */ label?: string; + /** Schema field used as the human-readable name (defaults to "name") */ + nameField?: string; /** UID of the category content type used to resolve categories_list strings */ categoryUid?: string; /** Default entity field values set on every new submission */ diff --git a/apps/cms/src/plugins/moderation/server/src/services/submission.ts b/apps/cms/src/plugins/moderation/server/src/services/submission.ts index 072a533..5d5df14 100644 --- a/apps/cms/src/plugins/moderation/server/src/services/submission.ts +++ b/apps/cms/src/plugins/moderation/server/src/services/submission.ts @@ -46,11 +46,21 @@ function buildAdminLink(uid: string, documentId: string) { /** Coarse submission kind used by n8n for labels/routing. */ function kindForUid(uid: string) { - if (uid === PACKAGE_UID) return "plugin"; + if (uid === PACKAGE_UID) return "package"; if (uid === "api::template.template") return "template"; + if (uid === "api::showcase.showcase") return "showcase"; return uid.split(".").pop() ?? uid; } +/** Read the human-readable name from an entity, respecting per-CT nameField config. */ +function getEntityName( + entity: Record, + ctConfig: ModerationContentTypeConfig, +): string | null { + const field = ctConfig.nameField ?? "name"; + return (entity[field] as string) ?? null; +} + /** Pull recipient contact off a populated `owner` relation (better-auth user). */ function ownerContact(entity: Record) { const owner = entity.owner as { email?: string; name?: string } | null; @@ -185,7 +195,7 @@ export default ({ strapi }) => { documentId: entity.documentId, contentType: uid, kind: kindForUid(uid), - name: entity.name, + name: getEntityName(entity, ctConfig), git_repository: entity.git_repository ?? null, owner_email: (rawBody.owner_email as string) ?? null, owner_name: @@ -391,7 +401,7 @@ export default ({ strapi }) => { documentId, contentType: uid, kind: kindForUid(uid), - name: entity.name, + name: getEntityName(entity, ctConfig), slug: published.slug ?? null, ...ownerContact(entity), dashboard_link: buildAdminLink(uid, documentId), @@ -454,7 +464,7 @@ export default ({ strapi }) => { documentId, contentType: uid, kind: kindForUid(uid), - name: entity.name, + name: getEntityName(entity, ctConfig), reason: reason ?? null, feedback: feedback ?? null, ...ownerContact(entity), diff --git a/apps/cms/src/seed/email-templates.ts b/apps/cms/src/seed/email-templates.ts index a9972f3..cee2bad 100644 --- a/apps/cms/src/seed/email-templates.ts +++ b/apps/cms/src/seed/email-templates.ts @@ -11,7 +11,7 @@ type SeedTemplate = { const seeds: SeedTemplate[] = [ { - key: "plugin-submission-received", + key: "package-submission-received", subject: "We've received your submission: {{ package_name }}", description: "Sent by n8n when a package is submitted to the community marketplace. Variables: package_name, author_name, git_repository.", @@ -26,8 +26,8 @@ In the meantime, you can keep iterating on the repository; the review looks at t — The Strapi Community team`, }, { - key: "plugin-approved", - subject: "Your plugin is live: {{ package_name }}", + key: "package-approved", + subject: "Your package is live: {{ package_name }}", description: "Sent by n8n when both business and security review reach 'approved' and the package is published. Variables: package_name, author_name, marketplace_link.", body: `Hi {{ author_name }}, @@ -41,7 +41,7 @@ Share it, celebrate, and thanks for contributing to the ecosystem. — The Strapi Community team`, }, { - key: "plugin-declined", + key: "package-declined", subject: "Update on your submission: {{ package_name }}", description: "Sent by n8n when business review state becomes 'declined'. Variables: package_name, author_name, decline_reason.", @@ -49,7 +49,7 @@ Share it, celebrate, and thanks for contributing to the ecosystem. Thanks again for submitting **{{ package_name }}** to the Strapi community marketplace. -After review we've decided not to list this plugin at this time. Here's the specific feedback from the reviewer: +After review we've decided not to list this package at this time. Here's the specific feedback from the reviewer: > {{ decline_reason }} @@ -58,7 +58,7 @@ If you'd like to discuss the decision or address the points raised, reply to thi — The Strapi Community team`, }, { - key: "plugin-changes-requested", + key: "package-changes-requested", subject: "Changes requested for {{ package_name }}", description: "Sent by n8n when business review state becomes 'changes_requested'. Variables: package_name, author_name, reviewer_feedback. Note: the dashboard_link variable is internal (Strapi admin) and must never appear in this developer-facing email body.", @@ -70,6 +70,49 @@ Thanks for submitting **{{ package_name }}**. Before we can publish it, the revi Once you've addressed the feedback, update the repository and reply to this email — we'll re-review. +— The Strapi Community team`, + }, + { + key: "showcase-submission-received", + subject: "We've received your showcase: {{ package_name }}", + description: + "Sent by n8n when a showcase is submitted to the community marketplace. Variables: package_name, author_name, showcase_url.", + body: `Hi {{ author_name }}, + +Thanks for submitting **{{ package_name }}** to the Strapi community showcase. + +We've received your submission and it's now queued for review. You'll hear from us once a moderator approves it — typically within a few business days. + +— The Strapi Community team`, + }, + { + key: "showcase-approved", + subject: "Your showcase is live: {{ package_name }}", + description: + "Sent by n8n when a showcase submission is approved. Variables: package_name, author_name.", + body: `Hi {{ author_name }}, + +Great news — **{{ package_name }}** has been approved and is now live in the Strapi community showcase gallery. + +Thanks for sharing what you've built with Strapi! + +— The Strapi Community team`, + }, + { + key: "showcase-declined", + subject: "Update on your showcase submission: {{ package_name }}", + description: + "Sent by n8n when a showcase submission is declined. Variables: package_name, author_name, decline_reason.", + body: `Hi {{ author_name }}, + +Thanks for submitting **{{ package_name }}** to the Strapi community showcase. + +After review we've decided not to list this showcase at this time. Here's the specific feedback from the reviewer: + +> {{ decline_reason }} + +If you'd like to discuss the decision or address the points raised, reply to this email and we'll be in touch. + — The Strapi Community team`, }, { diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index 2059bc0..953fc03 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -1133,7 +1133,7 @@ export interface ApiShowcaseShowcase extends Struct.CollectionTypeSchema { singularName: 'showcase'; }; options: { - draftAndPublish: false; + draftAndPublish: true; }; pluginOptions: { webtools: { @@ -1141,6 +1141,10 @@ export interface ApiShowcaseShowcase extends Struct.CollectionTypeSchema { }; }; attributes: { + business_review: Schema.Attribute.Relation< + 'oneToOne', + 'plugin::moderation.business-review' + >; categories: Schema.Attribute.Relation< 'oneToMany', 'api::showcase-category.showcase-category' @@ -1156,9 +1160,16 @@ export interface ApiShowcaseShowcase extends Struct.CollectionTypeSchema { 'api::showcase.showcase' > & Schema.Attribute.Private; + overall_status: Schema.Attribute.Enumeration< + ['submitted', 'under_review', 'changes_requested', 'rejected', 'approved'] + >; owner: Schema.Attribute.Relation<'morphToOne'>; packages: Schema.Attribute.Relation<'oneToMany', 'api::package.package'>; publishedAt: Schema.Attribute.DateTime; + submission_notes: Schema.Attribute.Text; + submitter_agreed_to_terms: Schema.Attribute.Boolean & + Schema.Attribute.DefaultTo; + submitter_ip: Schema.Attribute.String; tech_stacks: Schema.Attribute.Relation< 'oneToMany', 'api::tech-stack.tech-stack' diff --git a/apps/web/src/app/api/submit-plugin/route.ts b/apps/web/src/app/api/submit-package/route.ts similarity index 91% rename from apps/web/src/app/api/submit-plugin/route.ts rename to apps/web/src/app/api/submit-package/route.ts index f240250..5400c2b 100644 --- a/apps/web/src/app/api/submit-plugin/route.ts +++ b/apps/web/src/app/api/submit-package/route.ts @@ -13,7 +13,7 @@ import { uploadImageToStrapi, } from "@/features/submit/server/strapi"; -const LOG = "submit-plugin"; +const LOG = "submit-package"; export async function POST(req: NextRequest) { let formData: FormData; @@ -32,7 +32,7 @@ export async function POST(req: NextRequest) { ); } try { - const { success } = await verifyRecaptcha(token, "submit_plugin", LOG); + const { success } = await verifyRecaptcha(token, "submit_package", LOG); if (!success) { return NextResponse.json( { error: "reCAPTCHA verification failed. Please try again." }, @@ -48,7 +48,7 @@ export async function POST(req: NextRequest) { } } - const plugin_name = str(formData.get("plugin_name")); + const package_name = str(formData.get("package_name")); const description = str(formData.get("description")); const repository_url = str(formData.get("repository_url")); const owner_name = str(formData.get("owner_name")); @@ -56,7 +56,7 @@ export async function POST(req: NextRequest) { const agreed = formData.get("submitter_agreed_to_terms") === "true"; const errors: string[] = []; - if (!plugin_name) errors.push("Plugin name is required."); + if (!package_name) errors.push("Package name is required."); if (!description) errors.push("Description is required."); if (!repository_url) errors.push("Repository URL is required."); else if (!/^https?:\/\//i.test(repository_url)) @@ -92,12 +92,12 @@ export async function POST(req: NextRequest) { } const payload = { - name: plugin_name, - slug: slugify(plugin_name!), + name: package_name, + slug: slugify(package_name!), description, git_repository: repository_url, package_location: str(formData.get("package_location")), - type: str(formData.get("package_type")) || "plugin", + type: str(formData.get("package_type")) || "package", categories_list: parseCategories(formData.get("categories_list")), owner_name, owner_email, @@ -125,7 +125,7 @@ export async function POST(req: NextRequest) { return NextResponse.json( { error: - "Could not submit your plugin at this time. Please try again later.", + "Could not submit your package at this time. Please try again later.", }, { status: 503 }, ); diff --git a/apps/web/src/app/api/submit-showcase/route.ts b/apps/web/src/app/api/submit-showcase/route.ts new file mode 100644 index 0000000..b6f388b --- /dev/null +++ b/apps/web/src/app/api/submit-showcase/route.ts @@ -0,0 +1,130 @@ +import { type NextRequest, NextResponse } from "next/server"; +import slugify from "slugify"; +import { + isRecaptchaConfigured, + verifyRecaptcha, +} from "@/features/submit/server/recaptcha"; +import { + ALLOWED_IMAGE_TYPES, + MAX_LOGO_SIZE, + parseCategories, + str, + submitToStrapi, + uploadImageToStrapi, +} from "@/features/submit/server/strapi"; + +const LOG = "submit-showcase"; + +export async function POST(req: NextRequest) { + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Invalid form data." }, { status: 400 }); + } + + if (isRecaptchaConfigured()) { + const token = str(formData.get("recaptcha_token")); + if (!token) { + return NextResponse.json( + { error: "Missing reCAPTCHA token." }, + { status: 400 }, + ); + } + try { + const { success } = await verifyRecaptcha(token, "submit_showcase", LOG); + if (!success) { + return NextResponse.json( + { error: "reCAPTCHA verification failed. Please try again." }, + { status: 400 }, + ); + } + } catch (err) { + console.error(`[${LOG}] reCAPTCHA error:`, err); + return NextResponse.json( + { error: "reCAPTCHA service unavailable. Please try again later." }, + { status: 503 }, + ); + } + } + + const showcase_name = str(formData.get("showcase_name")); + const showcase_url = str(formData.get("url")); + const description = str(formData.get("description")); + const owner_name = str(formData.get("owner_name")); + const owner_email = str(formData.get("owner_email")); + const agreed = formData.get("submitter_agreed_to_terms") === "true"; + + const errors: string[] = []; + if (!showcase_name) errors.push("Showcase name is required."); + if (!showcase_url) errors.push("Live URL is required."); + else if (!/^https?:\/\//i.test(showcase_url)) + errors.push("Live URL must be a valid https:// URL."); + if (!description) errors.push("Description is required."); + if (!owner_name) errors.push("Owner name is required."); + if (!owner_email) errors.push("Contact email is required."); + else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(owner_email)) + errors.push("Contact email is not valid."); + if (!agreed) errors.push("You must agree to the terms."); + if (errors.length > 0) return NextResponse.json({ errors }, { status: 422 }); + + let imageDocumentId: string | null = null; + const logoFile = formData.get("logo_file"); + if (logoFile instanceof File && logoFile.size > 0) { + if (!ALLOWED_IMAGE_TYPES.includes(logoFile.type)) { + return NextResponse.json( + { error: "Screenshot must be a PNG, JPEG, SVG, or WebP image." }, + { status: 422 }, + ); + } + if (logoFile.size > MAX_LOGO_SIZE) { + return NextResponse.json( + { error: "Screenshot file must be smaller than 2 MB." }, + { status: 422 }, + ); + } + imageDocumentId = await uploadImageToStrapi(logoFile, LOG); + if (!imageDocumentId) { + console.warn( + `[${LOG}] Image upload failed — submission will proceed without screenshot.`, + ); + } + } + + const payload = { + title: showcase_name, + slug: slugify(showcase_name!), + url: showcase_url, + description, + categories_list: parseCategories(formData.get("categories_list")), + owner_name, + owner_email, + submission_notes: str(formData.get("submission_notes")), + submitter_agreed_to_terms: true, + image: imageDocumentId ? { documentId: imageDocumentId } : null, + }; + + try { + const { submissionId } = await submitToStrapi( + "/api/moderation/showcases/submit", + payload, + LOG, + ); + return NextResponse.json({ success: true, submissionId }, { status: 201 }); + } catch (err) { + if ((err as Error).message === "strapi_error") { + return NextResponse.json( + { error: "Submission failed. Please try again." }, + { status: 500 }, + ); + } + console.error(`[${LOG}] Could not reach Strapi:`, err); + return NextResponse.json( + { + error: + "Could not submit your showcase at this time. Please try again later.", + }, + { status: 503 }, + ); + } +} diff --git a/apps/web/src/app/submit/plugin/SubmitPluginForm.tsx b/apps/web/src/app/submit/package/SubmitPackageForm.tsx similarity index 84% rename from apps/web/src/app/submit/plugin/SubmitPluginForm.tsx rename to apps/web/src/app/submit/package/SubmitPackageForm.tsx index 43f0dc5..8c4b0f3 100644 --- a/apps/web/src/app/submit/plugin/SubmitPluginForm.tsx +++ b/apps/web/src/app/submit/package/SubmitPackageForm.tsx @@ -17,14 +17,15 @@ import { useSubmitForm } from "@/features/submit/hooks/use-submit-form"; import { EMAIL_RE, URL_RE } from "@/features/submit/lib/validation"; import type { BaseFormFields, FieldErrors } from "@/features/submit/types"; -interface PluginFormFields extends BaseFormFields { - plugin_name: string; +interface PackageFormFields extends BaseFormFields { + repository_url: string; + package_name: string; package_location: string; readme: string; } -const INITIAL: PluginFormFields = { - plugin_name: "", +const INITIAL: PackageFormFields = { + package_name: "", package_location: "", repository_url: "", description: "", @@ -37,9 +38,9 @@ const INITIAL: PluginFormFields = { agreed: false, }; -function validate(f: PluginFormFields): FieldErrors { - const e: FieldErrors = {}; - if (!f.plugin_name.trim()) e.plugin_name = "Plugin name is required."; +function validate(f: PackageFormFields): FieldErrors { + const e: FieldErrors = {}; + if (!f.package_name.trim()) e.package_name = "Package name is required."; if (!f.description.trim()) e.description = "Description is required."; if (!f.repository_url.trim()) e.repository_url = "Repository URL is required."; @@ -54,11 +55,11 @@ function validate(f: PluginFormFields): FieldErrors { } function buildFormData( - fields: PluginFormFields, + fields: PackageFormFields, recaptchaToken: string, ): FormData { const form = new FormData(); - form.append("plugin_name", fields.plugin_name.trim()); + form.append("package_name", fields.package_name.trim()); form.append("package_location", fields.package_location.trim()); form.append("repository_url", fields.repository_url.trim()); form.append("description", fields.description.trim()); @@ -85,7 +86,7 @@ const REVIEW_STEPS = [ }, ]; -export function SubmitPluginForm({ +export function SubmitPackageForm({ initialCategories, }: { initialCategories: string[]; @@ -102,17 +103,17 @@ export function SubmitPluginForm({ } = useSubmitForm({ initial: INITIAL, validate, - apiEndpoint: "/api/submit-plugin", - recaptchaAction: "submit_plugin", + apiEndpoint: "/api/submit-package", + recaptchaAction: "submit_package", buildFormData, }); return ( - Share your Strapi plugin with the community. All submissions go + Share your Strapi package with the community. All submissions go through a business and security review before being listed in the marketplace. We’ll reach out if we need more information.{" "} } reviewSteps={REVIEW_STEPS} - contentType="plugin" + contentType="package" success={success} submitting={submitting} - submitLabel="Submit Plugin" + submitLabel="Submit Package" formError={errors._form} onSubmit={handleSubmit} >
-
@@ -152,7 +153,7 @@ export function SubmitPluginForm({ id="package_location" value={fields.package_location} onChange={(e) => set("package_location", e.target.value)} - placeholder="https://www.npmjs.com/package/your-plugin" + placeholder="https://www.npmjs.com/package/your-package" autoComplete="off" /> @@ -179,13 +180,13 @@ export function SubmitPluginForm({