postnhost 0.1.1 → 0.2.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +27 -0
  4. data/app/assets/builds/postnhost/application.css +1 -1
  5. data/app/controllers/postnhost/api/base_controller.rb +34 -0
  6. data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
  7. data/app/controllers/postnhost/api/v1/mcp_controller.rb +53 -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/articles_controller.rb +5 -6
  11. data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
  12. data/app/controllers/postnhost/management_preview_controller.rb +17 -0
  13. data/app/controllers/postnhost/oauth/authorizations_controller.rb +50 -0
  14. data/app/controllers/postnhost/oauth/metadata_controller.rb +25 -0
  15. data/app/controllers/postnhost/oauth/public_controller.rb +16 -0
  16. data/app/controllers/postnhost/oauth/registrations_controller.rb +29 -0
  17. data/app/controllers/postnhost/oauth/tokens_controller.rb +19 -0
  18. data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
  19. data/app/controllers/postnhost/sessions_controller.rb +2 -1
  20. data/app/models/postnhost/api_token.rb +26 -0
  21. data/app/models/postnhost/oauth_client.rb +29 -0
  22. data/app/models/postnhost/oauth_grant.rb +32 -0
  23. data/app/models/postnhost/user.rb +3 -0
  24. data/app/services/postnhost/api/v1/openapi.rb +27 -0
  25. data/app/services/postnhost/management/catalog.rb +151 -0
  26. data/app/services/postnhost/management/command.rb +174 -0
  27. data/app/services/postnhost/management/content_actions.rb +75 -0
  28. data/app/services/postnhost/management/input.rb +49 -0
  29. data/app/services/postnhost/management/mcp.rb +54 -0
  30. data/app/services/postnhost/management/preview.rb +25 -0
  31. data/app/services/postnhost/management/resources.rb +30 -0
  32. data/app/services/postnhost/management/schema_fields.rb +25 -0
  33. data/app/services/postnhost/management/serializer.rb +40 -0
  34. data/app/services/postnhost/management/settings_actions.rb +64 -0
  35. data/app/services/postnhost/management/upload.rb +18 -0
  36. data/app/services/postnhost/translation/enqueue.rb +59 -0
  37. data/app/views/layouts/postnhost/_sidebar.html.erb +7 -0
  38. data/app/views/postnhost/api_tokens/index.html.erb +92 -0
  39. data/app/views/postnhost/oauth/authorizations/new.html.erb +18 -0
  40. data/config/routes.rb +21 -0
  41. data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
  42. data/db/migrate/20261025120000_add_mcp_oauth.rb +23 -0
  43. data/lib/postnhost/engine.rb +7 -0
  44. data/lib/postnhost/oauth_discovery.rb +22 -0
  45. data/lib/postnhost/version.rb +1 -1
  46. metadata +32 -1
