Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
002074e
Add MDS env config and host-matching helper.
kaysiz Jul 7, 2026
264a64f
Add MDS application layer, DOI operations, and host routes.
kaysiz Jul 7, 2026
5350e37
Add MDS metadata and media protocol handlers.
kaysiz Jul 7, 2026
aea545b
Document MDS embed and dual-host nginx routing.
kaysiz Jul 7, 2026
9e56494
Add MDS routing and request specs without running them.
kaysiz Jul 7, 2026
3939197
Fix MDS env bootstrap so test/dev enable safely.
kaysiz Jul 7, 2026
2f7a789
Clarify MDS config comment after env bootstrap fix.
kaysiz Jul 7, 2026
e105f5d
Add Mds::Error and shared DOI support for MDS protocol layer.
kaysiz Jul 7, 2026
4864410
Collapse MDS into thin protocol controllers over Lupo domain.
kaysiz Jul 7, 2026
51f4ba9
Move landing URL policy onto DataciteDoi domain helpers.
kaysiz Jul 7, 2026
99d21ef
Share request credential parsing between REST and MDS.
kaysiz Jul 7, 2026
504fc61
Split MDS DOI helpers off the protocol base controller.
kaysiz Jul 7, 2026
3d65c8f
Fix RuboCop layout on MDS controllers and specs.
kaysiz Jul 7, 2026
52ba467
Bump crass, css_parser, and msgpack for bundler-audit.
kaysiz Jul 7, 2026
f304370
Fix MDS metadata GET/DELETE 500s from CI.
kaysiz Jul 7, 2026
0332242
Address MDS review blockers for protocol and minting.
kaysiz Jul 7, 2026
fa8f2df
Route MDS heartbeat to Lupo memcached probe; drop login.
kaysiz Jul 7, 2026
e4ed668
Bump actions/checkout to v5 for Node 24 runtime.
kaysiz Jul 7, 2026
7e09e3f
Merge branch 'master' of github.com:datacite/lupo into ks-embed-mds-p…
kaysiz Jul 20, 2026
4b1131a
Merge branch 'master' of github.com:datacite/lupo into ks-embed-mds-p…
kaysiz Jul 21, 2026
f62d966
Merge branch 'master' of github.com:datacite/lupo into ks-embed-mds-p…
kaysiz Jul 23, 2026
57ba84b
cleaup some comments and authenticate mds with api key
kaysiz Jul 24, 2026
25de77f
Merge branch 'master' into ks-embed-mds-poodle
kaysiz Aug 6, 2026
909288e
Prefer DC.* API keys over JWT and fail closed on invalid keys.
kaysiz Aug 7, 2026
26a2c69
upgrade graphql
kaysiz Aug 7, 2026
5a3bc63
Reject blank media type or URL on MDS media create.
kaysiz Aug 7, 2026
5dae62c
Validate MDS metadata path DOI against body identifier.
kaysiz Aug 7, 2026
de4fd9a
Validate MDS mint prefix before generating random DOIs.
kaysiz Aug 7, 2026
c6f6c63
Merge branch 'master' into ks-embed-mds-poodle
kaysiz Aug 7, 2026
35badc2
Merge branch 'master' of github.com:datacite/lupo into ks-embed-mds-p…
kaysiz Aug 13, 2026
a266560
Harden prefix cache and client assign_prefix against stale ids.
kaysiz Aug 13, 2026
97bbb1d
Consolidate MDS protocol helpers and error handling.
kaysiz Aug 13, 2026
87ce85a
Finish shared landing-URL and API-key boundaries.
kaysiz Aug 13, 2026
db459a1
Define Authenticable.api_key_token? on the module itself.
kaysiz Aug 13, 2026
2985cdb
Fix MDS draft read leak and tighten protocol consistency.
kaysiz Aug 13, 2026
ab11371
Fix MDS cross-client specs: need a free prefix for other_client.
kaysiz Aug 13, 2026
9437d47
Constrain nested MDS media doi_id; document Poodle show event.
kaysiz Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,18 @@ ES_HOST=elasticsearch:9200
MAILGUN_API_KEY=
MG_DOMAIN=mg.datacite.org

# Needed for tests
# Needed for tests (seed client credentials, not the MDS protocol server)
MDS_USERNAME=
MDS_PASSWORD=

# MDS protocol (embedded former Poodle API). Host-constrained routes under Mds::
# Set MDS_ENABLED=true and point mds.* hosts at this app to serve classic MDS paths.
# Only list true MDS hostnames — never api/localhost/example.org or REST will hit MDS catch-all.
MDS_ENABLED=false
MDS_HOSTS=mds.datacite.org,mds.test.datacite.org,mds.stage.datacite.org,mds.local
MDS_URL=https://mds.test.datacite.org
MDS_REALM=mds.datacite.org

