postnhost 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +11 -0
  4. data/app/assets/builds/postnhost/application.css +1 -1
  5. data/app/controllers/postnhost/api/base_controller.rb +29 -0
  6. data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
  7. data/app/controllers/postnhost/api/v1/mcp_controller.rb +49 -0
  8. data/app/controllers/postnhost/api_tokens_controller.rb +34 -0
  9. data/app/controllers/postnhost/articles/translations_controller.rb +2 -9
  10. data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
  11. data/app/controllers/postnhost/management_preview_controller.rb +17 -0
  12. data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
  13. data/app/models/postnhost/api_token.rb +26 -0
  14. data/app/models/postnhost/user.rb +2 -0
  15. data/app/services/postnhost/api/v1/openapi.rb +27 -0
  16. data/app/services/postnhost/management/catalog.rb +151 -0
  17. data/app/services/postnhost/management/command.rb +174 -0
  18. data/app/services/postnhost/management/content_actions.rb +75 -0
  19. data/app/services/postnhost/management/input.rb +49 -0
  20. data/app/services/postnhost/management/mcp.rb +54 -0
  21. data/app/services/postnhost/management/preview.rb +25 -0
  22. data/app/services/postnhost/management/resources.rb +30 -0
  23. data/app/services/postnhost/management/schema_fields.rb +25 -0
  24. data/app/services/postnhost/management/serializer.rb +40 -0
  25. data/app/services/postnhost/management/settings_actions.rb +64 -0
  26. data/app/services/postnhost/management/upload.rb +18 -0
  27. data/app/services/postnhost/translation/enqueue.rb +59 -0
  28. data/app/views/layouts/postnhost/_sidebar.html.erb +7 -0
  29. data/app/views/postnhost/api_tokens/index.html.erb +92 -0
  30. data/config/routes.rb +12 -0
  31. data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
  32. data/lib/postnhost/version.rb +1 -1
  33. metadata +22 -1