@@ -0,0 +1,151 @@
1
+ module Postnhost
2
+ module Management
3
+ module Catalog
4
+ INTEGER = { type: "integer", minimum: 1 }.freeze
5
+ IDS = { type: "array", items: INTEGER, minItems: 1, maxItems: 100, uniqueItems: true }.freeze
6
+ FILE = { type: "object", properties: { filename: { type: "string" }, content_type: { type: "string" }, base64: { type: "string" } }, required: %w[filename content_type base64], additionalProperties: false }.freeze
7
+ READ_ACTIONS = %w[list get versions version_preview preview get_navigation get_translations].freeze
8
+
9
+ def self.tools
10
+ @tools ||= freeze_schema(build_tools)
11
+ end
12
+
13
+ def self.build_tools
14
+ Resources::DEFINITIONS.flat_map do |resource, definition|
15
+ actions = definition[:singleton] ? %w[get update] : %w[list get create update delete]
16
+ actions.delete("update") if resource == "api_tokens"
17
+ actions += %w[publish unpublish] if %w[articles pages article_variants page_variants].include?(resource)
18
+ actions += %w[translate] if %w[articles pages categories].include?(resource)
19
+ actions += %w[bulk_publish bulk_unpublish bulk_delete] if %w[article_variants page_variants].include?(resource)
20
+ actions += %w[versions version_preview restore preview upload_image upload_cover remove_cover] if resource == "articles"
21
+ actions += %w[preview upload_image] if resource == "pages"
22
+ actions += %w[get_navigation replace_navigation get_translations update_translations upload_logo upload_og_image remove_logo remove_og_image] if resource == "settings"
23
+ actions += %w[preview] if resource == "template"
24
+ actions += %w[upload_avatar] if resource == "users"
25
+ actions.map { |action| tool(resource, action, definition) }
26
+ end
27
+ end
28
+
29
+ def self.fetch(name)
30
+ @tools_by_name ||= tools.index_by { |tool| tool[:name] }.freeze
31
+ @tools_by_name.fetch(name) { raise ArgumentError, "Unknown command: #{name}" }
32
+ end
33
+
34
+ def self.tool(resource, action, definition)
35
+ read = READ_ACTIONS.include?(action)
36
+ detail = if action == "list"
37
+ "Returns compact summaries; use get for full content, relationships and draft comparisons."
38
+ elsif read
39
+ "Reads CMS draft data."
40
+ else
41
+ "Uses CMS validations and publication workflows."
42
+ end
43
+ { name: "#{resource}_#{action}", description: "#{action.tr('_', ' ').capitalize} #{resource.tr('_', ' ')}. #{detail}",
44
+ inputSchema: schema(resource, action, definition),
45
+ annotations: { readOnlyHint: read, destructiveHint: !read, idempotentHint: read || %w[update unpublish delete].include?(action) || action.start_with?("remove_"), openWorldHint: action == "translate" } }
46
+ end
47
+
48
+ def self.schema(resource, action, definition)
49
+ properties = {}
50
+ required = []
51
+ if definition[:parent]
52
+ properties["parent_id"] = INTEGER
53
+ required << "parent_id"
54
+ end
55
+ unless definition[:singleton] || %w[list create translate].include?(action) || action.start_with?("bulk_")
56
+ properties["id"] = INTEGER
57
+ required << "id"
58
+ end
59
+ case action
60
+ when "list"
61
+ properties["page"] = INTEGER
62
+ properties["limit"] = { type: "integer", minimum: 1, maximum: 100 }
63
+ properties["language_id"] = INTEGER if definition[:fields].include?("language_id")
64
+ properties.merge!("status" => { type: "string", enum: Article::STATUSES }, "sort" => { type: "string", enum: Article::SORT_OPTIONS.keys }, "mine" => { type: "boolean" }, "category_id" => INTEGER, "user_id" => INTEGER) if resource == "articles"
65
+ when "preview"
66
+ properties["name"] = { type: "string", enum: Template::NAMES } if resource == "template"
67
+ required << "name" if resource == "template"
68
+ when "create", "update"
69
+ properties["attributes"] = { type: "object", properties: definition[:fields].index_with { |field| field_schema(field, resource) }, additionalProperties: false }
70
+ required << "attributes"
71
+ when "translate"
72
+ properties["id"] = INTEGER
73
+ properties["language_ids"] = IDS
74
+ required += %w[id language_ids]
75
+ when "bulk_publish", "bulk_unpublish", "bulk_delete"
76
+ properties["ids"] = IDS
77
+ required << "ids"
78
+ when "restore", "version_preview"
79
+ properties["version_id"] = INTEGER
80
+ required << "version_id"
81
+ when "replace_navigation"
82
+ properties["tree"] = { type: "object", properties: { header: { type: "array", items: navigation_item_schema, maxItems: 100 }, footer: { type: "array", items: navigation_item_schema, maxItems: 100 } }, required: %w[header footer], additionalProperties: false }
83
+ required << "tree"
84
+ properties["locale"] = { type: "string" }
85
+ required << "locale"
86
+ when "get_navigation", "get_translations", "update_translations"
87
+ properties["locale"] = { type: "string" }
88
+ required << "locale"
89
+ if action == "update_translations"
90
+ properties["translations"] = { type: "object", additionalProperties: { type: "string" } }
91
+ required << "translations"
92
+ end
93
+ else
94
+ if action.start_with?("upload_")
95
+ properties["file"] = FILE
96
+ required << "file"
97
+ end
98
+ end
99
+ { type: "object", properties:, required:, additionalProperties: false }
100
+ end
101
+
102
+ def self.navigation_item_schema(depth = 0)
103
+ properties = {
104
+ id: INTEGER, kind: { type: "string", enum: NavigationItem.kinds.keys },
105
+ target_kind: { type: %w[string null], enum: NavigationItem.target_kinds.keys + [nil] },
106
+ target_id: { type: %w[integer null] }, target_slug: { type: %w[string null] },
107
+ url: { type: %w[string null] }, label: { type: %w[string null] }, nofollow: { type: "boolean" }
108
+ }
109
+ properties[:children] = { type: "array", items: navigation_item_schema(depth + 1), maxItems: 100 } if depth < 2
110
+ { type: "object", properties:, required: ["kind"], additionalProperties: false }
111
+ end
112
+
113
+ # This module is reloadable: schemas and their name index disappear with it.
114
+ # Freeze nested values so one consumer cannot modify another client's contract.
115
+ def self.freeze_schema(value)
116
+ case value
117
+ when Hash then value.each do |key, child|
118
+ freeze_schema(key)
119
+ freeze_schema(child)
120
+ end
121
+ when Array then value.each { |child| freeze_schema(child) }
122
+ end
123
+ value.freeze
124
+ end
125
+ private_class_method :build_tools, :freeze_schema
126
+
127
+ def self.field_schema(field, resource)
128
+ return { type: "string", enum: Template::NAMES } if resource == "template" && field == "name"
129
+ return { type: "string", enum: SiteScript::PLACEMENTS } if field == "placement"
130
+
131
+ return { type: %w[string null], format: "date-time" } if field == "scheduled_at"
132
+
133
+ if field.end_with?("_ids")
134
+ { type: "array", items: INTEGER, maxItems: 100, uniqueItems: true }
135
+ elsif field.end_with?("_id") || field == "public_page_size"
136
+ { type: %w[integer null] }
137
+ elsif %w[default top_pick use_excerpt_as_meta_description author_pages_enabled search_enabled show_powered_by use_auto_header_navigation use_auto_footer_navigation].include?(field)
138
+ { type: "boolean" }
139
+ elsif field == "schema_settings"
140
+ SchemaFields.settings
141
+ elsif field == "schema_profile"
142
+ SchemaFields.profile
143
+ elsif field == "schema_locale_overrides"
144
+ SchemaFields.overrides(resource)
145
+ else
146
+ { type: %w[string null] }
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,174 @@
1
+ module Postnhost
2
+ module Management
3
+ class Command < BaseService
4
+ def initialize(name:, arguments:, user:, script_name: "")
5
+ @name = name
6
+ @arguments = arguments.deep_stringify_keys
7
+ @user = user
8
+ @script_name = script_name
9
+ end
10
+
11
+ def call
12
+ return failure("Authentication required", status: :unauthorized) unless user
13
+
14
+ tool = Catalog.fetch(name)
15
+ Input.validate!(arguments, tool[:inputSchema])
16
+ resource = Resources::DEFINITIONS.keys.find { |key| name.start_with?("#{key}_") }
17
+ action = name.delete_prefix("#{resource}_")
18
+ result = PaperTrail.request(whodunnit: user.id.to_s) do
19
+ Time.use_zone(Setting.current.effective_timezone_name) { execute(resource, action) }
20
+ end
21
+ result.is_a?(ServiceResult) ? serialize_result(result) : success(Serializer.call(result), status: :ok)
22
+ rescue ActiveRecord::RecordNotFound
23
+ failure("Record not found", status: :not_found)
24
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
25
+ failure(e.record.errors.full_messages)
26
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::InvalidForeignKey
27
+ failure("Record conflicts with existing or referenced data", status: :conflict)
28
+ rescue ActiveRecord::StaleObjectError
29
+ failure("Record changed; reload and retry", status: :conflict)
30
+ rescue ArgumentError, TypeError => e
31
+ failure(e.message, status: :bad_request)
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :name, :arguments, :user, :script_name
37
+
38
+ def serialize_result(result)
39
+ ServiceResult.new(value: Serializer.call(result.value), errors: result.errors, status: result.status || :ok)
40
+ end
41
+
42
+ def execute(resource, action)
43
+ definition = Resources::DEFINITIONS.fetch(resource)
44
+ scope = scope_for(resource, definition)
45
+ return list(scope, resource) if action == "list"
46
+ return create(scope, resource, definition) if action == "create"
47
+ return ContentActions.call(resource:, action:, scope:, arguments:) if action.start_with?("bulk_")
48
+
49
+ scope = scope.select(:id) if action == "versions"
50
+ record = definition[:singleton] ? Resources.model(resource).current : scope.find(arguments.fetch("id"))
51
+ case action
52
+ when "get" then record
53
+ when "update" then update(record)
54
+ when "delete" then delete(record)
55
+ when "preview"
56
+ if resource == "template"
57
+ Preview.call(user:, template: arguments.fetch("name"), script_name:)
58
+ elsif resource == "articles"
59
+ Preview.call(user:, record:, script_name:).merge(draft: record)
60
+ else
61
+ ContentActions.call(resource:, action:, scope:, record:, arguments:)
62
+ end
63
+ else ContentActions.call(resource:, action:, scope:, record:, arguments:)
64
+ end
65
+ end
66
+
67
+ def scope_for(resource, definition)
68
+ if definition[:parent]
69
+ parent = Resources.model(definition[:parent]).find(arguments.fetch("parent_id"))
70
+ parent.public_send(resource)
71
+ elsif resource == "api_tokens"
72
+ user.api_tokens
73
+ elsif resource == "site_scripts"
74
+ Setting.current.site_scripts
75
+ else
76
+ Resources.model(resource).all
77
+ end
78
+ end
79
+
80
+ def list(scope, resource)
81
+ scope = scope.where(language_id: arguments["language_id"]) if arguments["language_id"] && scope.klass.column_names.include?("language_id")
82
+ if resource == "articles"
83
+ scope = scope.where(user_id: user.id) if arguments["mine"]
84
+ scope = scope.joins(:article_categories).where(postnhost_article_categories: { category_id: arguments["category_id"] }) if arguments["category_id"]
85
+ scope = scope.where(user_id: arguments["user_id"]) if arguments["user_id"]
86
+ scope = scope.with_status(arguments["status"]).sorted_by(arguments["sort"])
87
+ else
88
+ scope = scope.order(:id)
89
+ end
90
+ limit = arguments.fetch("limit", 25)
91
+ page = arguments.fetch("page", 1)
92
+ records = summary_scope(scope, resource).limit(limit).offset((page - 1) * limit)
93
+ result = { records: records.map { |record| Serializer.summary(record) }, total: scope.count, page:, limit: }
94
+ result[:static_pages] = static_page_slugs if resource == "pages"
95
+ result
96
+ end
97
+
98
+ def summary_scope(scope, resource)
99
+ table = scope.klass.arel_table
100
+ fields = Serializer::SUMMARY_FIELDS & scope.klass.column_names
101
+ records = scope.select(*fields.map { |field| table[field] })
102
+ snapshot = Resources::DEFINITIONS.fetch(resource)[:snapshot]
103
+ return records unless snapshot
104
+
105
+ association = scope.klass.reflect_on_association(snapshot)
106
+ snapshot_table = association.klass.arel_table
107
+ snapshot_id = association.klass.where(snapshot_table[association.foreign_key].eq(table[:id])).select(:id).limit(1)
108
+ records.select(snapshot_id.arel.as("management_snapshot_id"))
109
+ end
110
+
111
+ def static_page_slugs
112
+ [Rails.root, Engine.root].flat_map { |root| Dir[root.join("app/views/postnhost/static_pages/*.html.erb").to_s] }
113
+ .map { |path| File.basename(path, ".html.erb") }.uniq.sort
114
+ end
115
+
116
+ def create(scope, resource, definition)
117
+ attrs = arguments.fetch("attributes")
118
+ if resource == "api_tokens"
119
+ token, secret = ApiToken.issue!(user:, **attrs.symbolize_keys)
120
+ return { token:, secret: }
121
+ end
122
+ if %w[articles pages].include?(resource)
123
+ attrs = { "language_id" => Language.blog_default&.id }.merge(attrs).merge("user_id" => user.id)
124
+ elsif definition[:parent] && resource != "category_variants"
125
+ parent = Resources.model(definition[:parent]).find(arguments.fetch("parent_id"))
126
+ copied = parent.attributes.slice(*definition[:fields]).except("language_id")
127
+ attrs = copied.merge(attrs)
128
+ end
129
+ validate_schema_locales!(attrs)
130
+ record = scope.create!(attrs)
131
+ record.sync_publication_schedule! if record.is_a?(Article)
132
+ record
133
+ end
134
+
135
+ def update(record)
136
+ raise ArgumentError, "Translation is still generating" if record.respond_to?(:generating?) && record.generating?
137
+
138
+ attrs = arguments.fetch("attributes")
139
+ attrs = attrs.except("password", "password_confirmation") if record.is_a?(User) && attrs["password"].blank?
140
+ validate_schema_locales!(attrs)
141
+ record.update!(attrs)
142
+ record.sync_publication_schedule! if record.is_a?(Article)
143
+ record
144
+ end
145
+
146
+ def validate_schema_locales!(attributes)
147
+ return unless attributes["schema_locale_overrides"]
148
+
149
+ unknown = attributes["schema_locale_overrides"].keys - Language.pluck(:html_lang)
150
+ raise ArgumentError, "Schema locales must belong to configured languages" if unknown.any?
151
+ end
152
+
153
+ def delete(record)
154
+ if record.is_a?(ApiToken)
155
+ record.revoke!
156
+ return { revoked: true, id: record.id }
157
+ end
158
+ if record.is_a?(User)
159
+ return failure("You cannot remove your own account", status: :conflict) if record == user
160
+ return failure("Author owns pages; reassign or remove them first", status: :conflict) if record.pages.exists?
161
+
162
+ User.transaction do
163
+ Article.where(user_id: record.id).update_all(user_id: nil, updated_at: Time.current)
164
+ record.article_authors.delete_all
165
+ record.destroy!
166
+ end
167
+ else
168
+ record.destroy!
169
+ end
170
+ { deleted: true, id: record.id }
171
+ end
172
+ end
173
+ end
174
+ end
@@ -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