CONCURRENCY=25
INTEGRATION=1 # Set this environment variable to run the integration tests
REFRESH=true
Expand Down
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ gem "flipper", "~> 1.4", ">= 1.4.1"
gem "flipper-active_support_cache_store", "~> 1.4", ">= 1.4.1"
gem "gender_detector", "~> 2.1"
gem "google-protobuf", "~> 4.34", ">= 4.34.1"
gem "graphql", "~> 2.5", ">= 2.6.6"
gem "graphql", "~> 2.5", ">= 2.6.7"
gem "graphql-batch", "~> 0.6.1"
gem "hashid-rails", "~> 1.4", ">= 1.4.1"
gem "iso-639", "~> 0.3.8"
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,7 @@ DEPENDENCIES
flipper-active_support_cache_store (~> 1.4, >= 1.4.1)
gender_detector (~> 2.1)
google-protobuf (~> 4.34, >= 4.34.1)
graphql (~> 2.5, >= 2.6.6)
graphql (~> 2.5, >= 2.6.7)
graphql-batch (~> 0.6.1)
hashdiff (~> 1.2, >= 1.2.1)
hashid-rails (~> 1.4, >= 1.4.1)
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ Rails API application for managing DataCite providers, clients, prefixes and DOI
* **DOI**: Digital Object Identifier, the core entity managed by the system.
* **Prefix**: DOIs are assigned within a prefix (e.g., 10.1234).

## MDS API (classic Metadata Store protocol)

