postnhost 0.2.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 627ae55fdce85eb1586f9dd686b9b637a83443c5b3752bb9b932f563d972cc72
4
- data.tar.gz: 29b259e49deacd46af93786df0bb5ee347a91ebc5ec8902d1b56cceb16c0a7d7
3
+ metadata.gz: eeff5b8e47da4271aa4b1b5240c98fa2e5fcf89ef59ea86f775c41c7fbf3c64e
4
+ data.tar.gz: 8784beb69048bba221cfbda8306a57eb1366b470996b02b4325bb2dec8feaacc
5
5
  SHA512:
6
- metadata.gz: e163ba13b11fed2c1840ccedb5d48523f89ea9039f5bf3b2d9fccc3ca02f2c2d82ed1d25133d08b6c5a0856ad3ff1df4b8ef0ba168603d3c9600862317a15ad3
7
- data.tar.gz: f85efaf4ef237bb6119101c673ef8f57f1623f79e94fd488637590342b58b2c8c994d8189436f41bb8a7be9e41d068d069cd6e5a683e9f005dc83d6e0f60af03
6
+ metadata.gz: 8317f464ba035086b326e0537c157f8957ad250080201d32af7e74955e0909e546f59e13bf2dfcbdd648e4476be75a3bbf6dea0fcee4df3ed90a47fb04c718d0
7
+ data.tar.gz: db9ff2f49ee7727d9e8156288de193e01992eef8f0b1cf06aa59fe9a41339b46bc8d5370d9c1cf72f45edceaeac8953bc8dcfd36b717c8194090b443a199dac1
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to PostnHost are documented in this file.
4
4
 
5
+ ## [0.2.1] - 2026-11-03
6
+
7
+ ### Fixed
8
+
9
+ - Show all users' articles in the CMS article list and suggestion picker, including articles whose creator has been deleted.
10
+ - Preserve shared article suggestions when the editor displays validation errors.
11
+
5
12
  ## [0.2.0] - 2026-10-24
6
13
 
7
14
  ### Added
@@ -56,6 +63,7 @@ First open source release of PostnHost.
56
63
 
57
64
  Authored by Kirill Shevchenko (Ruby/Rails engineering) and Maxim Sova (technical SEO expertise).
58
65
 
66
+ [0.2.1]: https://github.com/postnhost/postnhost/compare/v0.2.0...v0.2.1
59
67
  [0.2.0]: https://github.com/postnhost/postnhost/compare/v0.1.1...v0.2.0
60
68
  [0.1.1]: https://github.com/postnhost/postnhost/compare/v0.1.0...v0.1.1
61
69
  [0.1.0]: https://github.com/postnhost/postnhost/releases/tag/v0.1.0
data/README.md CHANGED
@@ -306,6 +306,22 @@ and publishing workflows.
306
306
  Discover commands at `/api/v1/commands`, OpenAPI at `/api/v1/openapi`, and
307
307
  connect MCP clients to `/api/v1/mcp`, relative to the engine mount path.
308
308
 
309
+ MCP clients can authenticate through OAuth: add the endpoint URL, sign in to the
310
+ CMS in your browser, and approve the connection. OAuth grants full CMS access
311
+ with a single `mcp` scope. Access tokens do not expire; revoke individual
312
+ connections under **API & MCP**. No refresh tokens or copied secrets are needed.
313
+ Existing manually created bearer tokens remain supported.
314
+
315
+ The engine provides client registration, authorization-code exchange with S256
316
+ PKCE, and discovery metadata. Authorization codes expire after five minutes and
317
+ can only be used once. OAuth access tokens are bound to the MCP endpoint.
318
+ After upgrading, install and run the new engine migration:
319
+
320
+ ```bash
321
+ bin/rails postnhost:install:migrations
322
+ bin/rails db:migrate
323
+ ```
324
+
309
325
  ## Routes
310
326
 
311
327
  The engine mounts at `/blog` by default. You can change this in `config/routes.rb`:
@@ -14,13 +14,18 @@ module Postnhost
14
14
  def authenticate_token!
15
15
  scheme, secret = request.authorization.to_s.split(" ", 2)