@@ -0,0 +1,75 @@
1
+ module Postnhost
2
+ module Management
3
+ class ContentActions < BaseService
4
+ def initialize(resource:, action:, scope:, arguments:, record: nil)
5
+ @resource = resource
6
+ @action = action
7
+ @scope = scope
8
+ @arguments = arguments
9
+ @record = record
10
+ end
11
+
12
+ def call
13
+ case action
14
+ when "publish", "unpublish" then transition
15
+ when "bulk_publish", "bulk_unpublish" then bulk_transition
16
+ when "bulk_delete" then bulk_delete
17
+ when "translate" then Translation::Enqueue.call(record:, language_ids: arguments.fetch("language_ids"))
18
+ when "versions" then versions
19
+ when "version_preview" then record.versions.find(arguments.fetch("version_id")).reify
20
+ when "restore" then Publishing::Versions::RestoreDraft.call(record:, version: record.versions.find(arguments.fetch("version_id")))
21
+ when "preview" then { draft: record, html: record.content }
22
+ when "remove_cover"
23
+ record.update_column(:cover_image, nil)
24
+ record.reload
25
+ else
26
+ SettingsActions.call(action:, record:, arguments:)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :resource, :action, :scope, :arguments, :record
33
+
34
+ def versions
35
+ columns = record.versions.klass.column_names - ["object"]
36
+ record.versions.select(*columns).order(id: :desc).map(&:attributes)
37
+ end
38
+
39
+ def transition
40
+ return failure("Translation is still generating", status: :conflict) if record.respond_to?(:generating?) && record.generating?
41
+
42
+ service = publishing_module.const_get(action.classify, false)
43
+ result = service.call(resource.singularize.to_sym => record)
44
+ result.success? ? success(record.reload, status: result.status) : result
45
+ end
46
+
47
+ def bulk_transition
48
+ parent_resource = Resources::DEFINITIONS.fetch(resource).fetch(:parent)
49
+ parent = Resources.model(parent_resource).find(arguments.fetch("parent_id"))
50
+ service = publishing_module.const_get("#{action.delete_prefix('bulk_').classify}Many", false)
51
+ service.call(parent_resource.singularize.to_sym => parent, ids: arguments.fetch("ids"))
52
+ end
53
+
54
+ def publishing_module
55
+ Publishing.const_get(resource.camelize, false)
56
+ end
57
+
58
+ def bulk_delete
59
+ records = scope.where(id: arguments.fetch("ids")).order(:id)
60
+ failures = (arguments.fetch("ids") - records.pluck(:id)).index_with { ["Translation not found"] }
61
+ deleted = []
62
+ records.each do |variant|
63
+ if variant.generating?
64
+ failures[variant.id] = ["Translation is still generating"]
65
+ elsif variant.destroy
66
+ deleted << variant.id
67
+ else
68
+ failures[variant.id] = variant.errors.full_messages
69
+ end
70
+ end
71
+ success({ deleted:, failures: }, status: failures.empty? ? :ok : :unprocessable_content)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,49 @@
1
+ module Postnhost
2
+ module Management
3
+ module Input
4
+ TYPES = {
5
+ "object" => [Hash],
6
+ "array" => [Array],
7
+ "integer" => [Integer],
8
+ "string" => [String],
9
+ "boolean" => [TrueClass, FalseClass],
10
+ "null" => [NilClass]
11
+ }.freeze
12
+
13
+ def self.validate!(value, schema, path = "arguments")
14
+ valid = Array(schema[:type]).any? do |type|
15
+ TYPES.fetch(type).any? { |klass| value.is_a?(klass) }
16
+ end
17
+ raise ArgumentError, "#{path} has an invalid type" unless valid
18
+
19
+ Time.iso8601(value) if schema[:format] == "date-time" && value
20
+
21
+ case value
22
+ when Hash
23
+ validate_object!(value, schema, path)
24
+ when Array
25
+ raise ArgumentError, "#{path} has an invalid length" if value.size < schema.fetch(:minItems, 0) || value.size > schema.fetch(:maxItems, 1000)
26
+ raise ArgumentError, "#{path} must contain unique values" if schema[:uniqueItems] && value.uniq != value
27
+
28
+ value.each { |item| validate!(item, schema[:items], path) } if schema[:items]
29
+ when Integer
30
+ raise ArgumentError, "#{path} is out of range" if value < schema.fetch(:minimum, value) || value > schema.fetch(:maximum, value)
31
+ end
32
+ end
33
+
34
+ def self.validate_object!(value, schema, path)
35
+ properties = schema.fetch(:properties, {}).stringify_keys
36
+ missing = schema.fetch(:required, []) - value.keys
37
+ raise ArgumentError, "#{path} requires #{missing.join(', ')}" if missing.any?
38
+
39
+ unknown = value.keys - properties.keys
40
+ raise ArgumentError, "#{path} contains unknown fields: #{unknown.join(', ')}" if schema[:additionalProperties] == false && unknown.any?
41
+
42
+ value.each do |key, item|
43
+ child = properties[key] || schema[:additionalProperties]
44
+ validate!(item, child, "#{path}.#{key}") if child.is_a?(Hash)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,54 @@
1
+ module Postnhost
2
+ module Management
3
+ class Mcp
4
+ VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26].freeze
5
+
6
+ def initialize(user:, script_name: "")
7
+ @user = user
8
+ @script_name = script_name
9
+ end
10
+
11
+ def call(message)
12
+ return error(nil, -32_600, "Invalid Request") unless message.is_a?(Hash)
13
+
14
+ id = message["id"]
15
+ return error(nil, -32_600, "Invalid Request") unless message["jsonrpc"] == "2.0" && message["method"].is_a?(String) && (!message.key?("id") || id.is_a?(String) || id.is_a?(Integer))
16
+ return nil unless message.key?("id")
17
+
18
+ params = message.fetch("params", {})
19
+ return error(id, -32_602, "params must be an object") unless params.is_a?(Hash)
20
+
21
+ result = case message["method"]
22
+ when "initialize"
23
+ { protocolVersion: VERSIONS.include?(params["protocolVersion"]) ? params["protocolVersion"] : VERSIONS.first,
24
+ capabilities: { tools: { listChanged: false } }, serverInfo: { name: "PostnHost", version: Postnhost::VERSION },
25
+ instructions: "Manage CMS drafts, translations and settings. Publishing is explicit. Generate translations from published sources. Files use base64 and follow the same upload limits as the CMS." }
26
+ when "ping" then {}
27
+ when "tools/list" then { tools: Catalog.tools }
28
+ when "tools/call" then call_tool(params)
29
+ else return error(id, -32_601, "Method not found")
30
+ end
31
+ { jsonrpc: "2.0", id:, result: }
32
+ rescue ArgumentError => e
33
+ error(id, -32_602, e.message)
34
+ end
35
+
36
+ private
37
+
38
+ def call_tool(params)
39
+ name = params.fetch("name", "")
40
+ Catalog.fetch(name)
41
+ arguments = params.fetch("arguments", {})
42
+ raise ArgumentError, "arguments must be an object" unless arguments.is_a?(Hash)
43
+
44
+ result = Command.call(name:, arguments:, user: @user, script_name: @script_name)
45
+ data = { data: result.value, errors: result.errors, status: Rack::Utils.status_code(result.status) }
46
+ { content: [{ type: "text", text: data.to_json }], structuredContent: data, isError: !result.success? || data[:status] >= 400 }
47
+ end
48
+
49
+ def error(id, code, message)
50
+ { jsonrpc: "2.0", id:, error: { code:, message: } }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,25 @@
1
+ module Postnhost
2
+ module Management
3
+ module Preview
4
+ def self.call(user:, record: nil, template: nil, script_name: "")
5
+ action = record ? "preview" : "index"
6
+ path = record ? "/preview/#{record.id}" : "/?preview_template=#{template}"
7
+ origin = Setting.current.effective_site_url || "http://example.com"
8
+ env = Rack::MockRequest.env_for("#{origin}#{script_name}#{path}")
9
+ env["SCRIPT_NAME"] = script_name
10
+ env["PATH_INFO"] = path.split("?").first
11
+ env["postnhost.management_user"] = user
12
+ env["action_dispatch.request.path_parameters"] = { controller: "postnhost/management_preview", action:, id: record&.id }
13
+ env["action_dispatch.routes"] = Engine.routes
14
+ status, _headers, body = I18n.with_locale(I18n.default_locale) { ManagementPreviewController.action(action).call(env) }
15
+ html = +""
16
+ body.each { |chunk| html << chunk }
17
+ raise ArgumentError, "Preview could not be rendered" unless status == 200
18
+
19
+ { html:, template: template || Template.active_name }
20
+ ensure
21
+ body&.close if body.respond_to?(:close)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ module Postnhost
2
+ module Management
3
+ module Resources
4
+ TEXT = %w[title title_tag og_title meta_description content language_id].freeze
5
+ EXCERPT = %w[custom_excerpt use_excerpt_as_meta_description].freeze
6
+ ARTICLE = (TEXT + EXCERPT + %w[schema_headline schema_article_type slug cover_image_alt scheduled_at top_pick author_ids category_ids suggested_article_ids]).freeze
7
+ PAGE = (TEXT + %w[slug]).freeze
8
+ USER = %w[email name slug position bio website_url x_url linkedin_url threads_url tiktok_url youtube_url facebook_url instagram_url bluesky_url mastodon_url password password_confirmation].freeze
9
+ SETTING = %w[site_url site_indexing public_page_size timezone author_pages_enabled search_enabled show_powered_by use_auto_header_navigation use_auto_footer_navigation schema_settings schema_locale_overrides].freeze
10
+ DEFINITIONS = {
11
+ "api_tokens" => { model: "ApiToken", fields: %w[name] },
12
+ "articles" => { model: "Article", fields: ARTICLE, snapshot: :article_snapshot },
13
+ "pages" => { model: "Page", fields: PAGE, snapshot: :page_snapshot },
14
+ "categories" => { model: "Category", fields: %w[name slug meta_description] },
15
+ "languages" => { model: "Language", fields: %w[name html_lang default] },
16
+ "users" => { model: "User", fields: USER + %w[schema_profile schema_locale_overrides] },
17
+ "article_variants" => { model: "ArticleVariant", fields: TEXT + EXCERPT + %w[schema_headline], parent: "articles", snapshot: :article_variant_snapshot },
18
+ "page_variants" => { model: "PageVariant", fields: TEXT, parent: "pages", snapshot: :page_variant_snapshot },
19
+ "category_variants" => { model: "CategoryVariant", fields: %w[name meta_description language_id], parent: "categories" },
20
+ "settings" => { model: "Setting", fields: SETTING, singleton: true },
21
+ "template" => { model: "Template", fields: %w[name], singleton: true },
22
+ "site_scripts" => { model: "SiteScript", fields: %w[placement script] }
23
+ }.freeze
24
+
25
+ def self.model(resource)
26
+ Postnhost.const_get(DEFINITIONS.fetch(resource).fetch(:model), false)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,25 @@
1
+ module Postnhost
2
+ module Management
3
+ module SchemaFields
4
+ TEXT = { type: %w[string null] }.freeze
5
+ STRINGS = { type: "array", items: { type: "string" }, maxItems: 100 }.freeze
6
+
7
+ def self.settings
8
+ fields = Postnhost::Settings::SchemaManageable::SCHEMA_SETTING_FIELDS.index_with { TEXT }
9
+ fields["same_as"] = STRINGS
10
+ fields["policies"] = { type: "object", properties: Postnhost::SchemaHelper::POLICY_FIELDS.keys.index_with { TEXT }, additionalProperties: false }
11
+ { type: "object", properties: fields, additionalProperties: false }
12
+ end
13
+
14
+ def self.profile
15
+ fields = Postnhost::Users::SchemaManageable::USER_SCHEMA_PROFILE_FIELDS.index_with { |field| %w[same_as knows_language].include?(field) ? STRINGS : TEXT }
16
+ { type: "object", properties: fields, additionalProperties: false }
17
+ end
18
+
19
+ def self.overrides(resource)
20
+ fields = resource == "users" ? Postnhost::Users::SchemaManageable::USER_SCHEMA_TEXT_FIELDS : Postnhost::SchemaHelper::SETTINGS_TEXT_OVERRIDE_FIELDS
21
+ { type: "object", additionalProperties: { type: "object", properties: fields.index_with { TEXT }, additionalProperties: false } }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ module Postnhost
2
+ module Management
3
+ module Serializer
4
+ SUMMARY_FIELDS = %w[
5
+ id user_id article_id page_id category_id language_id name title slug email position
6
+ html_lang default created_at updated_at scheduled_at publication_error top_pick generating
7
+ articles_count article_variants_count page_variants_count placement revoked_at
8
+ cover_image cover_image_alt avatar_file
9
+ ].freeze
10
+
11
+ def self.summary(record)
12
+ data = record.attributes.slice(*SUMMARY_FIELDS)
13
+ data["published"] = record["management_snapshot_id"].present? if record.has_attribute?("management_snapshot_id")
14
+ %w[cover_image avatar_file].each do |field|
15
+ data["#{field}_url"] = record.public_send(field).url if record.has_attribute?(field)
16
+ end
17
+ data
18
+ end
19
+
20
+ def self.call(value)
21
+ case value
22
+ when ActiveRecord::Base
23
+ data = value.attributes.except("password_digest", "token_digest", "scheduled_job_id")
24
+ if value.respond_to?(:published?)
25
+ data["published"] = value.published?
26
+ data["unpublished_changes"] = value.unpublished_changes?
27
+ end
28
+ data.merge!("author_ids" => value.author_ids, "category_ids" => value.category_ids, "suggested_article_ids" => value.suggested_article_ids) if value.is_a?(Article)
29
+ %w[cover_image avatar_file site_logo og_image].each do |field|
30
+ data["#{field}_url"] = value.public_send(field).url if value.respond_to?(field)
31
+ end
32
+ data
33
+ when Hash then value.transform_values { |item| call(item) }
34
+ when Array then value.map { |item| call(item) }
35
+ else value
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ module Postnhost
2
+ module Management
3
+ class SettingsActions < BaseService
4
+ include Postnhost::Settings::NavigationManageable
5
+
6
+ def initialize(action:, record:, arguments:)
7
+ @action = action
8
+ @record = record
9
+ @arguments = arguments
10
+ end
11
+
12
+ def call
13
+ return upload if action.start_with?("upload_")
14
+
15
+ if %w[remove_logo remove_og_image].include?(action)
16
+ field = action == "remove_logo" ? :site_logo : :og_image
17
+ record.update!(field => nil)
18
+ return record
19
+ end
20
+
21
+ validate_locale!
22
+ case action
23
+ when "get_navigation"
24
+ @setting = record
25
+ @selected_navigation_locale_key = arguments.fetch("locale")
26
+ @default_navigation_locale_key = Language.blog_default&.html_lang || I18n.default_locale.to_s
27
+ @navigation = record.current_navigation
28
+ { tree: navigation_tree_for_editor, targets: navigation_target_options }
29
+ when "replace_navigation"
30
+ tree = arguments.fetch("tree")
31
+ record.current_navigation.replace_tree!(tree:, locale_key: arguments.fetch("locale"))
32
+ { navigation: record.current_navigation.navigation_items.to_a }
33
+ when "get_translations"
34
+ Settings::I18nOverrides.editable_translations_for_locale(arguments.fetch("locale"), record.locale_overrides)
35
+ when "update_translations"
36
+ overrides = Settings::I18nOverrides.merge_locale_overrides(record.locale_overrides, arguments.fetch("locale"), arguments.fetch("translations"))
37
+ record.update!(locale_overrides: overrides)
38
+ record
39
+ else
40
+ raise ArgumentError, "Unsupported action"
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :action, :record, :arguments
47
+
48
+ def validate_locale!
49
+ raise ArgumentError, "Locale must belong to a configured language" unless Language.exists?(html_lang: arguments["locale"])
50
+ end
51
+
52
+ def upload
53
+ file = Upload.open(arguments.fetch("file"))
54
+ if action == "upload_image"
55
+ result = record.upload_inline_image(file)
56
+ return result[:success] ? success({ url: result[:url] }) : failure(result[:error])
57
+ end
58
+ field = { "upload_cover" => :cover_image, "upload_avatar" => :avatar_file, "upload_logo" => :site_logo, "upload_og_image" => :og_image }.fetch(action)
59
+ record.update!(field => file)
60
+ { url: record.public_send(field).url }
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,18 @@
1
+ require "base64"
2
+ require "stringio"
3
+
4
+ module Postnhost
5
+ module Management
6
+ module Upload
7
+ def self.open(payload)
8
+ filename = payload.fetch("filename")
9
+ raise ArgumentError, "Invalid filename" if filename.blank? || filename != File.basename(filename) || filename.include?("\\")
10
+
11
+ file = StringIO.new(Base64.strict_decode64(payload.fetch("base64")))
12
+ file.define_singleton_method(:original_filename) { filename }
13
+ file.define_singleton_method(:content_type) { payload.fetch("content_type") }
14
+ file
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,59 @@
1
+ module Postnhost
2
+ module Translation
3
+ class Enqueue < BaseService
4
+ def initialize(record:, language_ids:)
5
+ @record = record
6
+ @language_ids = language_ids
7
+ end
8
+
9
+ def call
10
+ return success({ variants: [], job_ids: [] }) if language_ids.empty?
11
+
12
+ kind = record.class.model_name.element
13
+ return failure("Publish the source before generating translations") if record.respond_to?(:published?) && !record.published?
14
+
15
+ languages = language_ids.map { |id| Language.find(id) }
16
+ primary_id = record.respond_to?(:language_id) ? record.language_id : Language.blog_default&.id
17
+ return failure("Cannot translate into the source language") if languages.any? { |language| language.id == primary_id }
18
+
19
+ variants = record.class.transaction do
20
+ languages.map { |language| record.public_send("#{kind}_variants").create!(language:, generating: true) }
21
+ end
22
+ enqueue_variants(variants, Translation.const_get("#{kind.camelize}VariantJob", false))
23
+ rescue ActiveRecord::RecordInvalid => e
24
+ failure(e.record.errors.full_messages)
25
+ rescue ActiveRecord::RecordNotFound
26
+ failure("Language not found", status: :not_found)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :record, :language_ids
32
+
33
+ def enqueue_variants(variants, job_class)
34
+ jobs = []
35
+ variants.each do |variant|
36
+ job = enqueue(job_class, variant)
37
+ unless job
38
+ unqueued = variants.drop(jobs.size)
39
+ unqueued.each(&:destroy!)
40
+ return ServiceResult.new(
41
+ value: { variants: variants.take(jobs.size), job_ids: jobs.map(&:job_id), failed_language_ids: unqueued.map(&:language_id) },
42
+ errors: ["Could not queue all translations. Retry the failed languages."], status: :service_unavailable
43
+ )
44
+ end
45
+ jobs << job
46
+ end
47
+ success({ variants:, job_ids: jobs.map(&:job_id) }, status: :accepted)
48
+ end
49
+
50
+ def enqueue(job_class, variant)
51
+ job = job_class.new(record.id, variant.language_id)
52
+ job_class.perform_later(job)
53
+ rescue StandardError => e
54
+ Rails.logger.error("Postnhost translation enqueue failed (#{e.class})")
55
+ job if job&.successfully_enqueued?
56
+ end
57
+ end
58
+ end
59
+ end
@@ -58,6 +58,13 @@
58
58
  <span class="text-sm font-medium">Settings</span>