Lupo can serve the [classic DataCite MDS API](https://support.datacite.org/docs/mds-api-guide) in-process (paths such as `/doi`, `/metadata`, `/media`), replacing the former standalone [Poodle](https://github.com/datacite/poodle) service.

* Enable with `MDS_ENABLED=true`.
* Requests are accepted only when the HTTP `Host` is listed in `MDS_HOSTS` (e.g. `mds.datacite.org`, `mds.test.datacite.org`, `mds.local`).
* Public MDS URLs stay the same; only the backend behind those hosts changes.
* Non-MDS hosts continue to serve the JSON:API / GraphQL REST surface only.

See `.env.example` for `MDS_ENABLED`, `MDS_HOSTS`, `MDS_URL`, and `MDS_REALM`.

## Tech Stack

* Ruby on Rails
Expand Down
28 changes: 3 additions & 25 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Basic::ControllerMethods
include Authenticable
include RequestCredentials
include CanCan::ControllerAdditions
include ErrorSerializable
require "facets/string/snakecase"
Expand Down Expand Up @@ -71,31 +72,13 @@ def authenticate_user_with_basic_auth!
end

def authenticate_user!
type, credentials = type_and_credentials_from_request_headers

return false if credentials.blank?

if (ENV["JWT_BLACKLISTED"] || "").split(",").include?(credentials)
raise JWT::VerificationError
end

@current_user = User.new(credentials, type: type)

fail CanCan::AuthorizationNotPerformed if @current_user.errors.present?

set_api_key_sentry_tags
@current_user
authenticate_request!
end

def current_ability
@current_ability ||= Ability.new(current_user)
end

# based on https://github.com/nsarno/knock/blob/master/lib/knock/authenticable.rb
def type_and_credentials_from_request_headers
request.headers["Authorization"]&.split
end

def authenticated_user
current_user.try(:uid)
end
Expand Down Expand Up @@ -197,11 +180,6 @@ def set_raven_context
end

def set_api_key_sentry_tags
return unless current_user.try(:api_key_authenticated?)

Sentry.set_tags(
auth_method: current_user.auth_method,
api_key_prefix: current_user.api_key_prefix,
)
tag_api_key_observability!
end
end
54 changes: 54 additions & 0 deletions app/controllers/concerns/mds/doi_lookup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

module Mds
module DoiLookup
extend ActiveSupport::Concern

included do
include Bolognese::DoiUtils
end

private
def client_symbol
(current_user.client_id.presence || current_user.uid).to_s
end

def find_datacite_doi!(doi_string, not_found: Mds::DOI_NOT_FOUND)
doi_id = validate_doi(doi_string)
fail Mds::Error.new(not_found, status: 404) if doi_id.blank?

doi = DataciteDoi.where(doi: doi_id).first
fail Mds::Error.new(not_found, status: 404) if doi.blank?

doi
end

# Same visibility rules as REST DataciteDoisController#show: findable is
# public; draft/registered only for the owning client (or provider/staff).
# Use 404 (not 403) so existence of another repository's draft is not confirmed.
# Do not use authorize! :read — Ability has a global can :read, Doi.
def authorize_mds_doi_read!(doi, not_found: Mds::DOI_UNKNOWN_TO_MDS)
if not_allowed_by_doi_and_user(doi: doi, user: current_user)
fail Mds::Error.new(not_found, status: 404)
end
end

# DOIs for the authenticated repository's first prefix (MDS GET /doi list).
# Returns nil when there is nothing to list (caller should 204).
def listed_dois_for_current_user
client =
Client.where("datacentre.symbol = ?", current_user.uid.upcase).first
client_prefix = client&.prefixes&.first
return if client_prefix.blank?

dois =
DataciteDoi.get_dois(
prefix: client_prefix.uid,
username: current_user.uid.upcase,
)
return if dois.blank? || !dois.is_a?(Array) || dois.empty?

dois
end
end
end
32 changes: 32 additions & 0 deletions app/controllers/concerns/mds/doi_writer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

module Mds
module DoiWriter
extend ActiveSupport::Concern

included do
include Mds::DoiLookup
end

private
def upsert_datacite_doi!(doi_id, attributes)
attrs = attributes.to_h.compact.with_indifferent_access
doi = DataciteDoi.where(doi: doi_id).first

if doi
authorize! :update, doi
doi.current_user = current_user
doi.assign_attributes(attrs.except(:doi, :client_id))
else
doi = DataciteDoi.new(attrs.merge(doi: doi_id))
doi.current_user = current_user
authorize! :new, doi
end

return doi if doi.save

message = doi.errors.full_messages.first || "Unprocessable entity"
fail Mds::Error.new(message, status: 422)
end
end
end
48 changes: 48 additions & 0 deletions app/controllers/concerns/request_credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

module RequestCredentials
extend ActiveSupport::Concern

# based on https://github.com/nsarno/knock/blob/master/lib/knock/authenticable.rb
def type_and_credentials_from_request_headers
# Limit 2 keeps JWT/API-key material intact if it ever contained spaces.
request.headers["Authorization"]&.split(" ", 2)
end

def user_from_request_credentials
type, credentials = type_and_credentials_from_request_headers
return if credentials.blank?

# Only JWT access tokens belong on the blacklist — never DC.* API keys.
if !api_key_token?(credentials) &&
(ENV["JWT_BLACKLISTED"] || "").split(",").include?(credentials)
raise JWT::VerificationError
end

User.new(credentials, type: type)
end

def api_key_token?(token)
Authenticable.api_key_token?(token)
end

def authenticate_request!
@current_user = user_from_request_credentials
return false if @current_user.nil?

fail CanCan::AuthorizationNotPerformed if @current_user.errors.present?

tag_api_key_observability!
@current_user
end

def tag_api_key_observability!
return unless @current_user.try(:api_key_authenticated?)
return unless defined?(Sentry)

Sentry.set_tags(
auth_method: @current_user.auth_method,
api_key_prefix: @current_user.api_key_prefix,
)
end
end
58 changes: 21 additions & 37 deletions app/controllers/datacite_dois_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -728,41 +728,25 @@ def get_url

authorize! :get_url, @doi

if !@doi.is_registered_or_findable? ||
%w[europ].include?(@doi.provider_id) ||
@doi.type == "OtherDoi"
url = @doi.url
head :no_content && return if url.blank?
else
response = @doi.get_url

if response.status == 200
url = response.body.dig("data", "values", 0, "data", "value")
elsif response.status == 400 &&
response.body.dig("errors", 0, "title", "responseCode") == 301
response =
OpenStruct.new(
status: 403,
body: {
"errors" => [
{
"status" => 403,
"title" => "SERVER NOT RESPONSIBLE FOR HANDLE",
},
],
},
)
url = nil
else
url = nil
end
end

if url.present?
render json: { url: url }.to_json, status: :ok
else
render json: response.body.to_json,
status: response.status || :bad_request
result = @doi.resolve_landing_url
case result.kind
when :ok
render json: { url: result.url }.to_json, status: :ok
when :no_content
head :no_content
when :forbidden_handle
render json: {
"errors" => [
{
"status" => 403,
"title" => "SERVER NOT RESPONSIBLE FOR HANDLE",
},
],
}.to_json,
status: :forbidden
when :upstream
render json: result.body.to_json,
status: result.status || :bad_request
end
end

Expand All @@ -771,8 +755,8 @@ def get_dois

client =
Client.where("datacentre.symbol = ?", current_user.uid.upcase).first
client_prefix = client.prefixes.first
head :no_content && return if client_prefix.blank?
client_prefix = client&.prefixes&.first
return head :no_content if client_prefix.blank?

dois =
DataciteDoi.get_dois(
Expand Down
Loading