16
16
  token = ApiToken.authenticate(secret) if scheme&.casecmp?("Bearer")
17
+ token = nil if token&.resource.present? && token.resource != request.base_url + request.path
17
18
  @api_user = token&.user
18
19
  return if api_user
19
20
 
20
- response.set_header("WWW-Authenticate", 'Bearer realm="PostnHost"')
21
+ response.set_header("WWW-Authenticate", bearer_challenge)
21
22
  render json: { errors: ["A valid bearer token is required"] }, status: :unauthorized
22
23
  end
23
24
 
25
+ def bearer_challenge
26
+ 'Bearer realm="PostnHost"'
27
+ end
28
+
24
29
  def prevent_caching
25
30
  response.set_header("Cache-Control", "no-store")
26
31
  end
@@ -23,6 +23,10 @@ module Postnhost
23
23
 
24
24
  private
25
25
 
26
+ def bearer_challenge
27
+ %(Bearer resource_metadata="#{postnhost.oauth_resource_metadata_url}", scope="mcp")
28
+ end
29
+
26
30
  def validate_transport!
27
31
  origin = request.headers["Origin"]
28
32
  if origin.present? && origin != request.base_url
@@ -11,10 +11,9 @@ module Postnhost
11
11
  @status = Postnhost::Article.normalize_status(params[:status])
12
12
  @sort = Postnhost::Article.normalize_sort(params[:sort])
13
13
 
14
- articles = current_user.articles
15
- .includes(:language, :article_snapshot)
16
- .with_status(@status)
17
- .sorted_by(@sort)
14
+ articles = Postnhost::Article.includes(:language, :article_snapshot)
15
+ .with_status(@status)
16
+ .sorted_by(@sort)
18
17
 
19
18
  @pagy, @articles = pagy(:offset, articles, limit: PAGE_SIZE)
20
19
  end
@@ -30,7 +29,7 @@ module Postnhost
30
29
  @categories = Postnhost::Category.all
31
30
  @users = Postnhost::User.order(:name, :email)
32
31
  @languages = Postnhost::Language.order(default: :desc, name: :asc)
33
- @articles = current_user.articles.where.not(id: @article.id).order(:title)
32
+ @articles = Postnhost::Article.where.not(id: @article.id).order(:title)
34
33
  end
35
34
 
36
35
  def update
@@ -45,7 +44,7 @@ module Postnhost
45
44
  @categories = Postnhost::Category.all
46
45
  @users = Postnhost::User.order(:name, :email)
47
46
  @languages = Postnhost::Language.order(default: :desc, name: :asc)
48
- @articles = current_user.articles.where.not(id: @article.id).order(:title)
47
+ @articles = Postnhost::Article.where.not(id: @article.id).order(:title)
49
48
  render :edit, status: :unprocessable_content
50
49
  end
51
50
  end