59
59
  <% end %>
60
60
 
61
+ <%= link_to api_tokens_path, class: "group flex items-center space-x-3 text-gray-700 hover:text-gray-900 hover:bg-white font-medium px-3 py-2.5 rounded-xl transition-all duration-200 #{controller_name == 'api_tokens' ? 'bg-white text-gray-900' : ''}" do %>
62
+ <div class="flex-shrink-0 <%= controller_name == 'api_tokens' ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-700' %>">
63
+ <%= icon("code-bracket", css_class: "h-5 w-5") %>
64
+ </div>
65
+ <span class="text-sm font-medium">API &amp; MCP</span>
66
+ <% end %>
67
+
61
68
  <%= link_to edit_template_path, class: "group flex items-center space-x-3 text-gray-700 hover:text-gray-900 hover:bg-white font-medium px-3 py-2.5 rounded-xl transition-all duration-200 #{current_page?(edit_template_path) || controller_name == 'templates' ? 'bg-white text-gray-900' : ''}" do %>
62
69
  <div class="flex-shrink-0 #{current_page?(edit_template_path) || controller_name == 'templates' ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-700'}">
63
70
  <%= icon("eye", css_class: "h-5 w-5") %>
@@ -0,0 +1,92 @@
1
+ <% content_for :title, "API & MCP" %>
2
+ <% content_for :head do %>
3
+ <meta name="turbo-cache-control" content="no-cache">
4
+ <% end %>
5
+
6
+ <div class="space-y-8">
7
+ <%= render "postnhost/shared/elements/page_header", title: "API & MCP access", description: "Connect agents and integrations to your CMS." %>
8
+
9
+ <% if @secret.present? %>
10
+ <section class="rounded-2xl border border-green-200 bg-green-50 p-6 space-y-3" aria-label="New token">
11
+ <div class="flex items-center gap-2 text-green-800">
12
+ <%= icon("check-circle", css_class: "h-5 w-5") %>
13
+ <h2 class="font-medium">Token created</h2>
14
+ </div>
15
+ <p class="text-sm text-green-800">Copy this token now. It is shown only once.</p>
16
+ <code class="block rounded-xl border border-green-200 bg-white p-4 text-sm text-gray-900 break-all select-all"><%= @secret %></code>
17
+ </section>
18
+ <% end %>
19
+
20
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
21
+ <section class="lg:col-span-2 <%= form_card_classes %>" aria-labelledby="create-token-title">
22
+ <div>
23
+ <h2 id="create-token-title" class="text-lg font-medium text-gray-900">Create an access token</h2>
24
+ <p class="mt-2 text-sm text-gray-500">Tokens grant your CMS permissions. Give each integration its own token so you can revoke access separately.</p>
25
+ </div>
26
+ <%= form_with scope: :api_token, url: api_tokens_path, data: { turbo: false }, class: "space-y-6" do |form| %>
27
+ <div>
28
+ <%= form.label :name, class: form_label_classes %>
29
+ <%= form.text_field :name, required: true, placeholder: "e.g. Writing agent", class: form_input_classes %>
30
+ </div>
31
+ <%= form.submit "Create token", class: button_classes %>
32
+ <% end %>
33
+ </section>
34
+
35
+ <section class="<%= form_card_classes %>" aria-labelledby="connection-title">
36
+ <div>
37
+ <h2 id="connection-title" class="text-lg font-medium text-gray-900">Connection details</h2>
38
+ <p class="mt-2 text-sm text-gray-500">Use your token to authenticate either connection.</p>
39
+ </div>
40
+ <dl class="space-y-5">
41
+ <div>
42
+ <dt class="text-sm font-medium text-gray-700">Management API · v1</dt>
43
+ <dd class="mt-2 rounded-xl bg-gray-50 p-3 text-xs text-gray-900 break-all"><code><%= api_v1_commands_url %></code></dd>
44
+ </div>
45
+ <div>
46
+ <dt class="text-sm font-medium text-gray-700">MCP · v1</dt>
47
+ <dd class="mt-2 rounded-xl bg-gray-50 p-3 text-xs text-gray-900 break-all"><code><%= api_v1_mcp_url %></code></dd>
48
+ <dd class="mt-2 text-xs text-gray-500">Connect using Streamable HTTP.</dd>
49
+ </div>
50
+ <div>
51
+ <dt class="text-sm font-medium text-gray-700">Authorization header</dt>
52
+ <dd class="mt-2 rounded-xl bg-gray-50 p-3 text-xs text-gray-900 break-all"><code>Bearer &lt;your-token&gt;</code></dd>
53
+ </div>
54
+ </dl>
55
+ </section>
56
+ </div>
57
+
58
+ <section class="space-y-4" aria-labelledby="access-tokens-title">
59
+ <div>
60
+ <h2 id="access-tokens-title" class="text-lg font-medium text-gray-900">Access tokens</h2>
61
+ <p class="mt-2 text-sm text-gray-500">Manage the tokens you have created for your account.</p>
62
+ </div>
63
+ <% if @api_tokens.any? %>
64
+ <%= render "postnhost/shared/elements/table_container", headers: [
65
+ { text: "Token", class: "col-span-8" },
66
+ { text: "Actions", class: "col-span-4 text-right" }
67
+ ] do %>
68
+ <% @api_tokens.each do |token| %>
69
+ <div class="<%= table_row_classes %>">
70
+ <div class="grid grid-cols-12 gap-4 items-center">
71
+ <div class="col-span-8 min-w-0 space-y-2">
72
+ <p class="font-medium text-gray-900 break-words"><%= token.name %></p>
73
+ <% if token.revoked_at? %>
74
+ <span class="inline-flex <%= stats_badge_classes(variant: :gray) %>">Revoked</span>
75
+ <% else %>
76
+ <span class="inline-flex <%= stats_badge_classes(variant: :green) %>">Active</span>
77
+ <% end %>
78
+ </div>
79
+ <div class="col-span-4 flex justify-end">
80
+ <% unless token.revoked_at? %>
81
+ <%= button_to "Revoke", api_token_path(token), method: :delete, class: button_classes(variant: :danger) %>
82
+ <% end %>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ <% end %>
87
+ <% end %>
88
+ <% else %>
89
+ <%= render "postnhost/shared/elements/empty_state", icon: "code-bracket", title: "No access tokens", description: "Create a token above to connect your first agent or integration." %>
90
+ <% end %>
91
+ </section>
92
+ </div>
data/config/routes.rb CHANGED
@@ -33,6 +33,7 @@ Postnhost::Engine.routes.draw do
33
33
  end
34
34
 
35
35
  # Admin routes
36
+ resources :api_tokens, only: %i[index create destroy]
36
37
  resource :onboarding, only: %i[show update], controller: "onboarding"
37
38
  resource :session, only: %i[new create destroy], controller: "sessions"
38
39
  resources :users, only: %i[index new create edit update destroy] do
@@ -106,6 +107,17 @@ Postnhost::Engine.routes.draw do
106
107
  resources :site_scripts, only: %i[create update destroy], controller: "settings/site_scripts"
107
108
  end
108
109
 
110
+ # Management API and MCP
111
+ namespace :api do
112
+ namespace :v1 do
113
+ get "openapi", to: "commands#schema", as: :openapi
114
+ get "commands", to: "commands#index", as: :commands
115
+ post "commands/:command", to: "commands#create", as: :command
116
+ post "mcp", to: "mcp#create", as: :mcp
117
+ match "mcp", to: "mcp#unsupported", via: %i[get delete]
118
+ end
119
+ end
120
+
109
121
  # Public static pages from host app views:
110
122
  # app/views/postnhost/static_pages/*.html.erb
111
123
  get "/:slug", to: "public/static_pages#show", as: :public_static_page
@@ -0,0 +1,12 @@
1
+ class CreateApiTokens < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :postnhost_api_tokens do |t|
4
+ t.references :user, null: false, foreign_key: { to_table: :postnhost_users }
5
+ t.string :name, null: false
6
+ t.string :token_digest, null: false
7
+ t.datetime :revoked_at
8
+ t.timestamps
9
+ end
10
+ add_index :postnhost_api_tokens, :token_digest, unique: true
11
+ end
12
+ end
@@ -1,3 +1,3 @@
1
1
  module Postnhost
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  end