@@ -0,0 +1,50 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class AuthorizationsController < ApplicationController
4
+ protect_from_forgery with: :exception
5
+ before_action :prevent_caching
6
+ before_action :validate_request!
7
+ before_action :require_oauth_user!
8
+ layout "postnhost/product"
9
+
10
+ def new; end
11
+
12
+ def create
13
+ result = if params[:decision] == "approve"
14
+ { code: OauthGrant.issue!(oauth_client: @client, user: current_user,
15
+ **@oauth_request.slice(:redirect_uri, :resource, :code_challenge).symbolize_keys) }
16
+ else
17
+ { error: "access_denied" }
18
+ end
19
+ uri = URI.parse(@oauth_request[:redirect_uri])
20
+ uri.query = [uri.query.presence, { **result, state: @oauth_request[:state] }.to_query].compact.join("&")
21
+ redirect_to uri.to_s, allow_other_host: true
22
+ end
23
+
24
+ private
25
+
26
+ def prevent_caching
27
+ response.set_header("Cache-Control", "no-store")
28
+ response.set_header("Referrer-Policy", "no-referrer")
29
+ end
30
+
31
+ def validate_request!
32
+ @oauth_request = params.permit(:client_id, :redirect_uri, :response_type, :scope, :state,
33
+ :code_challenge, :code_challenge_method, :resource).to_h.with_indifferent_access
34
+ @client = OauthClient.find_by(client_id: @oauth_request[:client_id])
35
+ valid = @client&.redirect_uris&.include?(@oauth_request[:redirect_uri]) &&
36
+ @oauth_request[:response_type] == "code" && @oauth_request[:code_challenge_method] == "S256" &&
37
+ @oauth_request[:code_challenge].to_s.match?(/\A[A-Za-z0-9_-]{43}\z/) &&
38
+ @oauth_request[:scope].to_s.in?(["", "mcp"]) && @oauth_request[:resource] == postnhost.api_v1_mcp_url
39
+ render plain: "Invalid OAuth authorization request", status: :bad_request unless valid
40
+ end
41
+
42
+ def require_oauth_user!
43
+ return if user_signed_in?
44
+
45
+ session[:oauth_return_to] = postnhost.oauth_authorize_path(@oauth_request)
46
+ authenticate_user!
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class MetadataController < PublicController
4
+ def resource
5
+ render json: {
6
+ resource: postnhost.api_v1_mcp_url,
7
+ authorization_servers: [request.base_url + request.script_name],
8
+ scopes_supported: ["mcp"], bearer_methods_supported: ["header"]
9
+ }
10
+ end
11
+
12
+ def server
13
+ render json: {
14
+ issuer: request.base_url + request.script_name,
15
+ authorization_endpoint: postnhost.oauth_authorize_url,
16
+ token_endpoint: postnhost.oauth_token_url,
17
+ registration_endpoint: postnhost.oauth_register_url,
18
+ response_types_supported: ["code"], grant_types_supported: ["authorization_code"],
19
+ token_endpoint_auth_methods_supported: ["none"],
20
+ code_challenge_methods_supported: ["S256"], scopes_supported: ["mcp"]
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ module Postnhost
2
+ module Oauth
3
+ # OAuth protocol endpoints do not use CMS sessions for authentication.
4
+ # rubocop:disable-next Rails/ApplicationController
5
+ class PublicController < ActionController::Base
6
+ skip_forgery_protection
7
+ before_action :prevent_caching
8
+
9
+ private
10
+
11
+ def prevent_caching
12
+ response.set_header("Cache-Control", "no-store")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,29 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class RegistrationsController < PublicController
4
+ def create
5
+ unless params[:token_endpoint_auth_method].to_s.in?(["", "none"]) &&
6
+ supported_grants? &&
7
+ (params[:response_types].nil? || params[:response_types] == ["code"])
8
+ render json: { error: "invalid_client_metadata" }, status: :bad_request
9
+ return
10
+ end
11
+
12
+ client = OauthClient.create!(name: params[:client_name].presence || "MCP client", redirect_uris: params[:redirect_uris])
13
+ render json: {
14
+ client_id: client.client_id, client_name: client.name, redirect_uris: client.redirect_uris,
15
+ token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"]
16
+ }, status: :created
17
+ rescue ActiveRecord::RecordInvalid
18
+ render json: { error: "invalid_client_metadata" }, status: :bad_request
19
+ end
20
+
21
+ private
22
+
23
+ def supported_grants?
24
+ grants = params[:grant_types] || ["authorization_code"]
25
+ grants.is_a?(Array) && grants.include?("authorization_code") && (grants - %w[authorization_code refresh_token]).empty?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ module Postnhost
2
+ module Oauth
3
+ class TokensController < PublicController
4
+ def create
5
+ unless params[:grant_type] == "authorization_code"
6
+ render json: { error: "unsupported_grant_type" }, status: :bad_request
7
+ return
8
+ end
9
+
10
+ secret = OauthGrant.exchange(**%i[code client_id redirect_uri resource code_verifier].index_with { |key| params[key].to_s })
11
+ if secret
12
+ render json: { access_token: secret, token_type: "Bearer", scope: "mcp" }
13
+ else
14
+ render json: { error: "invalid_grant" }, status: :bad_request
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -9,8 +9,9 @@ module Postnhost
9
9
  user = Postnhost::User.find_by(email: session_params[:email].to_s.downcase.strip)
10
10
 
11
11
  if user&.authenticate(session_params[:password].to_s)
12
+ oauth_return_to = session[:oauth_return_to]
12
13
  sign_in(user)
13
- redirect_to articles_path
14
+ redirect_to oauth_return_to || articles_path
14
15
  else
15
16
  flash.now[:alert] = "Invalid email or password."
16
17
  render :new, status: :unprocessable_content
@@ -7,9 +7,9 @@ module Postnhost
7
7
  validates :name, :token_digest, presence: true
8
8
  validates :token_digest, uniqueness: true
9
9
 
10
- def self.issue!(user:, name:)
10
+ def self.issue!(user:, name:, resource: nil)
11
11
  secret = "pnh_#{SecureRandom.urlsafe_base64(32)}"
12
- token = create!(user:, name:, token_digest: Digest::SHA256.hexdigest(secret))
12
+ token = create!(user:, name:, resource:, token_digest: Digest::SHA256.hexdigest(secret))
13
13
  [token, secret]
14
14
  end
15
15
 
@@ -0,0 +1,29 @@
1
+ require "uri"
2
+
3
+ module Postnhost
4
+ class OauthClient < ApplicationRecord
5
+ has_many :oauth_grants, class_name: "Postnhost::OauthGrant", dependent: :destroy
6
+ has_secure_token :client_id, length: 32
7
+
8
+ validates :name, presence: true, length: { maximum: 100 }
9
+ validate :safe_redirect_uris
10
+
11
+ private
12
+
13
+ def safe_redirect_uris
14
+ return if redirect_uris.is_a?(Array) && redirect_uris.size.between?(1, 10) && redirect_uris.all? { |value| safe_redirect_uri?(value) }
15
+
16
+ errors.add(:redirect_uris, "must contain HTTPS URLs or HTTP loopback URLs")
17
+ end
18
+
19
+ def safe_redirect_uri?(value)
20
+ return false unless value.is_a?(String) && value.length <= 2048
21
+
22
+ uri = URI.parse(value)
23
+ uri.host.present? && uri.userinfo.nil? && uri.fragment.nil? &&
24
+ (uri.scheme == "https" || (uri.scheme == "http" && %w[localhost 127.0.0.1 [::1]].include?(uri.host)))
25
+ rescue URI::InvalidURIError
26
+ false
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,32 @@
1
+ require "base64"
2
+ require "digest"
3
+
4
+ module Postnhost
5
+ class OauthGrant < ApplicationRecord
6
+ belongs_to :oauth_client, class_name: "Postnhost::OauthClient"
7
+ belongs_to :user, class_name: "Postnhost::User"
8
+
9
+ def self.issue!(**attributes)
10
+ code = SecureRandom.urlsafe_base64(32)
11
+ create!(**attributes, code_digest: Digest::SHA256.hexdigest(code), expires_at: 5.minutes.from_now)
12
+ code
13
+ end
14
+
15
+ def self.exchange(code:, client_id:, redirect_uri:, resource:, code_verifier:)
16
+ return unless code_verifier.match?(/\A[A-Za-z0-9._~-]{43,128}\z/)
17
+
18
+ transaction do
19
+ grant = lock.find_by(code_digest: Digest::SHA256.hexdigest(code))
20
+ return unless grant&.expires_at&.future? && grant.oauth_client.client_id == client_id &&
21
+ grant.redirect_uri == redirect_uri && grant.resource == resource
22
+
23
+ challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
24
+ return unless ActiveSupport::SecurityUtils.secure_compare(grant.code_challenge, challenge)
25
+
26
+ token = ApiToken.issue!(user: grant.user, name: grant.oauth_client.name, resource: grant.resource)
27
+ grant.destroy!
28
+ token.last
29
+ end
30
+ end
31
+ end
32
+ end
@@ -6,6 +6,7 @@ module Postnhost
6
6
  self.table_name = "postnhost_users"
7
7
 
8
8
  has_many :api_tokens, class_name: "Postnhost::ApiToken", dependent: :destroy
9
+ has_many :oauth_grants, class_name: "Postnhost::OauthGrant", dependent: :destroy
9
10
 
10
11
  has_secure_password
11
12
 
@@ -35,7 +35,7 @@
35
35
  <section class="<%= form_card_classes %>" aria-labelledby="connection-title">
36
36
  <div>
37
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>
38
+ <p class="mt-2 text-sm text-gray-500">Connect an MCP client by signing in through your browser, or use a token for the management API.</p>
39
39
  </div>
40
40
  <dl class="space-y-5">
41
41
  <div>
@@ -45,7 +45,7 @@
45
45
  <div>
46
46
  <dt class="text-sm font-medium text-gray-700">MCP · v1</dt>
47
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>
48
+ <dd class="mt-2 text-xs text-gray-500">Connect using Streamable HTTP and OAuth. Access remains active until revoked.</dd>
49
49
  </div>
50
50
  <div>
51
51
  <dt class="text-sm font-medium text-gray-700">Authorization header</dt>
@@ -0,0 +1,18 @@
1
+ <% content_for :title, "Connect MCP" %>
2
+
3
+ <section class="max-w-xl space-y-6" aria-labelledby="oauth-title">
4
+ <h1 id="oauth-title" class="text-xl font-medium text-gray-900">Connect <%= @client.name %>?</h1>
5
+ <p class="text-gray-700">This application will be able to read and manage your CMS content and settings, including publishing and deleting content.</p>
6
+ <p class="text-sm text-gray-500">Access remains active until you revoke it in API &amp; MCP.</p>
7
+ <dl class="space-y-2">
8
+ <dt class="text-sm font-medium text-gray-700">Application callback</dt>
9
+ <dd class="text-sm text-gray-700 break-all"><%= @oauth_request[:redirect_uri] %></dd>
10
+ </dl>
11
+ <%= form_with url: oauth_authorize_path, data: { turbo: false }, class: "flex gap-3" do |form| %>
12
+ <% @oauth_request.each do |key, value| %>
13
+ <%= hidden_field_tag key, value %>
14
+ <% end %>
15
+ <%= form.button "Connect", name: "decision", value: "approve", class: button_classes %>
16
+ <%= form.button "Cancel", name: "decision", value: "deny", class: button_classes(variant: :secondary) %>
17
+ <% end %>
18
+ </section>
data/config/routes.rb CHANGED
@@ -33,6 +33,15 @@ Postnhost::Engine.routes.draw do
33
33
  end
34
34
 
35
35
  # Admin routes
36
+ namespace :oauth do
37
+ get "resource_metadata", to: "metadata#resource"
38
+ get "server_metadata", to: "metadata#server"
39
+ get "authorize", to: "authorizations#new"
40
+ post "authorize", to: "authorizations#create"
41
+ post "register", to: "registrations#create"
42
+ post "token", to: "tokens#create"
43
+ end
44
+
36
45
  resources :api_tokens, only: %i[index create destroy]
37
46
  resource :onboarding, only: %i[show update], controller: "onboarding"
38
47
  resource :session, only: %i[new create destroy], controller: "sessions"
@@ -0,0 +1,23 @@
1
+ class AddMcpOauth < ActiveRecord::Migration[7.2]
2
+ def change
3
+ create_table :postnhost_oauth_clients do |t|
4
+ t.string :client_id, null: false
5
+ t.string :name, null: false
6
+ t.json :redirect_uris, null: false
7
+ t.timestamps
8
+ end
9
+ add_index :postnhost_oauth_clients, :client_id, unique: true
10
+
11
+ create_table :postnhost_oauth_grants do |t|
12
+ t.references :oauth_client, null: false, foreign_key: { to_table: :postnhost_oauth_clients }
13
+ t.references :user, null: false, foreign_key: { to_table: :postnhost_users }
14
+ t.string :code_digest, null: false
15
+ t.string :code_challenge, null: false
16
+ t.string :redirect_uri, null: false
17
+ t.string :resource, null: false
18
+ t.datetime :expires_at, null: false
19
+ end
20
+ add_index :postnhost_oauth_grants, :code_digest, unique: true
21
+ add_column :postnhost_api_tokens, :resource, :string
22
+ end
23
+ end
@@ -1,7 +1,14 @@
1
+ require_relative "oauth_discovery"
2
+
1
3
  module Postnhost
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace Postnhost
4
6
 
7
+ initializer "postnhost.oauth" do |app|
8
+ app.middleware.use Postnhost::OauthDiscovery
9
+ app.config.filter_parameters += %i[code code_verifier access_token]
10
+ end
11
+
5
12
  # Initialize configuration before other initializers
6
13
  initializer "postnhost.configuration", before: :load_config_initializers do
7
14
  Postnhost.configure
@@ -0,0 +1,22 @@
1
+ module Postnhost
2
+ # Well-known discovery lives at the origin even when the engine is mounted at /blog.
3
+ class OauthDiscovery
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ path = env["PATH_INFO"]
10
+ if env["REQUEST_METHOD"] == "GET" && path.match?(%r{\A/\.well-known/oauth-authorization-server(?:/.*)?\z})
11
+ status, headers, body = @app.call(env)
12
+ return [status, headers, body] unless status == 404
13
+
14
+ body.close if body.respond_to?(:close)
15
+ mount = path.delete_prefix("/.well-known/oauth-authorization-server")
16
+ return @app.call(env.merge("PATH_INFO" => "#{mount}/oauth/server_metadata"))
17
+ end
18
+
19
+ @app.call(env)
20
+ end
21
+ end
22
+ end
@@ -1,3 +1,3 @@
1
1
  module Postnhost
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: postnhost
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kirill Shevchenko
@@ -338,6 +338,11 @@ files:
338
338
  - app/controllers/postnhost/category_variants_controller.rb
339
339
  - app/controllers/postnhost/languages_controller.rb
340
340
  - app/controllers/postnhost/management_preview_controller.rb
341
+ - app/controllers/postnhost/oauth/authorizations_controller.rb
342
+ - app/controllers/postnhost/oauth/metadata_controller.rb
343
+ - app/controllers/postnhost/oauth/public_controller.rb
344
+ - app/controllers/postnhost/oauth/registrations_controller.rb
345
+ - app/controllers/postnhost/oauth/tokens_controller.rb
341
346
  - app/controllers/postnhost/onboarding_controller.rb
342
347
  - app/controllers/postnhost/page_variants_controller.rb
343
348
  - app/controllers/postnhost/pages/images_controller.rb
@@ -416,6 +421,8 @@ files:
416
421
  - app/models/postnhost/language.rb
417
422
  - app/models/postnhost/navigation.rb
418
423
  - app/models/postnhost/navigation_item.rb
424
+ - app/models/postnhost/oauth_client.rb
425
+ - app/models/postnhost/oauth_grant.rb
419
426
  - app/models/postnhost/page.rb
420
427
  - app/models/postnhost/page_variant.rb
421
428
  - app/models/postnhost/public_site_revision.rb
@@ -537,6 +544,7 @@ files:
537
544
  - app/views/postnhost/languages/index.html.erb
538
545
  - app/views/postnhost/languages/new.html.erb
539
546
  - app/views/postnhost/languages/show.html.erb
547
+ - app/views/postnhost/oauth/authorizations/new.html.erb
540
548
  - app/views/postnhost/onboarding/_post_install_checklist.html.erb
541
549
  - app/views/postnhost/onboarding/_step_1.html.erb
542
550
  - app/views/postnhost/onboarding/_step_2.html.erb
@@ -690,6 +698,7 @@ files:
690
698
  - db/migrate/20260709090000_create_snapshots.rb
691
699
  - db/migrate/20260726130000_create_site_scripts.rb
692
700
  - db/migrate/20261008120000_create_api_tokens.rb
701
+ - db/migrate/20261025120000_add_mcp_oauth.rb
693
702
  - lib/generators/postnhost/install/install_generator.rb
694
703
  - lib/generators/postnhost/install/templates/carrierwave.rb
695
704
  - lib/generators/postnhost/install/templates/postnhost.rb
@@ -704,6 +713,7 @@ files:
704
713
  - lib/postnhost/css_build.rb
705
714
  - lib/postnhost/css_scope.rb
706
715
  - lib/postnhost/engine.rb
716
+ - lib/postnhost/oauth_discovery.rb
707
717
  - lib/postnhost/sample_data.rb
708
718
  - lib/postnhost/settings/i18n_overrides.rb
709
719
  - lib/postnhost/settings/i18n_patch.rb