diff --git a/.gitattributes b/.gitattributes index a2257114..3b7d0da9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -150,6 +150,7 @@ flake.lock text eol=lf -diff linguist-generated=true Manifest.toml text eol=lf -diff -merge linguist-generated=true renv.lock text eol=lf -diff -merge linguist-generated=true frontend/bun.lock text eol=lf -diff -merge linguist-generated=true +test/doi/bun.lock text eol=lf -diff -merge linguist-generated=true bun.lockb binary -diff -merge linguist-generated=true package-lock.json text eol=lf -diff -merge linguist-generated=true pnpm-lock.yaml text eol=lf -diff -merge linguist-generated=true diff --git a/.github/workflows/doi.yml b/.github/workflows/doi.yml new file mode 100644 index 00000000..5e5b4c53 --- /dev/null +++ b/.github/workflows/doi.yml @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MPL-2.0 +name: DOI publication contracts + +on: + pull_request: + paths: ['src/doi/**', 'src/server/**', 'src/analysis/AnalysisConfig.jl', 'test/doi/**', 'test/unit/test_doi*', 'bench/doi/**', 'scripts/link-doi.sh', 'config/schemas/doi*', 'config/templates/doi*', 'Project.toml', 'Manifest.toml', '.github/workflows/doi.yml'] + push: + branches: [main, 'arena/**'] + paths: ['src/doi/**', 'src/server/**', 'src/analysis/AnalysisConfig.jl', 'test/doi/**', 'test/unit/test_doi*', 'bench/doi/**', 'scripts/link-doi.sh', 'config/schemas/doi*', 'config/templates/doi*', 'Project.toml', 'Manifest.toml', '.github/workflows/doi.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + contracts: + name: DOI contracts (no credentials or live deposits) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + JULIA_PKG_PRECOMPILE_AUTO: '0' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: '1.12.5' + - uses: julia-actions/cache@a7bed9df697e5d7309d68afe7542a87621a8b6c8 # v3.3.0 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version-file: .bun-version + - name: Syntax and source dependency contracts + run: | + julia --startup-file=no config/ci/lint_source.jl + bash -n scripts/link-doi.sh test/doi/check-nickel.sh + command -v zip + command -v jq + command -v flock + - name: Instantiate isolated HTTP-only test environment + run: julia --project=test/doi -e 'using Pkg; Pkg.instantiate()' + - name: Configure temporary contract outputs + run: echo "DOI_CONTRACT_ARTIFACTS=$RUNNER_TEMP/doi-contracts" >> "$GITHUB_ENV" + - name: Publication lifecycle, security, restart and streamed HTTP contracts + run: julia --project=test/doi test/doi/runtests.jl + - name: Install pinned browser and schema tools + working-directory: test/doi + run: | + bun install --frozen-lockfile --ignore-scripts + bunx playwright install --with-deps chromium + - name: Linker, JSON Schema, CFF and projection roundtrips + working-directory: test/doi + run: bun run test + - name: Evidence Mode, typed confirmation and browser recovery + working-directory: test/doi + run: bun run test:browser + - name: Verify Nickel contracts with a checksum-pinned CLI + run: | + curl --fail --location --retry 2 --max-time 120 https://github.com/nickel-lang/nickel/releases/download/1.18.0/nickel-x86_64-linux -o "$RUNNER_TEMP/nickel" + printf '9cba4dd65ae9915ec61f73033aafcff307a377665a83fd8f530df086763318cb %s\n' "$RUNNER_TEMP/nickel" | sha256sum --check + chmod +x "$RUNNER_TEMP/nickel" + NICKEL="$RUNNER_TEMP/nickel" bash test/doi/check-nickel.sh + - name: Publication performance and bounded-memory smoke + run: | + julia --project=test/doi bench/doi/benchmark.jl > "$RUNNER_TEMP/doi-performance.json" + if [[ -f bench/doi/baseline.json ]]; then + bun bench/doi/compare.js bench/doi/baseline.json "$RUNNER_TEMP/doi-performance.json" + else + echo '::warning::First DOI benchmark baseline is not established. Report is informational; no regression verdict is claimed. Review and retain a same-host baseline before enabling the >10% comparison.' + fi + - name: Preserve contract outputs and resolved environment + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: doi-contract-evidence + path: | + test/doi/Manifest.toml + ${{ runner.temp }}/doi-contracts/ + ${{ runner.temp }}/doi-performance.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index cdfeec6c..4fd90453 100644 --- a/.gitignore +++ b/.gitignore @@ -262,6 +262,7 @@ docs/* !docs/type-system/ !docs/types/ !docs/reproducibility.md +!docs/doi-publication.md !docs/owner-review-2026-09-25.md !docs/integration/ !docs/integration/** diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt new file mode 100644 index 00000000..e69451d2 --- /dev/null +++ b/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,395 @@ +Creative Commons Attribution 4.0 International Public License + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/Manifest.toml b/Manifest.toml index dbfb54e6..3146ad43 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -2,7 +2,7 @@ julia_version = "1.12.5" manifest_format = "2.0" -project_hash = "5b8f89666567516def011d4b7c2b6a7d0010a274" +project_hash = "15c3e14a1821e548099e9b92fd6c3f06c9160794" [[deps.AliasTables]] deps = ["PtrArrays", "Random"] @@ -409,6 +409,12 @@ deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"] uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" version = "1.11.0" +[[deps.MD5]] +deps = ["Random", "SHA"] +git-tree-sha1 = "1576f756617d31eb397a4a517b68562fd28dc2b4" +uuid = "6ac74813-4b46-53a4-afec-0b5dc9d7885c" +version = "0.2.3" + [[deps.MbedTLS]] deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"] git-tree-sha1 = "8785729fa736197687541f7053f6d8ab7fc44f92" diff --git a/Project.toml b/Project.toml index 6ec20689..1878f08e 100644 --- a/Project.toml +++ b/Project.toml @@ -12,6 +12,7 @@ DuckDB = "d2f5444f-75bc-4fdf-ac35-56f514c445e1" HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +MD5 = "6ac74813-4b46-53a4-afec-0b5dc9d7885c" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" Oxygen = "df9a0d86-3283-4920-82dc-4555fc0d1d8b" PackageCompiler = "9b87118b-4619-50d2-8e1e-99f35a4d4d9d" @@ -27,6 +28,9 @@ YAML = "ddb6d928-2868-570f-bddf-ab3f9cf99eb6" BenchmarkTools = "1.8.0" CSV = "0.10" DataFrames = "1.8" +MD5 = "0.2.3" +HTTP = "1" +JSON3 = "1" PackageCompiler = "2.2.5" RCall = "0.14" YAML = "0.4" diff --git a/README.md b/README.md index f76644dc..1d531a13 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,18 @@ Once a run completes, analysis is performed on request through the web UI, both Counts may be normalised before analysis (none, rarefaction to a fixed or auto-resolved depth, or relative sum scaling), and contamination-flagged taxa may be included or excluded. All analysis charts are returned as Plotly JSON and rendered interactively in the browser. +## Citable analysis bundles (opt-in) + +The study's **Evidence / DOI publication** page can prepare a private Zenodo draft, +freeze and verify the exact config/result archive, and request DOI publication +only after a separate DANGER/typed confirmation. Sandbox is the default. Published +receipts/citations can be linked to an existing GitHub release and Projects v2 board +with the offline-first `scripts/link-doi.sh` helper. + +See [operator and author guidance](docs/doi-publication.md) and the +[test/acceptance report](docs/testing/doi-publication.md). **Runtime acceptance is +still pending**; static and adapter checks alone are not production approval. + ## Prerequisites **One-command toolchain (recommended — the repo is standalone):** the pinned diff --git a/bench/doi/benchmark.jl b/bench/doi/benchmark.jl new file mode 100644 index 00000000..65f592a8 --- /dev/null +++ b/bench/doi/benchmark.jl @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MPL-2.0 +# Credential-free warmed publication microbenchmarks. No R or live Zenodo calls. +# Run with the pinned isolated environment; stdout is a machine-readable report: +# julia --project=test/doi bench/doi/benchmark.jl > /tmp/doi-performance.json +using JSON3, Test, HTTP, Dates, SHA, MD5 +include(joinpath(@__DIR__, "..", "..", "test", "doi", "bootstrap.jl")) +const S = DOIIsolated.DOIStorage +const B = DOIIsolated.DOIBundles +const Z = DOIIsolated.Zenodo +const P = DOIIsolated.DOIPublications +include(joinpath(@__DIR__, "..", "..", "test", "doi", "fixtures.jl")) + +function measure(operation; samples=31) + for _ in 1:3; operation(); end # exclude first-compilation costs + GC.gc() + trials = [@timed(operation()) for _ in 1:samples] + midpoint = cld(samples, 2) + Dict("median_ns" => sort!([t.time * 1e9 for t in trials])[midpoint], + "median_bytes" => sort!([t.bytes for t in trials])[midpoint]) +end + +report = Dict{String,Any}("schema_version" => 1, "julia_version" => string(VERSION), + "cpu" => Sys.CPU_NAME, "os" => string(Sys.KERNEL), "arch" => string(Sys.ARCH), + "threads" => Threads.nthreads(), "samples" => 31, "payload_bytes" => 8 * 1024 * 1024, + "metrics" => Dict{String,Any}()) +prepared_fixture() do tmp, root, source, fake, client, prepared + path = joinpath(tmp, "bounded-download.bin") + write(path, repeat("0123456789abcdef", div(report["payload_bytes"], 16))) + metadata = B.validate_metadata(metadata_fixture()) + report["metrics"]["metadata_validation"] = measure(() -> B.validate_metadata(metadata_fixture())) + report["metrics"]["snapshot_checksums"] = measure(() -> B.snapshot(source)) + report["metrics"]["archive_sha256_8MiB"] = measure(() -> S.file_sha256(path)) + report["metrics"]["download_8MiB"] = measure(() -> write(devnull, S.FileBody(path))) + before = length(fake.calls) + report["metrics"]["prepared_replay"] = measure(() -> P.prepare!(root, source, metadata, client)) + length(fake.calls) == before || error("A prepared replay must not perform network operations") + publish_fixture(root, prepared, client) + before = length(fake.calls) + report["metrics"]["published_replay"] = measure(() -> publish_fixture(root, prepared, client)) + length(fake.calls) == before || error("A published replay must not perform network operations") + # Resource-safety contract, independent of CPU speed or a historical baseline. + report["metrics"]["download_8MiB"]["median_bytes"] <= 2 * 1024 * 1024 || + error("Download allocation exceeded its bounded-memory budget (2 MiB)") +end +println(S.canonical_json(report)) diff --git a/bench/doi/compare.js b/bench/doi/compare.js new file mode 100644 index 00000000..95eb9d2b --- /dev/null +++ b/bench/doi/compare.js @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 +// Deliberately fail closed: never manufacture a baseline or silently ignore cases. +import { readFileSync } from 'node:fs' + +export function compareReports(baseline, current) { + if (baseline.schema_version !== 1 || current.schema_version !== 1) throw new Error('Unsupported DOI benchmark report version') + for (const key of ['julia_version', 'cpu', 'os', 'arch', 'threads', 'samples', 'payload_bytes']) { + if (baseline[key] === undefined || baseline[key] !== current[key]) throw new Error(`Incomparable benchmark environment/workload: ${key}`) + } + const keys = Object.keys(baseline.metrics ?? {}).sort() + if (!keys.length || JSON.stringify(keys) !== JSON.stringify(Object.keys(current.metrics ?? {}).sort())) throw new Error('Missing or changed benchmark cases') + const failures = [] + for (const key of keys) { + for (const field of ['median_ns', 'median_bytes']) { + const before = baseline.metrics[key][field], after = current.metrics[key][field] + if (![before, after].every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0)) throw new Error(`Invalid benchmark measurement: ${key}.${field}`) + if (after > before * 1.10) failures.push(`${key}.${field}: ${before} -> ${after} (>10% regression)`) + } + } + if (failures.length) throw new Error(failures.join('\n')) + return keys.length +} + +if (import.meta.main) { + if (process.argv.length !== 4) throw new Error('Usage: bun bench/doi/compare.js BASELINE.json CURRENT.json; generate both with bench/doi/benchmark.jl on the same controlled host') + const [baseline, current] = process.argv.slice(2).map(path => JSON.parse(readFileSync(path, 'utf8'))) + console.log(`DOI benchmark gate passed: ${compareReports(baseline, current)} cases, time and allocation <=10% regression`) +} diff --git a/config/schemas/doi_publication.ncl b/config/schemas/doi_publication.ncl new file mode 100644 index 00000000..dd7b2863 --- /dev/null +++ b/config/schemas/doi_publication.ncl @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MPL-2.0 +# Nickel >=1.8. Contract for the flat RESERVED attestation only. Apply with: +# let C = import "doi_publication.ncl" in (import "publication.ncl") | C +# Status/receipt lifecycle is doi_publication.schema.json; Julia additionally +# verifies remote state, file bytes, identifiers and explicit confirmation. +let Matching = fun regex => std.contract.from_predicate (fun v => + std.is_string v && std.string.is_match regex v) in +let Hash = Matching "^[0-9a-f]{64}$" in +let UUID = Matching "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" in +let Optional = fun C => std.contract.any_of [std.contract.from_predicate (fun v => v == null), C] in +let Environment = Matching "^(sandbox|production)$" in +let Kind = Matching "^(configuration|analysis_result)$" in +let Release = Matching "^https://github[.]com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/tag/[A-Za-z0-9_.~+/-]+$" in +let Project = Matching "^https://github[.]com/(users|orgs)/[A-Za-z0-9][A-Za-z0-9-]*/projects/[1-9][0-9]*$" in +std.contract.all_of [ + { + schema_version | Matching "^1[.]0[.]0$", + publication_id | Hash, + environment | Environment, + state | Matching "^reserved$" | doc "A reservation is not a published, citable DOI.", + deposition_id | Matching "^[1-9][0-9]{0,17}$", + doi | Matching "^10[.](5072|5281)/zenodo[.][1-9][0-9]*$", + kind | Kind, + config_id | UUID, + config_hash | Hash, + config_file_sha256 | Hash, + result_id | Optional UUID, + result_hash | Optional Hash, + result_file_sha256 | Optional Hash, + dangerous | Bool | doc "Scientific DANGER warnings remain in the original bundle.", + github_release_url | Optional Release, + github_project_url | Optional Project, + }, + std.contract.from_predicate (fun value => + (value.doi == ((if value.environment == "sandbox" then "10.5072/zenodo." else "10.5281/zenodo.") ++ value.deposition_id)) && + (if value.kind == "configuration" then + value.result_id == null && value.result_hash == null && value.result_file_sha256 == null + else + value.result_id != null && value.result_hash != null && value.result_file_sha256 != null)), +] diff --git a/config/schemas/doi_publication.schema.json b/config/schemas/doi_publication.schema.json new file mode 100644 index 00000000..2ab11bdd --- /dev/null +++ b/config/schemas/doi_publication.schema.json @@ -0,0 +1,765 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://hyperpolymath.github.io/MetaManifold-WebUI/schemas/doi_publication.schema.json", + "$comment": "SPDX-License-Identifier: MPL-2.0", + "title": "MetaManifold DOI publication status and receipt v1", + "description": "Separate publication attestation, never an edit to a scientific config hash. Julia additionally validates ORCID checksums, URL traversal, dates relative to today, confirmation binding and remote integrity. Reserved identifiers and 202 acceptance are not published DOIs.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "state", + "environment", + "binding", + "metadata", + "deposition_id", + "reserved_doi", + "doi", + "record_url", + "bundle_sha256", + "bundle_md5", + "bundle_size", + "created_at", + "updated_at", + "published_at", + "last_error", + "confirmation_phrase", + "citation", + "doi_url", + "draft_url", + "test_record" + ], + "properties": { + "schema_version": { + "const": "1.0.0" + }, + "id": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "state": { + "enum": [ + "preparing", + "creating", + "creation_uncertain", + "draft", + "ready", + "publishing", + "publication_uncertain", + "published" + ] + }, + "environment": { + "enum": [ + "sandbox", + "production" + ] + }, + "binding": { + "$ref": "#/$defs/binding" + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "deposition_id": { + "anyOf": [ + { + "type": "string", + "pattern": "^[1-9][0-9]{0,17}$" + }, + { + "type": "null" + } + ] + }, + "reserved_doi": { + "anyOf": [ + { + "type": "string", + "pattern": "^10\\.(5072|5281)/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "doi": { + "anyOf": [ + { + "type": "string", + "pattern": "^10\\.(5072|5281)/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "record_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://(sandbox\\.)?zenodo.org/records/[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "bundle_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "bundle_md5": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + { + "type": "null" + } + ] + }, + "bundle_size": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 50000000000 + }, + { + "type": "null" + } + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "published_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + }, + "last_error": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + }, + "confirmation_phrase": { + "type": "string" + }, + "citation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "doi_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://doi.org/10\\.(5072|5281)/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "draft_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://(sandbox\\.)?zenodo.org/deposit/[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "test_record": { + "type": "boolean" + }, + "api_version": { + "const": "deposition-v1" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "at", + "event", + "state" + ], + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "event": { + "type": "string" + }, + "state": { + "enum": [ + "preparing", + "creating", + "creation_uncertain", + "draft", + "ready", + "publishing", + "publication_uncertain", + "published" + ] + } + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "const": "published" + } + } + }, + "then": { + "properties": { + "doi": { + "type": "string", + "pattern": "^10\\.(5072|5281)/zenodo\\.[1-9][0-9]*$" + }, + "doi_url": { + "type": "string", + "format": "uri" + }, + "published_at": { + "type": "string", + "format": "date-time" + }, + "deposition_id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,17}$" + }, + "bundle_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "citation": { + "type": "string", + "minLength": 1 + }, + "record_url": { + "type": "string", + "format": "uri" + } + } + }, + "else": { + "properties": { + "doi": { + "type": "null" + }, + "doi_url": { + "type": "null" + }, + "published_at": { + "type": "null" + }, + "citation": { + "type": "null" + } + } + } + }, + { + "if": { + "properties": { + "environment": { + "const": "sandbox" + } + } + }, + "then": { + "properties": { + "test_record": { + "const": true + }, + "reserved_doi": { + "anyOf": [ + { + "type": "string", + "pattern": "^10\\.5072/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "doi": { + "anyOf": [ + { + "type": "string", + "pattern": "^10\\.5072/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + } + } + }, + "else": { + "properties": { + "test_record": { + "const": false + }, + "reserved_doi": { + "anyOf": [ + { + "type": "string", + "pattern": "^10\\.5281/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + }, + "doi": { + "anyOf": [ + { + "type": "string", + "pattern": "^10\\.5281/zenodo\\.[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + } + } + } + } + ], + "$defs": { + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "description", + "creators", + "license" + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 250 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 10000 + }, + "creators": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 250 + }, + "affiliation": { + "type": "string", + "maxLength": 500 + }, + "orcid": { + "type": "string", + "pattern": "^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$" + } + } + } + }, + "license": { + "enum": [ + "CC-BY-4.0", + "CC-BY-SA-4.0", + "CC0-1.0" + ] + }, + "publication_date": { + "type": "string", + "format": "date", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "github_release_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://github\\.com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/tag/[A-Za-z0-9_.~+/-]+$" + }, + { + "type": "null" + } + ] + }, + "github_project_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://github\\.com/(users|orgs)/[A-Za-z0-9][A-Za-z0-9-]*/projects/[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + } + } + }, + "binding": { + "type": "object", + "additionalProperties": false, + "required": [ + "config_id", + "config_hash", + "config_file_sha256", + "dangerous", + "result_id", + "result_hash", + "result_file_sha256", + "kind" + ], + "properties": { + "config_id": { + "type": "string", + "format": "uuid" + }, + "config_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "config_file_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "dangerous": { + "type": "boolean" + }, + "result_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "result_hash": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "result_file_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "kind": { + "enum": [ + "configuration", + "analysis_result" + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "configuration" + } + } + }, + "then": { + "properties": { + "result_id": { + "type": "null" + }, + "result_hash": { + "type": "null" + }, + "result_file_sha256": { + "type": "null" + } + } + }, + "else": { + "properties": { + "result_id": { + "type": "string", + "format": "uuid" + }, + "result_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "result_file_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + } + } + } + } + ] + }, + "attestation": { + "type": "object", + "additionalProperties": false, + "required": [ + "config_id", + "config_hash", + "config_file_sha256", + "dangerous", + "result_id", + "result_hash", + "result_file_sha256", + "kind", + "schema_version", + "publication_id", + "environment", + "deposition_id", + "doi", + "state", + "github_release_url", + "github_project_url" + ], + "properties": { + "config_id": { + "type": "string", + "format": "uuid" + }, + "config_hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "config_file_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "dangerous": { + "type": "boolean" + }, + "result_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "result_hash": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "result_file_sha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + { + "type": "null" + } + ] + }, + "kind": { + "enum": [ + "configuration", + "analysis_result" + ] + }, + "schema_version": { + "const": "1.0.0" + }, + "publication_id": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "environment": { + "enum": [ + "sandbox", + "production" + ] + }, + "deposition_id": { + "type": "string", + "pattern": "^[1-9][0-9]{0,17}$" + }, + "doi": { + "type": "string", + "pattern": "^10\\.(5072|5281)/zenodo\\.[1-9][0-9]*$" + }, + "state": { + "const": "reserved" + }, + "github_release_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://github\\.com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/tag/[A-Za-z0-9_.~+/-]+$" + }, + { + "type": "null" + } + ] + }, + "github_project_url": { + "anyOf": [ + { + "type": "string", + "pattern": "^https://github\\.com/(users|orgs)/[A-Za-z0-9][A-Za-z0-9-]*/projects/[1-9][0-9]*$" + }, + { + "type": "null" + } + ] + } + } + }, + "prepare_request": { + "type": "object", + "additionalProperties": false, + "required": [ + "config_id", + "result_id", + "metadata", + "acknowledge_upload" + ], + "properties": { + "config_id": { + "type": "string", + "format": "uuid" + }, + "result_id": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ] + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "acknowledge_upload": { + "const": true + } + } + }, + "publish_request": { + "type": "object", + "additionalProperties": false, + "required": [ + "confirmation", + "bundle_sha256", + "acknowledge_public" + ], + "properties": { + "confirmation": { + "type": "string", + "pattern": "^PUBLISH (sandbox|production) [1-9][0-9]*$" + }, + "bundle_sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "acknowledge_public": { + "const": true + } + } + } + } +} diff --git a/config/templates/doi_publication_chora.deed b/config/templates/doi_publication_chora.deed new file mode 100644 index 00000000..e2483fef --- /dev/null +++ b/config/templates/doi_publication_chora.deed @@ -0,0 +1,26 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; Reserved DOI attestation template. Generated alongside publication.json/.ncl. +;; No token, browser confirmation or post-publication archive hash belongs here. +;; Optional JSON nulls are represented by empty strings in this DEED projection. +;; The actual publication receipt is an external, immutable JSON sidecar, avoiding +;; a circular hash and preserving the original scientific config/result bytes. + +(repo-deed + :schema-version "1.0.0" + :canonical-name "doi-publication-template" + (doi-publication + :publication-id "<64 lowercase hex characters>" + :environment "sandbox" + :state "reserved" + :deposition-id "" + :doi "" + :kind "configuration" + :config-id "" + :config-hash "" + :config-file-sha256 "" + :result-id "" + :result-hash "" + :result-file-sha256 "" + :dangerous #f + :github-release-url "" + :github-project-url "")) diff --git a/docs/doi-publication.md b/docs/doi-publication.md new file mode 100644 index 00000000..c97906fe --- /dev/null +++ b/docs/doi-publication.md @@ -0,0 +1,183 @@ + +# Publish an analysis DOI with Zenodo + +Issue [#8](https://github.com/hyperpolymath/MetaManifold-WebUI/issues/8). +**Opt-in, single-user feature. Publication is irreversible.** The implementation +is reviewable; see [verification status](testing/doi-publication.md) before +production enablement. No live record is created by the test suite. + +## What is published + +A new, frozen ZIP contains the exact saved AnalysisConfig, an **explicitly chosen** +completed result (or an explicitly labelled configuration-only payload), original +provenance, scientific DANGER warnings, SHA-256 checksums, DataCite/Zenodo metadata, +CITATION.cff/text, and JSON/Nickel/DEED publication attestations. It does **not** +include raw sequencing inputs or reference databases. Authors must document how +those inputs can be obtained; a DOI does not make missing inputs reproducible. + +No scientific object is rewritten to insert a DOI. A separate, immutable +post-publication receipt binds the DOI to the config/result identities **and their +exact file hashes**, the uploaded ZIP hash, metadata and journal events. Its hash +is recorded in the private journal. This avoids a circular archive/receipt hash. + +The ZIP records a **reserved** identifier. Reservation is not registration, and +HTTP 202 is only acceptance. Only a verified published record gets a DOI badge. +Sandbox records remain visibly **TEST DOI**, never production citations. + +## Operator setup + +Requirements: Julia 1.12.5 and the repository dependencies, `zip`, a Unix filesystem +with working `flock`, atomic rename and `fsync` (native Linux/macOS or WSL2). Use a +local filesystem, not an untested shared/network mount. Native Windows is refused +rather than silently using an unsafe process-local lock. + +1. Start with a **Zenodo sandbox** account and a sandbox token with `deposit:write` + and `deposit:actions` scopes. Inject it as `ZENODO_SANDBOX_TOKEN` into the server + environment using your private secret manager. Do not put it in source, config + JSON, a URL, browser storage, a receipt, shell history, an issue, or chat. +2. Set these **non-secret** server options: + + ```sh + export METAMANIFOLD_ZENODO_ENABLED=true + export METAMANIFOLD_ZENODO_ENVIRONMENT=sandbox + # ZENODO_SANDBOX_TOKEN is supplied by your secret manager, not this command. + just start + ``` + +3. Enable production only after the acceptance checks below. Set the environment + to `production` and supply a **separate** `ZENODO_TOKEN`. No sandbox-token + fallback is used in production. Unset the enable flag to disable new actions; + saved receipts and downloads remain available. +4. Back up `projects//.analysis` and `projects//.doi` **together**, + including hidden files and filesystem permissions. The public `/files` route + refuses hidden path segments. Publication-aware study rename/delete and bound + config deletion return 409 rather than orphaning the journal. + +**Authentication boundary:** CSRF tokens, same-origin checks and JSON-only requests +are not authentication. This is a local single-user application. Do not expose it +as a public upload service. A network deployment requires an authenticated reverse +proxy, TLS, trusted filesystem access and operator-controlled credentials. For a +proxy with a different internal Host, set `METAMANIFOLD_PUBLIC_ORIGIN` to the exact +external origin, with no trailing slash. Normal same-origin preview/proxy hosts +work without wildcard CORS. The browser uses relative API paths, never a separate +localhost backend or a direct Zenodo request. + +Capabilities report **local configuration**, not proof that Zenodo has accepted the +token. Raw HTTP exceptions and remote error bodies are not returned or journalled. +The server's token is never added to a bundle; user-authored scientific metadata +can still contain sensitive content and must be reviewed by the author. + +## Author workflow + +1. Open a study's **Evidence / DOI publication** link. An AnalysisConfigEditor + supplied with a study context also offers a **Mint DOI** launcher. The current + React editor is not mounted in the application; the study link is the reachable + entry point. The publication page is Julia-rendered with a small DOM/fetch + adapter, consistent with the UI migration policy. +2. Enable **Evidence Mode**. Select an immutable saved config and choose a specific + completed result **or Configuration only**. There is no implicit “latest result”. + The web scaffold run endpoint is marked mock and cannot supply a publishable + result. The page can save an explicit config, but never invokes that scaffold. +3. Supply a real title, description/limitations, creators, date/version and a + licence you are entitled to grant. Supported licences are `CC-BY-4.0`, + `CC-BY-SA-4.0` and `CC0-1.0`. The API additionally supports creator affiliation + and checksum-validated ORCID. Placeholders such as “Anonymous” are rejected. +4. Optionally enter an existing GitHub release URL and Projects v2 URL. Links are + fixed to `github.com`; release tags use ordinary ASCII characters, not encoded + tags, queries, fragments or traversal segments. A project link alone does not + suffice for the release-linking helper: it also needs an existing release. +5. Acknowledge that **Prepare** sends the files off this machine as a private + Zenodo draft. It reserves a DOI and verifies the uploaded archive, but **does + not publish**. Retries/restarts reuse the same durable operation. +6. Download the exact ZIP. Check privacy, host/sample paths, consent, licence, + scientific warnings, selected result and missing raw inputs. Record its SHA-256. + Modifying metadata or files after preparation is refused, not silently accepted. +7. Click **Mint DOI…**, read the DANGER dialog, acknowledge the review, and type + exactly `PUBLISH `. Cancel, an incorrect phrase, a + missing acknowledgement or a stale/different ZIP hash cannot publish. +8. Wait for a verified badge. If Zenodo is still processing, **Refresh** the existing + deposition. Download the receipt and citation only after verification. + +Real Julia analyses can be enrolled explicitly with +`AnalysisStore.save_config!(store, config)` and +`AnalysisStore.save_result!(store, result)`, where `store` is the study's `.analysis` +directory. Results must reference that exact config ID, hash and method. Mock, +empty and failed/not-run results are refused. Publication never executes an +analysis or treats a mock result as scientific evidence. + +Context-help keys: `doi`, `doi.environment`, `doi.confirmation`, `doi.recovery`, +`doi.github` in `AnalysisConfig.context_help` and the existing help API. + +## Recovery — never mint a replacement to fix a timeout + +| Saved state / failure | Safe next action | +|---|---| +| `preparing` | Resume the same operation. No successful create has been recorded. | +| `creating` / `creation_uncertain` | Creation might have succeeded. Find the draft on Zenodo by the publication-ID marker in its notes and recover its numeric deposition ID. Recovery requires the matching, unsubmitted, empty draft. Never guess another ID or create a replacement. | +| `draft` | Resume against the same deposition. Idempotent upload retries reopen the exact frozen file from byte zero. | +| `ready` | Download/review, then use the separate confirmation dialog. Refresh does not publish. | +| `publishing` / `publication_uncertain` | Refresh/reconcile by GET. The application **will not replay** an uncertain publish POST. If Zenodo still shows a draft after review, finish that same deposition there manually, then refresh here. | +| `published` | Repeated confirmation/status reads do not create another deposition or DOI. Use the saved receipt. | +| 401/403 | Operator checks environment, ownership, token validity/scopes; do not paste credentials into the UI. | +| 429 | Honour Retry-After. Default policy allows four attempts and at most 60 seconds of retry sleep; excessive hints stop retries and are surfaced, not truncated to an unsafe wait. | +| Integrity/metadata mismatch | Stop. Restore exact local bytes from backup or inspect the existing remote deposition. Do not overwrite the frozen archive or bypass the mismatch by erasing the journal. | +| Local 409 busy | Another process owns the kernel lock. Wait, then reload. Process death releases the lock; no age-based lock stealing is used. | +| GitHub failure | Resume the linker with the same receipt; publication is already independent of GitHub. | + +A definite 429 can retry a POST. Disconnects, unexpected successes, 408 and 5xx on +non-idempotent create/publish calls are ambiguous and **never blindly replayed**. +Intents are fsynced before those requests. A false-negative/uncertain result is +preferable to duplicate irreversible publication. There is no automatic delete, +withdrawal, new-version publication or local-journal reset operation. + +## Link the published DOI to GitHub + +The web server does not hold GitHub credentials. On an operator-controlled Unix +machine with `gh` (2.32+ for Projects v2), `jq` and `flock`, use the downloaded **production** receipt: + +```sh +scripts/link-doi.sh --receipt /private/path/publication-ID.json # offline plan +scripts/link-doi.sh --receipt /private/path/publication-ID.json --apply # explicit writes +``` + +The helper uses existing `gh` authentication, requires an already-published +release, preserves notes outside its ID-marked block, uploads ID-named receipt/CFF +assets, and upserts one marked draft item on the optional Projects v2 board. It +never creates/publishes a GitHub release or talks to Zenodo. Sandbox/pending records +are refused. The production CFF is validated against the official 1.2.0 schema in +contract tests. Receipts are local attestations, **not cryptographic signatures**; +use the receipt downloaded from your trusted application, not an untrusted file. + +Release edits need repository write permission; Projects v2 updates need appropriate +project access (for classic OAuth/PAT authentication, the `project` scope). Consult +`gh auth status` privately. Do not paste its credentials into an issue. Local +replays are locked by receipt; GitHub does not supply a cross-machine notes-edit +transaction, so do not run independent release-note writers concurrently. A partial +failure can leave release notes/assets already updated. Rerun the **same receipt**; +incomplete project listings and duplicate/malformed markers stop rather than +risking an additional item or deleting other notes. + +## API and contract boundary + +- Capability/assets: `/api/v1/doi/capabilities`, `/api/v1/doi/assets/{name}`. +- UI: `/api/v1/studies/{study}/doi-ui?config={config-id}`. +- Study publications: `/api/v1/studies/{study}/doi-publications` (GET list, POST prepare). +- `/{id}` status; POST `/{id}/{resume,refresh,recover,publish}`; + GET `/{id}/download/{bundle,receipt,citation}`. +- Mutations require `application/json`, at most 64 KiB, the current `X-DOI-CSRF` + token and an accepted origin. Unknown fields (including tokens/API roots) fail. +- `config/schemas/doi_publication.schema.json` is the public status/receipt contract + with request/metadata/binding/reserved-attestation definitions. The Nickel contract + and DEED template cover the flat **reserved attestation**, not a second lifecycle + implementation. Optional JSON/Nickel null maps to an empty string in DEED. Julia + owns semantic validation, hashes, remote-state verification and confirmation. + +The compatibility target is explicitly **Zenodo deposition-v1**, recorded in +receipts. The documented endpoint is `/api/deposit/depositions`, not an invented +`/v1` URL. `application/json` intentionally selects the legacy deposition serializer; +`application/vnd.zenodo.v1+json` selects a different serializer and is **not** an +interchangeable “version pin”. Origins are fixed to production/sandbox, redirects +are refused, HTTP/JSON3 dependencies are pinned, and contract tests verify the +expected response shapes. Zenodo can still change its service: perform an explicit +sandbox acceptance check before production deployment. MD5 is used only for the +Zenodo bucket integrity protocol; provenance identity uses SHA-256. diff --git a/docs/testing/doi-publication.md b/docs/testing/doi-publication.md new file mode 100644 index 00000000..7f57c6c9 --- /dev/null +++ b/docs/testing/doi-publication.md @@ -0,0 +1,154 @@ + +# DOI publication — test plan and implementation evidence + +2026-09-26 · issue #8 · **implementation present; runtime acceptance blocked**. +This is the feature handoff/milestone report, not a claim of successful live minting. + +## Executable lanes + +From the repository root, with Julia 1.12.5, Bun 1.3.10, `zip`, `jq` and `flock`: + +```sh +julia --startup-file=no config/ci/lint_source.jl +julia --project=test/doi -e 'using Pkg; Pkg.instantiate()' +export DOI_CONTRACT_ARTIFACTS="$(mktemp -d)" +julia --project=test/doi test/doi/runtests.jl +bun install --cwd test/doi --frozen-lockfile --ignore-scripts +(cd test/doi && bunx playwright install --with-deps chromium) +bun run --cwd test/doi test +bun run --cwd test/doi test:browser +NICKEL=/path/to/nickel-1.18.0 bash test/doi/check-nickel.sh +``` + +The isolated Julia environment loads the **actual** Storage, Zenodo, Bundles, +Publications and Web modules, not a reimplementation. A synthetic transport +captures requests and injects failures; it cannot reach Zenodo. Its rendered HTML, +statuses, receipts, CFF and attestation projections feed the schema/browser/Nickel +lanes. A supplied artifact directory must contain outputs; the lanes fail rather +than silently skipping missing artifacts. `PLAYWRIGHT_CHROMIUM_EXECUTABLE` can +select an already-installed Chromium for the browser adapter tests. + +For linker/schema/performance-comparator tests only (no Julia artifacts): + +```sh +unset DOI_CONTRACT_ARTIFACTS +bun run --cwd test/doi test +``` + +These pure tests validate synthetic contracts and execute the actual Bash linker +against an isolated fake `gh`. This command alone does **not** validate Julia output. +The browser tests use an explicitly synthetic local HTTP API, even when supplied +with Julia-emitted HTML; server authorization is tested separately by Julia. + +With the full scientific/R environment instantiated, run: + +```sh +julia --project=. test/runtests.jl +``` + +That lane includes actual AnalysisConfig/result persistence and bundle generation, +plus Oxygen internal-request tests covering origin/CSRF/body restrictions, +prepare/publish/download, archive bytes, mutation protection and disabled credentials. +The isolated suite additionally exercises the real HTTP.jl streamed response writer +across a local socket. Neither is replaced by successful JavaScript tests. + +### Coverage map + +| Area | Contracts | +|---|---| +| Metadata & input | explicit creators/licence/date; ORCID checksum; exact config/result binding; mock/empty/failed rejection; allowlisted files; symlink and checksum refusal | +| Credentials | redacted client display/errors; fixed origins; no token in public state/files; redirect/bucket-origin refusal; no browser token or localStorage | +| Retry protocol | 429/Retry-After seconds/date/overflow; bounded retries; reopened upload stream; ambiguous create/publish responses not replayed | +| Durability | write-ahead transitions, immutable ZIP, restarted clients, lock exclusion, interrupted upload resume, creation recovery, accepted-but-pending publication, uncertain publication reconciliation | +| Provenance | exact archive SHA-256/MD5/size; remote metadata/files; separate receipt; corrupted receipt/citation refusal; unchanged scientific bytes; sandbox versus production | +| Browser | Evidence Mode, explicit payload, mock option disabled, narrow viewport, untrusted text rendered as text, cancel/wrong phrase/acknowledgement, one publish request, uncertainty recovery, disabled mode, distinct badges | +| GitHub helper | default offline dry run; valid production only; preserved notes; repeatable assets/item updates; partial failure; incomplete listing/malformed markers refusal; official CFF 1.2.0 validation | +| Formats | draft-2020-12 JSON validation and negative cases; actual-output roundtrips; Nickel contract evaluation/negative cases; reserved JSON/Nickel/DEED vocabulary and projection checks | + +The DEED projection is structurally checked against the emitted attestation; there +is no claim of a general CHORA/DEED engine evaluation. The Nickel CLI is pinned by +version **and SHA-256** in `.github/workflows/doi.yml`. The official CFF schema is +vendored with attribution to remove a network dependency from citation tests. + +## Performance and the >10% gate + +```sh +julia --project=test/doi bench/doi/benchmark.jl > /tmp/doi-current.json +bun bench/doi/compare.js /private/controlled-host-baseline.json /tmp/doi-current.json +``` + +The new warmed, credential-free microbenchmarks cover metadata, file checksums, +SHA-256 hashing, bounded downloads, prepared replay and published replay. Replays +must issue **zero network requests**, and an 8 MiB streamed download has a 2 MiB +allocation ceiling. Archive downloads use a 1 MiB buffer, not `read(archive)`. +Hash verification is intentionally O(archive size); creation also incurs ZIP, +checksum and durable-write costs. These are publication actions, not timed +scientific estimation. Benchmark numbers do not represent network latency or +scientific-method performance. + +The comparator fails on **>10% time or allocation growth**, mismatched runtime/CPU/ +OS/architecture/thread/sample/workload identity, missing cases or invalid numbers. +Its tests exercise both passing and failing paths. It never creates a baseline. +Preserve a reviewed first measurement from a controlled host; compare subsequent +changes on that same environment. A reviewed `bench/doi/baseline.json` enables the +comparison in the DOI workflow. Until one exists, CI emits a warning and retains +an **informational** measurement, not a fabricated regression verdict. + +**No DOI timing or allocation measurement has been obtained in this session.** +The first Julia benchmark baseline and confirmation of the requested regression +budget remain acceptance work. Existing frontend benchmark checksums pass, but +are not evidence of DOI backend performance. Existing scientific benchmarks remain +in the full CI lane; this feature does not relabel their informational timing as a +new performance guarantee. + +## Evidence available in this implementation session + +- Frontend typecheck and existing tests: **599 pass, 5 pre-existing TODO, 0 fail**; + 3,368 assertions; seven benchmark checksums passed. +- New offline linker, JSON Schema, official CFF and comparator tests: + **15 pass, 0 fail**, 121 assertions. The fake gh enforces the distinct `DI_` + draft-content ID (not the `PVTI_` project-item ID) required by the real CLI. +- Chromium browser adapter checks: **4 pass, 0 fail**, against a local synthetic API and an + **adapter-only HTML fixture derived from the Julia source template**. Julia was + not executed to produce that local fixture. CI instead requires the real Julia + renderer outputs. This proves adapter interaction behaviour, not Julia rendering + or end-to-end Zenodo compatibility. +- Tree-sitter: **124 tracked Julia sources, 0 syntax findings**. Shell/JavaScript + syntax, licence, whitespace and blob-hygiene gates pass. These provide static + evidence only. They do not prove Julia dispatch, package loading or runtime tests. +- Root/test manifests retain pinned versions; project hashes were calculated using + the Julia 1.12 Pkg algorithm and checked against the original root hash. The + isolated manifest is the dependency closure of the root. **Pkg instantiate/resolve + has not been run here.** A Julia-enabled runner must confirm both environments. + +### External execution blocker + +GitHub Actions run +[36207063201](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/runs/36207063201) +was rejected **before any jobs started** with: + +> Actor is not allowed to trigger Actions workflows. Workflow file: '.github/workflows/doi.yml'. + +There are no Julia job logs or passing checks from that run. The sandbox has no +Julia runtime, and allowed download attempts failed. Nickel release-binary access +also failed locally. An authorized repository actor must resolve the Actions +policy or run the documented lanes on a provisioned machine. Do not treat this as +a failing application test or work around it by weakening gates. + +## Production acceptance checklist + +- [ ] Run isolated Julia, real-output schema/Nickel/browser, and full-app/Oxygen tests. +- [ ] Instantiate the pinned root/isolated manifests without unexpected resolution. +- [ ] Record a real controlled-host DOI performance baseline; confirm memory limits + and the >10% comparator against later measurements. +- [ ] With operator-approved credentials, prepare a **sandbox** fixture, inspect the + exact ZIP and API response contract, explicitly confirm sandbox publication, + reconcile/restart, and verify the returned identifier. Confirm licence IDs + and remote metadata normalization against the current service. +- [ ] Review authentication, secret injection, privacy review and backup/restore. +- [ ] Only then enable production, with separate credentials and explicit human + confirmation for the intended real record. + +No real Zenodo upload/publication, DOI mint, release edit or project-item write was +performed for verification. The issue must not be closed on the strength of static +checks alone. diff --git a/frontend/src/components/AnalysisConfigEditor.tsx b/frontend/src/components/AnalysisConfigEditor.tsx index 4cb7393d..aec0e311 100644 --- a/frontend/src/components/AnalysisConfigEditor.tsx +++ b/frontend/src/components/AnalysisConfigEditor.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { useState } from 'react' +import { apiUrl } from '../api/client' import type { AnalysisConfig, ValidationError } from '../types/analysis_config' import { contextHelp, isDangerous, DANGER_ACK_TOKEN } from '../types/analysis_config' import { DangerBanner } from './DangerBanner' @@ -7,6 +8,8 @@ import { AdvancedAnalysisExpander } from './AdvancedAnalysisExpander' interface AnalysisConfigEditorProps { evidenceMode: boolean + /** Thin launch link: publication UI and lifecycle remain Julia-owned. */ + study?: string config: AnalysisConfig onChange: (config: AnalysisConfig) => void onSave: () => void @@ -14,7 +17,7 @@ interface AnalysisConfigEditorProps { validationErrors?: ValidationError[] } -export function AnalysisConfigEditor({ evidenceMode, config, onChange, onSave, availableMetadataColumns, validationErrors }: AnalysisConfigEditorProps) { +export function AnalysisConfigEditor({ evidenceMode, study, config, onChange, onSave, availableMetadataColumns, validationErrors }: AnalysisConfigEditorProps) { const [helpField, setHelpField] = useState(null) const [showJson, setShowJson] = useState(false) @@ -310,6 +313,15 @@ export function AnalysisConfigEditor({ evidenceMode, config, onChange, onSave, a DOI-ready: Bundle includes JSON + Nickel + DEED + DataCite + provenance. + {evidenceMode && study && ( +

+ + Mint DOI / view publication badge… + + {' '}Opens the Julia publication screen for a saved configuration. Preparing a draft does not publish it. +

+ )} + {/* Actions */}
+

+ Evidence & DOI publication + {' '}— review an immutable analysis bundle and publish it on Zenodo. +

+ {loading && } {error &&

{error}

} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 89efbfc4..542fdd3c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -29,12 +29,11 @@ "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true, "verbatimModuleSyntax": true, - "baseUrl": ".", "paths": { - "@api/*": ["src/api/*"], - "@components/*": ["src/components/*"], - "@views/*": ["src/views/*"], - "@hooks/*": ["src/hooks/*"] + "@api/*": ["./src/api/*"], + "@components/*": ["./src/components/*"], + "@views/*": ["./src/views/*"], + "@hooks/*": ["./src/hooks/*"] } }, "include": ["src"] diff --git a/scripts/link-doi.sh b/scripts/link-doi.sh new file mode 100755 index 00000000..4542e986 --- /dev/null +++ b/scripts/link-doi.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Link an already-published DOI to GitHub. This script NEVER talks to Zenodo or +# publishes a release. Dry-run by default; uses gh's existing authentication. +set -euo pipefail + +usage() { + printf '%s\n' 'Usage: scripts/link-doi.sh --receipt publication-ID.json [--apply]' \ + 'Without --apply: validate and print a plan, with no network calls.' \ + 'With --apply: update the existing release notes, upload citation/receipt' \ + 'assets, and create/update one marked draft item on the optional project.' +} +fail() { printf 'link-doi: %s\n' "$*" >&2; exit 1; } +receipt='' apply=false +while [[ $# -gt 0 ]]; do + case "$1" in + --receipt) [[ $# -ge 2 ]] || fail '--receipt needs a path'; receipt="$2"; shift 2 ;; + --apply) apply=true; shift ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; fail 'unknown argument' ;; + esac +done +[[ -f "$receipt" && ! -L "$receipt" ]] || fail 'provide a regular publication receipt file' +command -v jq >/dev/null || fail 'jq is required' +[[ $(wc -c < "$receipt") -le 1048576 ]] || fail 'receipt exceeds 1 MiB' +# Validate before invoking gh. Unknown/unpublished/sandbox receipts are never +# turned into a production citation, even when the caller uses --apply. +jq -e ' + .schema_version == "1.0.0" and .state == "published" and + .environment == "production" and .test_record == false and + (.id | type == "string" and test("^[0-9a-f]{64}$")) and + (.bundle_sha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.binding.config_hash | type == "string" and test("^[0-9a-f]{64}$")) and + (.doi | type == "string" and test("^10\\.5281/zenodo\\.[1-9][0-9]*$")) and + .reserved_doi == .doi and .doi_url == ("https://doi.org/" + .doi) and + (.metadata.title | type == "string" and length > 0 and length <= 250) and + (.binding.kind | . == "configuration" or . == "analysis_result") and + (.binding.dangerous | type == "boolean") and + (.metadata.creators | type == "array" and length > 0 and length <= 100 and + all(.[]; (.name | type == "string" and length > 0 and length <= 500) and + ((.orcid == null) or (.orcid | type == "string" and test("^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$"))))) and + (.metadata.publication_date | type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (.metadata.version | type == "string" and length > 0) and + (.metadata.license | . == "CC-BY-4.0" or . == "CC-BY-SA-4.0" or . == "CC0-1.0") and + (.metadata.github_release_url | type == "string" and + test("^https://github\\.com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/tag/[A-Za-z0-9_.~+/-]+$")) and + ((.metadata.github_project_url == null) or + (.metadata.github_project_url | type == "string" and + test("^https://github\\.com/(users|orgs)/[A-Za-z0-9][A-Za-z0-9-]*/projects/[1-9][0-9]*$"))) +' "$receipt" >/dev/null || fail 'receipt must describe a verified production publication with valid GitHub links' + +id=$(jq -r '.id' "$receipt") +doi=$(jq -r '.doi' "$receipt") +release=$(jq -r '.metadata.github_release_url' "$receipt") +project=$(jq -r '.metadata.github_project_url // empty' "$receipt") +relative=${release#https://github.com/} +repo=${relative%%/releases/tag/*} +tag=${relative#*/releases/tag/} +[[ "/$tag/" != *'/../'* && "/$tag/" != *'/./'* ]] || fail 'release tag contains a traversal segment' +marker="" +endmarker="" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +# Treat user-authored titles as text, not as release-note control markers. +title=$(jq -r '.metadata.title' "$receipt" | tr '\r\n' ' ' | sed 's/[][\\`*_<>#]/\\&/g') +{ + printf '%s\n\n' "$marker" + printf '### MetaManifold analysis citation\n\n%s\n\n' "$title" + printf '**DOI:** [%s](https://doi.org/%s)\n\n' "$doi" "$doi" + printf '**Payload:** %s\n\n' "$(jq -r 'if .binding.kind == "configuration" then "Configuration only — no analysis results" else "Configuration and selected analysis result" end' "$receipt")" + printf '**Archive SHA-256:** `%s`\n\n' "$(jq -r '.bundle_sha256' "$receipt")" + printf '**Config SHA-256:** `%s`\n\n' "$(jq -r '.binding.config_hash' "$receipt")" + if jq -e '.binding.dangerous == true' "$receipt" >/dev/null; then + printf '**DANGER:** Scientific overrides are present. Preserve and disclose the archived warning.\n\n' + fi + printf 'See the attached `publication-%s.json` receipt and `citation-%s.cff`.\n\n' "$id" "$id" + printf '%s\n' "$endmarker" +} > "$tmp/block.md" + +printf 'Release: %s\nDOI: https://doi.org/%s\n' "$release" "$doi" +[[ -z "$project" ]] || printf 'Project: %s (one marked draft item)\n' "$project" +if ! $apply; then + printf '\nDRY RUN — no GitHub writes or network calls. Proposed managed block:\n\n' + cat "$tmp/block.md" + printf '\nReview the receipt and rerun with --apply to link it. No DOI will be minted.\n' + exit 0 +fi +command -v gh >/dev/null || fail 'GitHub CLI (gh) is required for --apply' +command -v flock >/dev/null || fail 'flock is required to serialize local linking of this receipt' +if [[ -n "$project" ]]; then + gh project --help >/dev/null 2>&1 || fail 'Projects v2 linking requires a gh version with project commands (2.32 or newer)' +fi +# The receipt is an immutable operator-owned file. Multiple --apply invocations +# for it must not race each other. Do not run independent release-note writers +# concurrently; GitHub release edits do not provide a cross-machine transaction. +exec 9< "$receipt" +flock -n 9 || fail 'another linker is using this receipt' + +gh_safe() { + if ! gh "$@" 2> "$tmp/gh-error"; then + fail 'GitHub operation failed. Check the GitHub connection/permissions and retry this same receipt; do not mint another DOI. No credentials or raw errors are printed.' + fi +} + +gh_safe release view --repo "$repo" --json body,url,isDraft -- "$tag" > "$tmp/release.json" +jq -e --arg url "$release" '.url == $url and .isDraft == false' "$tmp/release.json" >/dev/null || fail 'release must already exist and be published at the exact recorded URL' +jq -r '.body // ""' "$tmp/release.json" > "$tmp/old-notes.md" +starts=$(grep -Fxc "$marker" "$tmp/old-notes.md" || true) +ends=$(grep -Fxc "$endmarker" "$tmp/old-notes.md" || true) +[[ "$starts" == "$ends" && ( "$starts" == 0 || "$starts" == 1 ) ]] || fail 'release notes contain ambiguous managed markers; repair them manually' +awk -v start="$marker" -v end="$endmarker" -v block="$tmp/block.md" ' + function emit( line) { while ((getline line < block) > 0) print line; close(block) } + $0 == start { inside=1; seen=1; emit(); next } + $0 == end { if (!inside) exit 2; inside=0; next } + !inside { print } + END { if (inside) exit 2; if (!seen) { print ""; emit() } } +' "$tmp/old-notes.md" > "$tmp/notes.md" || fail 'managed release-note block is malformed' +# Skip the PATCH on an exact replay, preserving unrelated notes. +if ! cmp -s "$tmp/old-notes.md" "$tmp/notes.md"; then + gh_safe release edit --repo "$repo" --notes-file "$tmp/notes.md" -- "$tag" >/dev/null +fi +cp "$receipt" "$tmp/publication-$id.json" +jq -r ' + "cff-version: 1.2.0\nmessage: \"Please cite this published analysis dataset.\"\ntype: dataset\n" + + "title: " + (.metadata.title|tojson) + "\n" + + "doi: " + (.doi|tojson) + "\nversion: " + (.metadata.version|tojson) + "\n" + + "date-released: " + (.metadata.publication_date|tojson) + "\n" + + "license: " + (.metadata.license|tojson) + "\nauthors:\n" + + ([.metadata.creators[] | " - name: " + (.name|tojson) + + (if .orcid then "\n orcid: " + (("https://orcid.org/" + .orcid)|tojson) else "" end)] | join("\n")) +' "$receipt" > "$tmp/citation-$id.cff" +gh_safe release upload --repo "$repo" --clobber -- "$tag" "$tmp/publication-$id.json" "$tmp/citation-$id.cff" >/dev/null + +if [[ -n "$project" ]]; then + owner=$(printf '%s' "$project" | cut -d/ -f5) + number=${project##*/} + gh_safe project item-list "$number" --owner "$owner" --limit 10000 --format json > "$tmp/items.json" + jq -e '(.items | type == "array") and (.totalCount | type == "number" and . >= 0 and . == floor) and (.totalCount == (.items|length))' "$tmp/items.json" >/dev/null || + fail 'project listing was incomplete; refusing to create a potentially duplicate item' + jq --arg marker "$marker" '[.items[] | select((.body // .content.body // "") | contains($marker))]' "$tmp/items.json" > "$tmp/matches.json" + matches=$(jq 'length' "$tmp/matches.json") + [[ "$matches" -le 1 ]] || fail 'project has duplicate publication markers; reconcile them manually' + { cat "$tmp/block.md"; printf '\nGitHub release: %s\n' "$release"; } > "$tmp/project-body.md" + if [[ "$matches" == 0 ]]; then + gh_safe project item-create "$number" --owner "$owner" --title "MetaManifold DOI: $doi" --body "$(cat "$tmp/project-body.md")" --format json >/dev/null + else + # Editing a draft uses its DI_ content ID, NOT the PVTI_ project item ID. + item=$(jq -r '.[0] | select(.content.type == "DraftIssue") | .content.id // empty' "$tmp/matches.json") + [[ "$item" =~ ^DI_[A-Za-z0-9_-]+$ ]] || fail 'marked project item must be a draft issue with a valid content identifier' + gh_safe project item-edit --id "$item" --title "MetaManifold DOI: $doi" --body "$(cat "$tmp/project-body.md")" >/dev/null + fi +fi +printf 'Linked existing DOI %s to the release%s. No DOI or release was created.\n' "$doi" "${project:+ and project}" diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl index e8c4b006..afbd8fff 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -27,6 +27,13 @@ include("pipeline/merge_taxa.jl") include("pipeline/dada2.jl") include("pipeline/swarm.jl") +# DOI infrastructure is independent of the scientific/R runtime. +include("doi/Storage.jl") +include("doi/Zenodo.jl") +include("doi/Bundles.jl") +include("doi/Publications.jl") +include("doi/Web.jl") + # Analysis include("analysis/numeric_policy.jl") # Exact summaries are catalogue item 1 and are built on the numeric policy, so they follow it. @@ -34,6 +41,7 @@ include("analysis/exact_summaries.jl") include("analysis/diversity.jl") include("analysis/analysis.jl") include("analysis/AnalysisConfig.jl") +include("doi/AnalysisStore.jl") include("analysis/clade_cumulus.jl") # Estimation fits what AnalysisConfig declares and Execution runs it, so it sits between them. include("analysis/estimation.jl") diff --git a/src/analysis/AnalysisConfig.jl b/src/analysis/AnalysisConfig.jl index 852d3293..133bce01 100644 --- a/src/analysis/AnalysisConfig.jl +++ b/src/analysis/AnalysisConfig.jl @@ -32,6 +32,7 @@ using UUIDs using JSON3 using OrderedCollections using Logging +using ..DOIBundles: write_checksums! # Re-use provenance from core using ..Provenance: CapturedEnvironment, probe_metamanifold, probe_host @@ -818,6 +819,37 @@ return a fallback message that includes the requested path. """ function context_help(field_path::String) help_db = Dict{String,String}( + "doi" => """ + DOI publication is opt-in and behind Evidence Mode. Prepare a private Zenodo draft, + download and review its frozen ZIP, then separately confirm irreversible publication. + Configuration-only bundles are explicitly labelled; mock/empty results are not citable results. + A reserved DOI or HTTP 202 is not publication. Only a verified published record has a badge. + See docs/doi-publication.md for setup, privacy review and recovery. + """, + "doi.environment" => """ + Sandbox is the default test environment (10.5072); production (10.5281) registers permanent DOIs. + The operator sets METAMANIFOLD_ZENODO_ENABLED and METAMANIFOLD_ZENODO_ENVIRONMENT, + with a separate server-side ZENODO_SANDBOX_TOKEN or ZENODO_TOKEN. Never enter tokens in the browser. + CSRF protection is not authentication: use a local single-user server or an authenticated reverse proxy. + """, + "doi.confirmation" => """ + DANGER: publication makes the entire reviewed archive public and the DOI cannot be unminted. + Check privacy, creator consent, licence, provenance paths, results and scientific override warnings. + Acknowledge this review, type PUBLISH followed by the environment and deposition ID, + and submit the exact reviewed bundle SHA-256. Preparing/uploading is not this confirmation. + """, + "doi.recovery" => """ + Reload saved publications after a restart or lost response. Resume draft preparation against + the same journal. If creation is uncertain, find the marked draft on Zenodo and recover its ID. + If publication is uncertain, refresh the existing deposition; publication is never blindly retried. + Back up .analysis and .doi together. Never delete a journal or mint a replacement to fix a timeout. + """, + "doi.github" => """ + Download the verified production receipt and run scripts/link-doi.sh --receipt PATH for + an offline dry run. Explicit --apply updates the existing release and optional Projects v2 item + using gh authentication. No GitHub credential enters the web server and no second DOI is minted. + A partial linking failure is safe to resume with the same receipt; check the GitHub connection. + """, "method" => """ Analysis Method (required, explicit, no auto-selection) — v1: NB GLM, CLR/ILR+Gaussian, logistic @@ -1548,13 +1580,19 @@ end """ create_doi_bundle(config, [result]; output_dir, authors, title, license, description) -Create or update `output_dir` with DataCite metadata, JSON, Nickel, DEED, -provenance, and content-hash files, then return the directory path. An optional -result is embedded in the DataCite document. Dangerous configurations also write -`DANGER_BANNER.txt` and emit a warning; existing files with the same names are -overwritten. +Create a fresh `output_dir` with DataCite metadata, JSON, Nickel, DEED, +provenance, and per-file SHA-256 checksums, then return the directory path. +A supplied result must belong to the exact config. Non-empty destinations are +refused: stale results or DANGER banners must never enter a new publication. +Dangerous configurations also write `DANGER_BANNER.txt` and emit a warning. """ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult,Nothing}=nothing; output_dir::String="doi_bundle_$(config.id)", authors::Vector{String}=String[], title::String="MetaManifold Analysis Bundle", license::String="CC-BY-4.0", description::String="Differential abundance analysis") + islink(output_dir) && throw(ArgumentError("DOI bundle directory must not be a symlink")) + isdir(output_dir) && !isempty(readdir(output_dir)) && throw(ArgumentError("DOI bundle requires an empty destination")) + if !isnothing(result) + result.config_id == config.id && result.config_hash == config.hash && result.method == config.method || + throw(ArgumentError("DOI bundle result must belong to the exact configuration")) + end mkpath(output_dir) # DataCite JSON @@ -1566,7 +1604,7 @@ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult, "descriptions" => [OrderedDict("description" => description, "descriptionType" => "Abstract")], "publicationYear" => year(config.created_at), "publisher" => "MetaManifold-WebUI", - "resourceType" => OrderedDict("resourceTypeGeneral" => "Dataset", "resourceType" => "AnalysisConfig"), + "types" => OrderedDict("resourceTypeGeneral" => "Dataset", "resourceType" => isnothing(result) ? "Analysis configuration (no results)" : "Analysis results"), "subjects" => [ OrderedDict("subject" => "microbiome"), OrderedDict("subject" => "differential abundance"), @@ -1577,8 +1615,8 @@ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult, "version" => config.schema_version, "rightsList" => [OrderedDict("rights" => license)], "dates" => [OrderedDict("date" => string(config.created_at), "dateType" => "Created")], - "relatedIdentifiers" => [ - OrderedDict("relatedIdentifier" => config.hash, "relatedIdentifierType" => "SHA256", "relationType" => "IsIdenticalTo"), + "alternateIdentifiers" => [ + OrderedDict("alternateIdentifier" => config.hash, "alternateIdentifierType" => "SHA-256"), ], "schemaVersion" => "http://datacite.org/schema/kernel-4", "config" => JSON3.read(to_json(config)), @@ -1601,9 +1639,8 @@ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult, "method" => METHOD_TO_STRING[result.method], "results" => result.results ) - datacite["relatedIdentifiers"] = vcat(datacite["relatedIdentifiers"], [ - OrderedDict("relatedIdentifier" => result.hash, "relatedIdentifierType" => "SHA256", "relationType" => "HasPart") - ]) + push!(datacite["alternateIdentifiers"], + OrderedDict("alternateIdentifier" => result.hash, "alternateIdentifierType" => "SHA-256")) end # Write files @@ -1675,9 +1712,10 @@ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult, | `analysis_config.json` | Machine-readable analysis configuration | | `analysis_config.ncl` | Nickel serialisation of the configuration | | `analysis_config_chora.deed` | DEED attestation of the configuration | - | `analysis_result.json` | The analysis result this bundle was minted for | + | `analysis_result.json` | Selected result, only when explicitly included | | `provenance.json` | Captured software and host environment | | `content_hash.txt` | Content hash of the configuration | + | `checksums.sha256` | SHA-256 of every payload file | ## Authors @@ -1685,6 +1723,10 @@ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult, ## Reproducibility + **Payload:** $(isnothing(result) ? "Configuration only — no analysis results are included." : "Configuration and the explicitly selected result $(result.id).") + No DOI has been minted by this local export. Publication is a separate, + explicitly confirmed operation. Raw inputs and reference databases are not included. + The configuration is immutable and content-hashed. Re-running the analysis requires the same MetaManifold version and database snapshot recorded in `provenance.json`. @@ -1695,6 +1737,8 @@ function create_doi_bundle(config::AnalysisConfig, result::Union{AnalysisResult, write(io, config.hash) end + write_checksums!(output_dir) + @info "Created DOI-ready bundle" output_dir config_id=config.id hash=config.hash dangerous=config.dangerous return output_dir diff --git a/src/doi/AnalysisStore.jl b/src/doi/AnalysisStore.jl new file mode 100644 index 00000000..3047222d --- /dev/null +++ b/src/doi/AnalysisStore.jl @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: MPL-2.0 +# Persistence required by the DOI workflow: a backend restart must not erase the +# config/result named by a reviewed draft. The publication journal keeps its own +# immutable snapshots as well, so it never follows a moving "latest result". +module AnalysisStore + +using JSON3, Dates, UUIDs, OrderedCollections +using ..DOIStorage +import ..AnalysisConfig + +export configs, results, save_config!, save_result!, delete_config!, result_json + +function _record_path(root, kind, id) + try UUID(id) catch; throw(PublicationError(400, "invalid_id", "Analysis ID must be a UUID.")) end + joinpath(root, kind, id * ".json") +end + +function configs(root) + directory = joinpath(root, "configs") + (islink(root) || islink(directory)) && throw(PublicationError(409, "unsafe_storage", "Stored analysis must not be a symlink.")) + output = Dict{String,AnalysisConfig.AnalysisConfigStruct}() + isdir(directory) || return output + for name in readdir(directory) + endswith(name, ".json") || continue + path = joinpath(directory, name) + islink(path) && throw(PublicationError(409, "unsafe_storage", "Stored analysis must not be a symlink.")) + text = read(path, String) + cfg = AnalysisConfig.from_json(text) + # from_json supports caller-supplied hashes for legacy uses; storage must + # not trust one. Reconstruct without it and compare the computed hash. + data = JSON3.read(text, Dict{String,Any}) + data["hash"] = nothing + AnalysisConfig.from_json(JSON3.write(data)).hash == cfg.hash || + throw(PublicationError(409, "config_hash_mismatch", "Stored analysis configuration failed its content-hash check.")) + name == cfg.id * ".json" || throw(PublicationError(409, "config_id_mismatch", "Stored configuration ID differs from its filename.")) + output[cfg.id] = cfg + end + return output +end + +function result_json(result::AnalysisConfig.AnalysisResult) + JSON3.write(OrderedDict{String,Any}("id" => result.id, "config_id" => result.config_id, + "config_hash" => result.config_hash, "created_at" => string(result.created_at), + "method" => AnalysisConfig.METHOD_TO_STRING[result.method], "results" => result.results, + "provenance" => result.provenance, "hash" => result.hash)) +end + +function results(root) + directory = joinpath(root, "results") + (islink(root) || islink(directory)) && throw(PublicationError(409, "unsafe_storage", "Stored analysis must not be a symlink.")) + output = Dict{String,AnalysisConfig.AnalysisResult}() + isdir(directory) || return output + for name in readdir(directory) + endswith(name, ".json") || continue + path = joinpath(directory, name) + islink(path) && throw(PublicationError(409, "unsafe_storage", "Stored result must not be a symlink.")) + data = JSON3.read(read(path, String)) + method = AnalysisConfig.METHOD_STRINGS[String(data.method)] + # Keep result entry ordering: the existing scientific hash format is + # order-sensitive. Do not canonical-sort the original scientific object. + result = AnalysisConfig.AnalysisResult(id=String(data.id), config_id=String(data.config_id), + config_hash=String(data.config_hash), created_at=DateTime(data.created_at), method=method, + results=OrderedDict{String,Any}(String(k) => v for (k, v) in data.results), + provenance=OrderedDict{String,Any}(String(k) => v for (k, v) in data.provenance)) + result.hash == data.hash && name == result.id * ".json" || + throw(PublicationError(409, "result_hash_mismatch", "Stored analysis result failed its content-hash check.")) + output[result.id] = result + end + return output +end + +function _save(root, kind, id, text) + with_publication_lock(root) do + path = _record_path(root, kind, id) + islink(path) && throw(PublicationError(409, "unsafe_storage", "Stored analysis must not be a symlink.")) + if isfile(path) + read(path, String) == text || throw(PublicationError(409, "immutable_analysis", "An immutable analysis ID already exists with different content.")) + else + atomic_write(path, text) + end + return id + end +end +save_config!(root, cfg::AnalysisConfig.AnalysisConfigStruct) = _save(root, "configs", cfg.id, AnalysisConfig.to_json(cfg)) +save_result!(root, result::AnalysisConfig.AnalysisResult) = _save(root, "results", result.id, result_json(result)) + +function delete_config!(root, id) + with_publication_lock(root) do + path = _record_path(root, "configs", id) + isfile(path) || throw(PublicationError(404, "config_not_found", "Analysis configuration not found.")) + rm(path) + end +end + +end # module AnalysisStore diff --git a/src/doi/Bundles.jl b/src/doi/Bundles.jl new file mode 100644 index 00000000..6119c40b --- /dev/null +++ b/src/doi/Bundles.jl @@ -0,0 +1,268 @@ +# SPDX-License-Identifier: MPL-2.0 +module DOIBundles + +using JSON3, SHA, MD5, Dates, UUIDs +using ..DOIStorage: PublicationError, canonical_json, file_sha256, atomic_write + +export validate_metadata, snapshot, write_checksums!, verify_checksums, archive_bundle, + decorate_bundle!, zenodo_metadata, file_md5, citation_cff, citation_text, + BUNDLE_FILES, LICENSES + +const LICENSES = Dict("CC-BY-4.0" => "cc-by-4.0", "CC-BY-SA-4.0" => "cc-by-sa-4.0", "CC0-1.0" => "cc0-1.0") +const BASE_FILES = Set(["analysis_config.json", "analysis_config.ncl", "analysis_config_chora.deed", + "datacite.json", "provenance.json", "content_hash.txt", "README.md"]) +const BUNDLE_FILES = union(BASE_FILES, Set(["analysis_result.json", "DANGER_BANNER.txt", "checksums.sha256"])) +const DECORATED_FILES = union(BUNDLE_FILES, Set(["publication.json", "publication.ncl", "publication_chora.deed", "CITATION.cff", "CITATION.txt", "zenodo.json"])) +fail(message) = throw(PublicationError(422, "invalid_publication", message)) +file_md5(path::AbstractString) = open(io -> bytes2hex(md5(io)), path) + +function _text(value, field; max_length=500, optional=false) + value isa AbstractString || fail("$field must be text.") + text = strip(String(value)) + (!optional && isempty(text)) && fail("$field must not be empty.") + length(text) <= max_length || fail("$field is too long.") + any(c -> iscntrl(c) && c != '\n' && c != '\t', text) && fail("$field contains control characters.") + return text +end + +function _github_url(value, field, kind) + text = _text(value, field; max_length=2048, optional=true) + isempty(text) && return nothing + pattern = kind == :release ? r"^https://github\.com/[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+/releases/tag/[A-Za-z0-9_.~+/-]+$" : + r"^https://github\.com/(users|orgs)/[A-Za-z0-9][A-Za-z0-9-]*/projects/[1-9][0-9]*$" + occursin(pattern, text) || fail("$field must be a public GitHub $(kind == :release ? "release tag" : "Projects v2") URL without credentials, query, or fragment.") + # Refuse URL parser normalisation and malformed percent escapes. + any(s -> s in (".", ".."), split(text, '/')) && fail("$field contains a traversal segment.") + occursin(r"%(?![0-9A-Fa-f]{2})", text) && fail("$field contains an invalid escape.") + occursin(r"(?i)%2e|%2f|%5c|%0[0-9a-f]|%1[0-9a-f]|%7f", text) && fail("$field contains an unsafe encoded path.") + return text +end + +function _orcid(value) + text = _text(value, "creator.orcid"; max_length=19) + occursin(r"^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$", text) || fail("ORCID must use the 0000-0000-0000-000X format.") + digits = replace(text, "-" => "") + total = 0 + for d in digits[1:15] + total = (total + (Int(d) - Int('0'))) * 2 + end + check = (12 - total % 11) % 11 + string(last(digits)) == (check == 10 ? "X" : string(check)) || fail("ORCID checksum is invalid.") + return text +end + +"""Validate an explicit publication request, independently of analysis-schema defaults.""" +function validate_metadata(input::AbstractDict; today=Date(now(UTC))) + allowed = Set(["title", "description", "creators", "license", "publication_date", "version", "github_release_url", "github_project_url"]) + all(k -> k in allowed, keys(input)) || fail("Unknown publication metadata field. Tokens, API URLs and DOI overrides are never accepted.") + title = _text(get(input, "title", nothing), "title"; max_length=250) + description = _text(get(input, "description", nothing), "description"; max_length=10000) + creators = get(input, "creators", nothing) + creators isa AbstractVector && 1 <= length(creators) <= 100 || fail("Provide 1–100 named creators.") + names = Dict{String,String}[] + for creator in creators + creator isa AbstractDict || fail("Each creator must be an object.") + all(k -> k in ("name", "affiliation", "orcid"), keys(creator)) || fail("Unknown creator field.") + name = _text(get(creator, "name", nothing), "creator.name"; max_length=250) + lowercase(name) in ("anonymous", "unknown", "test", "your name") && fail("Replace placeholder creators with the actual authors before publication.") + record = Dict("name" => name) + if haskey(creator, "affiliation") + record["affiliation"] = _text(creator["affiliation"], "creator.affiliation"; optional=true) + end + haskey(creator, "orcid") && (record["orcid"] = _orcid(creator["orcid"])) + push!(names, record) + end + license = get(input, "license", nothing) + license isa AbstractString && haskey(LICENSES, license) || fail("Choose an explicit supported licence: CC-BY-4.0, CC-BY-SA-4.0 or CC0-1.0.") + date_string = _text(get(input, "publication_date", string(today)), "publication_date"; max_length=10) + date = try Date(date_string, dateformat"yyyy-mm-dd") catch; fail("publication_date must be YYYY-MM-DD.") end + string(date) == date_string && Date(1900) <= date <= today || fail("publication_date must be a real date between 1900 and today.") + version = _text(get(input, "version", "1.0.0"), "version"; max_length=100) + result = Dict{String,Any}("title" => title, "description" => description, "creators" => names, + "license" => String(license), "publication_date" => date_string, "version" => version) + for (field, kind) in (("github_release_url", :release), ("github_project_url", :project)) + value = get(input, field, nothing) + result[field] = isnothing(value) ? nothing : _github_url(value, field, kind) + end + return result +end + +function _regular_files(directory, allowed) + isdir(directory) && !islink(directory) || fail("Bundle must be a regular directory.") + names = readdir(directory) + all(name -> name in allowed && isfile(joinpath(directory, name)) && !islink(joinpath(directory, name)), names) || + fail("Bundle contains an unexpected entry, directory or symlink. Only the documented bundle files may be published.") + return names +end + +"""Read and bind the exact config/result bytes; refuse scaffolds and mismatched results.""" +function snapshot(directory::String) + names = _regular_files(directory, BUNDLE_FILES) + issubset(BASE_FILES, Set(names)) || fail("Bundle is missing required config, provenance or metadata files.") + isfile(joinpath(directory, "checksums.sha256")) && verify_checksums(directory) + config_text = read(joinpath(directory, "analysis_config.json"), String) + cfg = try JSON3.read(config_text, Dict{String,Any}) catch; fail("Invalid analysis_config.json.") end + try UUID(cfg["id"]) catch; fail("Bundle config ID must be a UUID.") end + hash = get(cfg, "hash", "") + hash isa AbstractString && occursin(r"^[0-9a-f]{64}$", hash) || fail("Bundle config hash must be SHA-256.") + strip(read(joinpath(directory, "content_hash.txt"), String)) == hash || fail("Bundle content_hash.txt does not match its configuration.") + cfg["dangerous"] isa Bool || fail("Bundle dangerous flag must be boolean.") + cfg["dangerous"] && !("DANGER_BANNER.txt" in names) && fail("A dangerous configuration must retain its DANGER banner.") + result = nothing + result_sha = nothing + if "analysis_result.json" in names + text = read(joinpath(directory, "analysis_result.json"), String) + result = try JSON3.read(text, Dict{String,Any}) catch; fail("Invalid analysis_result.json.") end + get(result, "config_id", nothing) == cfg["id"] && get(result, "config_hash", nothing) == hash && get(result, "method", nothing) == cfg["method"] || + fail("Result does not belong to this exact analysis configuration.") + try UUID(result["id"]) catch; fail("Result ID must be a UUID.") end + rhash = get(result, "hash", "") + rhash isa AbstractString && occursin(r"^[0-9a-f]{64}$", rhash) || fail("Result hash must be SHA-256.") + payload = get(result, "results", nothing) + payload isa AbstractDict && !isempty(payload) || fail("Empty/scaffold results cannot be published. Select a configuration-only bundle explicitly instead.") + prov = get(result, "provenance", Dict()) + get(prov, "mock", false) === true && fail("Mock results cannot be published.") + startswith(lowercase(string(get(prov, "note", ""))), "mock result") && fail("Mock results cannot be published.") + estimation = get(prov, "estimation", Dict()) + get(estimation, "status", "") in ("not_run", "failed") && fail("An analysis which did not run cannot be published as a result.") + result_sha = bytes2hex(sha256(text)) + end + return Dict{String,Any}("config_id" => cfg["id"], "config_hash" => hash, + "config_file_sha256" => bytes2hex(sha256(config_text)), "dangerous" => cfg["dangerous"], + "result_id" => isnothing(result) ? nothing : result["id"], + "result_hash" => isnothing(result) ? nothing : result["hash"], "result_file_sha256" => result_sha, + "kind" => isnothing(result) ? "configuration" : "analysis_result") +end + +function write_checksums!(directory::String) + names = sort!(_regular_files(directory, DECORATED_FILES)) + text = join((file_sha256(joinpath(directory, name)) * " " * name * "\n" for name in names if name != "checksums.sha256")) + write(joinpath(directory, "checksums.sha256"), text) + return file_sha256(joinpath(directory, "checksums.sha256")) +end + +function verify_checksums(directory::String) + names = _regular_files(directory, DECORATED_FILES) + path = joinpath(directory, "checksums.sha256") + isfile(path) || fail("Bundle checksum manifest is missing.") + seen = Set{String}() + for line in eachline(path) + m = match(r"^([0-9a-f]{64}) ([A-Za-z0-9_.-]+)$", line) + !isnothing(m) || fail("Invalid bundle checksum manifest.") + hash, name = m.captures + name in names && name != "checksums.sha256" && !(name in seen) || fail("Checksum manifest contains an unexpected or repeated file.") + file_sha256(joinpath(directory, name)) == hash || fail("Bundle integrity check failed; a file changed after preparation.") + push!(seen, name) + end + seen == setdiff(Set(names), Set(["checksums.sha256"])) || fail("Bundle checksum manifest does not cover every file.") + return true +end + +function archive_bundle(directory::String, destination::String) + verify_checksums(directory) + isnothing(Sys.which("zip")) && throw(PublicationError(503, "zip_unavailable", "The server needs the zip executable to create a DOI bundle.")) + ispath(destination) && fail("Refusing to overwrite an existing publication archive.") + # Relative, sorted members; never archive absolute temporary paths or stale files. + files = sort!(readdir(directory)) + try + run(Cmd(`zip -q -X $(abspath(destination)) $files`; dir=directory)) + catch + isfile(destination) && rm(destination) + throw(PublicationError(500, "archive_failed", "Could not create the DOI archive. No upload was attempted.")) + end + return destination +end + +_html(text) = replace(text, '&' => "&", '<' => "<", '>' => ">", '"' => """, '\'' => "'") + +function zenodo_metadata(metadata, binding, publication_id) + kind = binding["kind"] == "configuration" ? "Configuration only; no analysis results are included." : "Analysis configuration, selected results and provenance." + warning = binding["dangerous"] ? " DANGER: scientific overrides are present; see DANGER_BANNER.txt and disclose them when citing." : "" + related = Dict{String,String}[] + for (field, relation) in (("github_release_url", "isSupplementTo"), ("github_project_url", "references")) + isnothing(metadata[field]) || push!(related, Dict("identifier" => metadata[field], "relation" => relation)) + end + Dict{String,Any}("title" => metadata["title"], "upload_type" => "dataset", + "description" => "

" * _html(metadata["description"]) * "

" * kind * warning * "

", + "creators" => metadata["creators"], "access_right" => "open", "license" => LICENSES[metadata["license"]], + "publication_date" => metadata["publication_date"], "version" => metadata["version"], + "prereserve_doi" => true, "related_identifiers" => related, + "notes" => "MetaManifold publication " * publication_id * "; config SHA-256 " * binding["config_hash"], + "keywords" => ["MetaManifold", "reproducibility", binding["kind"]]) +end + +function citation_text(metadata, doi) + join((c["name"] for c in metadata["creators"]), "; ") * " (" * metadata["publication_date"][1:4] * "). " * + metadata["title"] * " (" * metadata["version"] * ") [Data set]. Zenodo. https://doi.org/" * doi +end + +function citation_cff(metadata, doi) + io = IOBuffer() + println(io, "cff-version: 1.2.0\nmessage: \"Cite the published Zenodo record; reserved and sandbox DOIs are not production citations.\"\ntype: dataset") + for (key, value) in (("title", metadata["title"]), ("doi", doi), ("version", metadata["version"]), + ("date-released", metadata["publication_date"]), ("license", metadata["license"])) + println(io, key, ": ", JSON3.write(value)) # JSON strings are valid quoted YAML scalars. + end + println(io, "authors:") + for creator in metadata["creators"] + println(io, " - name: ", JSON3.write(creator["name"])) + haskey(creator, "orcid") && println(io, " orcid: ", JSON3.write("https://orcid.org/" * creator["orcid"])) + end + return String(take!(io)) +end + +function decorate_bundle!(directory, metadata, binding, publication_id, environment, deposit_id, doi) + # Publication is an external attestation, not an edit to the hashed config/result. + publication = merge(copy(binding), Dict("schema_version" => "1.0.0", "publication_id" => publication_id, + "environment" => environment, "deposition_id" => deposit_id, "doi" => doi, "state" => "reserved", + "github_release_url" => metadata["github_release_url"], "github_project_url" => metadata["github_project_url"])) + write(joinpath(directory, "publication.json"), canonical_json(publication)) + # Flat attestation values are controlled identifiers/URLs, never executable + # user-authored Nickel expressions. Missing optional fields are explicit null. + open(joinpath(directory, "publication.ncl"), "w") do io + println(io, "# SPDX-License-Identifier: MPL-2.0\n# Reserved DOI attestation; see doi_publication.ncl contract.\n{") + for key in sort!(collect(keys(publication))) + println(io, " ", key, " = ", JSON3.write(publication[key]), ",") + end + println(io, "}") + end + open(joinpath(directory, "publication_chora.deed"), "w") do io + println(io, ";; SPDX-License-Identifier: MPL-2.0\n(repo-deed\n :schema-version \"1.0.0\"") + println(io, " :canonical-name ", JSON3.write("doi-publication-" * publication_id)) + println(io, " (doi-publication") + for key in sort!(collect(setdiff(keys(publication), ["schema_version"]))) + value = publication[key] + rendered = value isa Bool ? (value ? "#t" : "#f") : JSON3.write(isnothing(value) ? "" : value) + println(io, " :", replace(key, '_' => '-'), " ", rendered) + end + println(io, " ))") + end + write(joinpath(directory, "CITATION.cff"), citation_cff(metadata, doi)) + write(joinpath(directory, "CITATION.txt"), citation_text(metadata, doi) * "\n") + write(joinpath(directory, "zenodo.json"), canonical_json(zenodo_metadata(metadata, binding, publication_id))) + # DataCite metadata uses its own schema, NOT the deposition request schema. + related = [Dict("relatedIdentifier" => metadata[f], "relatedIdentifierType" => "URL", "relationType" => relation) + for (f, relation) in (("github_release_url", "IsSupplementTo"), ("github_project_url", "References")) if !isnothing(metadata[f])] + datacite = Dict("identifiers" => [Dict("identifier" => doi, "identifierType" => "DOI")], + "creators" => [Dict("name" => c["name"]) for c in metadata["creators"]], + "titles" => [Dict("title" => metadata["title"])], "publisher" => "Zenodo", + "publicationYear" => parse(Int, metadata["publication_date"][1:4]), + "types" => Dict("resourceTypeGeneral" => "Dataset", "resourceType" => binding["kind"]), + "descriptions" => [Dict("description" => metadata["description"], "descriptionType" => "Abstract")], + "rightsList" => [Dict("rights" => metadata["license"], "rightsIdentifier" => metadata["license"], "rightsIdentifierScheme" => "SPDX")], + "relatedIdentifiers" => related, "version" => metadata["version"], + "alternateIdentifiers" => [Dict("alternateIdentifier" => binding["config_hash"], "alternateIdentifierType" => "SHA-256")]) + write(joinpath(directory, "datacite.json"), canonical_json(datacite)) + open(joinpath(directory, "README.md"), "a") do io + println(io, "\n## Zenodo publication\n\nEnvironment: ", environment, ". Kind: ", binding["kind"], ".") + println(io, "\nReserved DOI: ", doi, ". It is registered only after publication. Sandbox records are test records.") + println(io, "\n", citation_text(metadata, doi)) + println(io, "\n`publication.json` binds the DOI to the original config/result hashes without changing them.") + println(io, "`checksums.sha256` covers every payload file. The separate post-publication receipt records the archive hash (avoiding a circular hash).") + println(io, "Raw inputs and reference databases are not included; their availability must be described by the authors.") + end + write_checksums!(directory) + return directory +end + +end # module DOIBundles diff --git a/src/doi/Publications.jl b/src/doi/Publications.jl new file mode 100644 index 00000000..e0309dec --- /dev/null +++ b/src/doi/Publications.jl @@ -0,0 +1,399 @@ +# SPDX-License-Identifier: MPL-2.0 +# A durable, recoverable two-phase publication journal. No tokens in state or bundles. +module DOIPublications + +using JSON3, SHA, Dates +using ..DOIStorage +using ..DOIBundles +import ..Zenodo + +export prepare!, resume!, publish!, refresh!, recover_creation!, status, publications, + publication_path, confirmation_phrase, receipt, download_path + +const SCHEMA_VERSION = "1.0.0" +const MAX_ARCHIVE_BYTES = 50_000_000_000 +const STATES = Set(["preparing", "creating", "creation_uncertain", "draft", "ready", "publishing", "publication_uncertain", "published"]) + +function publication_path(root::AbstractString, id::AbstractString) + occursin(r"^[0-9a-f]{64}$", id) || throw(PublicationError(404, "publication_not_found", "Publication not found.")) + joinpath(root, id) +end +_state_path(root, id) = joinpath(publication_path(root, id), "state.json") +_archive_name(id) = "metamanifold-" * id * ".zip" +_archive(root, s) = joinpath(publication_path(root, s["id"]), _archive_name(s["id"])) + +function _read(root, id) + path = _state_path(root, id) + islink(root) && throw(PublicationError(409, "unsafe_storage", "Publication storage must not be a symlink.")) + islink(publication_path(root, id)) && throw(PublicationError(409, "unsafe_storage", "Publication storage must not be a symlink.")) + isfile(path) && !islink(path) || throw(PublicationError(404, "publication_not_found", "Publication not found.")) + s = try read_json(path) catch; throw(PublicationError(409, "corrupt_publication", "Publication state cannot be read. Restore its journal from backup; do not create a replacement.")) end + get(s, "schema_version", nothing) == SCHEMA_VERSION && get(s, "id", nothing) == id && get(s, "state", "") in STATES || + throw(PublicationError(409, "corrupt_publication", "Unsupported or inconsistent publication journal.")) + return s +end + +function _save(root, s, event) + s["updated_at"] = string(now(UTC)) * "Z" + push!(s["events"], Dict("at" => s["updated_at"], "event" => event, "state" => s["state"])) + atomic_json(_state_path(root, s["id"]), s) + return s +end + +function _client_matches(s, client) + s["environment"] == client.environment || throw(PublicationError(409, "environment_mismatch", "This publication belongs to a different Zenodo environment. Ask the operator to select its original environment.")) +end + +confirmation_phrase(s) = "PUBLISH " * s["environment"] * " " * something(s["deposition_id"], "unprepared") + +function _public(s) + # Deliberate allowlist: internal paths, raw remote responses and credentials + # cannot accidentally become a future public API field. + fields = ("schema_version", "id", "state", "environment", "binding", "metadata", "deposition_id", + "reserved_doi", "doi", "record_url", "bundle_sha256", "bundle_md5", "bundle_size", + "created_at", "updated_at", "published_at", "last_error") + result = Dict{String,Any}(key => get(s, key, nothing) for key in fields) + result["confirmation_phrase"] = confirmation_phrase(s) + result["citation"] = s["state"] == "published" ? citation_text(s["metadata"], s["doi"]) : nothing + result["doi_url"] = s["state"] == "published" ? "https://doi.org/" * s["doi"] : nothing + result["draft_url"] = isnothing(s["deposition_id"]) ? nothing : Zenodo.ORIGINS[s["environment"]] * "/deposit/" * s["deposition_id"] + result["test_record"] = s["environment"] == "sandbox" + return result +end +status(root, id) = _public(_read(root, id)) + +function publications(root) + islink(root) && throw(PublicationError(409, "unsafe_storage", "Publication storage must not be a symlink.")) + isdir(root) || return Dict{String,Any}[] + [_public(_read(root, id)) for id in sort!(readdir(root)) if occursin(r"^[0-9a-f]{64}$", id) && isfile(_state_path(root, id))] +end + +function _local_integrity(root, s) + archive = _archive(root, s) + isfile(archive) && !islink(archive) && file_sha256(archive) == s["bundle_sha256"] && filesize(archive) == s["bundle_size"] || + throw(PublicationError(409, "bundle_changed", "The frozen archive is missing or changed. Restore its exact bytes; do not publish a replacement under this confirmation.")) + return archive +end + +# Zenodo adds fields to creators/related identifiers; compare the fields we sent, +# not whole response dictionaries. Arrays remain order-sensitive for authorship. +_subset(expected::AbstractDict, actual::AbstractDict) = all(haskey(actual, k) && _subset(v, actual[k]) for (k, v) in expected) +_subset(expected::AbstractVector, actual::AbstractVector) = length(expected) == length(actual) && all(_subset(a, b) for (a, b) in zip(expected, actual)) +_subset(expected, actual) = expected == actual + +function _remote_metadata(s, deposit, client) + Zenodo.checked_id(get(deposit, "id", nothing)) == s["deposition_id"] || + throw(PublicationError(409, "deposition_mismatch", "Zenodo returned a different deposition. Refusing to continue.")) + expected = zenodo_metadata(s["metadata"], s["binding"], s["id"]) + delete!(expected, "prereserve_doi") + actual = get(deposit, "metadata", nothing) + actual isa AbstractDict && _subset(expected, actual) || + throw(PublicationError(409, "remote_metadata_changed", "Zenodo metadata differs from the frozen publication request. Review the existing deposition; this operation will not overwrite it or issue another publish request.")) + doi = get(deposit, "submitted", false) === true ? + Zenodo.checked_doi(client, get(deposit, "doi", get(actual, "doi", nothing))) : Zenodo.reserved_doi(client, deposit) + endswith(doi, "." * s["deposition_id"]) || throw(PublicationError(409, "deposition_mismatch", "Reserved DOI is not bound to the expected deposition ID.")) + if !isnothing(s["reserved_doi"]) + doi == s["reserved_doi"] || throw(PublicationError(409, "doi_changed", "The Zenodo DOI differs from the reserved identifier.")) + end + return doi +end + +function _file_matches(file, s) + file isa AbstractDict || return false + name = get(file, "filename", get(file, "name", get(file, "key", nothing))) + name == _archive_name(s["id"]) || return false + checksum = get(file, "checksum", nothing) + checksum isa AbstractString || return false + replace(lowercase(checksum), r"^md5:" => "") == s["bundle_md5"] || return false + rawsize = get(file, "filesize", get(file, "size", nothing)) + size = rawsize isa AbstractString ? tryparse(Int, rawsize) : rawsize + size isa Integer && !(size isa Bool) && size == s["bundle_size"] +end + +function _remote_files(s, deposit) + files = get(deposit, "files", nothing) + files isa AbstractVector && length(files) == 1 && _file_matches(only(files), s) || + throw(PublicationError(409, "remote_files_changed", "Zenodo does not contain exactly the reviewed archive with its matching size and MD5 checksum. Publication cannot be verified. Review the existing deposition; do not create a replacement.")) + return true +end + +function _make_archive!(root, s) + directory = publication_path(root, s["id"]) + archive = _archive(root, s) + if !isnothing(s["bundle_sha256"]) + _local_integrity(root, s) + return + end + payload = joinpath(directory, "payload") + # Before an archive hash is journalled no upload can have started. Interrupted + # local builds may be restarted, but a journalled archive is never regenerated. + isdir(payload) && rm(payload; recursive=true) + isfile(archive) && rm(archive) + cp(joinpath(directory, "source"), payload) + decorate_bundle!(payload, s["metadata"], s["binding"], s["id"], s["environment"], s["deposition_id"], s["reserved_doi"]) + archive_bundle(payload, archive) + chmod(archive, 0o600) + filesize(archive) <= MAX_ARCHIVE_BYTES || throw(PublicationError(422, "bundle_too_large", "The archive exceeds Zenodo's 50 GB record limit.")) + s["bundle_sha256"] = file_sha256(archive) + s["bundle_md5"] = file_md5(archive) # Protocol integrity only; SHA-256 is the provenance identity. + s["bundle_size"] = filesize(archive) + _save(root, s, "archive_frozen") +end + +function _remember_error!(root, s, e) + # Only our sanitised exceptions may reach the journal. Never persist raw HTTP + # errors, arbitrary exception strings, remote metadata, or token-bearing URLs. + s["last_error"] = e isa Zenodo.RemoteError || e isa PublicationError ? Dict("code" => e.code, "message" => e.message) : + Dict("code" => "publication_failed", "message" => "Publication operation failed locally. Review the journal and retry the same operation.") + _save(root, s, "operation_failed") +end + +function _finish!(root, s, deposit, client) + _remote_metadata(s, deposit, client) + _remote_files(s, deposit) + _local_integrity(root, s) + get(deposit, "submitted", false) === true && get(deposit, "state", "") == "done" || return false + doi = Zenodo.checked_doi(client, get(deposit, "doi", get(deposit["metadata"], "doi", nothing))) + doi == s["reserved_doi"] || throw(PublicationError(409, "doi_changed", "Published DOI differs from the reviewed DOI.")) + s["doi"] = doi + s["last_error"] = nothing + # Do not trust remote URLs (or render javascript: links). Construct known origins. + s["record_url"] = Zenodo.origin(client) * "/records/" * Zenodo.checked_id(get(deposit, "record_id", s["deposition_id"])) + receipt_path = joinpath(publication_path(root, s["id"]), "publication-receipt.json") + islink(receipt_path) && throw(PublicationError(409, "unsafe_storage", "Publication receipt must not be a symlink.")) + if isfile(receipt_path) + previous = read_json(receipt_path) + fields = ("schema_version", "id", "environment", "binding", "metadata", "deposition_id", "reserved_doi", "doi", "bundle_sha256", "bundle_md5", "bundle_size") + get(previous, "state", nothing) == "published" && get(previous, "api_version", nothing) == Zenodo.API_VERSION && + all(get(previous, key, nothing) == s[key] for key in fields) || + throw(PublicationError(409, "receipt_conflict", "An immutable publication receipt already exists with different content.")) + s["published_at"] = previous["published_at"] + else + s["published_at"] = string(now(UTC)) * "Z" + # The sidecar is written BEFORE the terminal journal state. A crash here + # is recoverable by GET/reconciliation, without a second publish POST. + value = _public(merge(copy(s), Dict("state" => "published"))) + value["api_version"] = Zenodo.API_VERSION + value["events"] = vcat(s["events"], [Dict("at" => s["published_at"], "event" => "publication_verified", "state" => "published")]) + atomic_json(receipt_path, value) + end + s["receipt_sha256"] = file_sha256(receipt_path) + s["state"] = "published" + s["last_error"] = nothing + _save(root, s, "publication_verified") + return true +end + +function _prepare_locked!(root, s, client) + _client_matches(s, client) + if s["state"] in ("ready", "published", "publishing", "publication_uncertain") + _local_integrity(root, s) + return _public(s) + end + if s["state"] in ("creating", "creation_uncertain") + throw(PublicationError(409, "creation_uncertain", "A draft creation may already have succeeded. Find its deposition ID on Zenodo and use recovery; creation will not be replayed.")) + end + try + if isnothing(s["deposition_id"]) + s["state"] = "creating" + _save(root, s, "creation_started") # write-ahead, before the non-idempotent POST + deposit = try + Zenodo.create_deposition(client, zenodo_metadata(s["metadata"], s["binding"], s["id"])) + catch e + s["state"] = e isa Zenodo.RemoteError && !e.ambiguous ? "preparing" : "creation_uncertain" + rethrow() + end + # If the response is malformed, creation remains uncertain, never retried. + s["deposition_id"] = Zenodo.checked_id(get(deposit, "id", nothing)) + s["state"] = "draft" + _save(root, s, "deposition_created") + end + deposit = Zenodo.get_deposition(client, s["deposition_id"]) + doi = _remote_metadata(s, deposit, client) + s["reserved_doi"] = doi + _save(root, s, "doi_reserved") + _make_archive!(root, s) + if get(deposit, "submitted", false) === true + _finish!(root, s, deposit, client) || throw(PublicationError(409, "zenodo_pending", "Zenodo is still processing this deposition. Refresh its status.")) + return _public(s) + end + files = get(deposit, "files", nothing) + files isa AbstractVector || throw(PublicationError(502, "invalid_files", "Zenodo returned an invalid file list.")) + # No extra files can hitch a ride into a publication. An interrupted PUT + # to our one filename is safe to repeat with the same frozen bytes. + all(f -> get(f, "filename", get(f, "name", get(f, "key", nothing))) == _archive_name(s["id"]), files) || + throw(PublicationError(409, "unexpected_remote_files", "The draft contains unexpected files. Review it on Zenodo before continuing.")) + if !(length(files) == 1 && _file_matches(only(files), s)) + _save(root, s, "upload_started") + uploaded = Zenodo.upload_file(client, deposit, _local_integrity(root, s), _archive_name(s["id"])) + _file_matches(uploaded, s) || throw(PublicationError(502, "upload_integrity_failed", "Zenodo's uploaded file checksum or size does not match the archive. Publishing is blocked.")) + end + verified = Zenodo.get_deposition(client, s["deposition_id"]) + _remote_metadata(s, verified, client) + _remote_files(s, verified) + get(verified, "submitted", false) === true && throw(PublicationError(409, "remote_state_changed", "The draft was submitted outside this operation. Refresh to reconcile it.")) + s["state"] = "ready" + s["last_error"] = nothing + _save(root, s, "draft_verified") + return _public(s) + catch e + s["state"] == "creating" && (s["state"] = "creation_uncertain") + _remember_error!(root, s, e) + rethrow() + end +end + +"""Freeze a local bundle, create a draft, reserve its DOI and verify its upload. Never publish.""" +function prepare!(root::String, bundle::String, input::AbstractDict, client::Zenodo.Client) + metadata = validate_metadata(input) + binding = snapshot(bundle) # all local validation BEFORE network side effects + isnothing(Sys.which("zip")) && throw(PublicationError(503, "zip_unavailable", "Install zip before preparing a Zenodo draft.")) + sum(filesize(joinpath(bundle, f)) for f in readdir(bundle)) <= MAX_ARCHIVE_BYTES || + throw(PublicationError(422, "bundle_too_large", "Bundle exceeds the supported 50 GB limit.")) + private_dir(root) + id = bytes2hex(sha256(canonical_json(Dict("environment" => client.environment, "binding" => binding)))) + directory = publication_path(root, id) + return with_publication_lock(directory) do + if isfile(_state_path(root, id)) + s = _read(root, id) + canonical_json(s["metadata"]) == canonical_json(metadata) || + throw(PublicationError(409, "metadata_frozen", "This exact config/result already has a publication with different metadata. Resume that record; do not mint a duplicate.")) + else + source = joinpath(directory, "source") + isdir(source) && rm(source; recursive=true) + cp(bundle, source) + # Verify the copied snapshot too: a caller must not race the freeze. + snapshot(source) == binding || throw(PublicationError(409, "bundle_changed", "The source bundle changed while it was being frozen.")) + timestamp = string(now(UTC)) * "Z" + s = Dict{String,Any}("schema_version" => SCHEMA_VERSION, "id" => id, "state" => "preparing", + "environment" => client.environment, "binding" => binding, "metadata" => metadata, + "deposition_id" => nothing, "reserved_doi" => nothing, "doi" => nothing, "record_url" => nothing, + "bundle_sha256" => nothing, "bundle_md5" => nothing, "bundle_size" => nothing, + "created_at" => timestamp, "updated_at" => timestamp, "published_at" => nothing, + "last_error" => nothing, "events" => Any[]) + _save(root, s, "bundle_snapshot_saved") + end + return _prepare_locked!(root, s, client) + end +end + +function resume!(root, id, client) + with_publication_lock(publication_path(root, id)) do + _prepare_locked!(root, _read(root, id), client) + end +end + +function recover_creation!(root, id, deposition_id, client) + with_publication_lock(publication_path(root, id)) do + s = _read(root, id) + _client_matches(s, client) + s["state"] in ("creating", "creation_uncertain") && isnothing(s["deposition_id"]) || + throw(PublicationError(409, "recovery_not_needed", "Only an uncertain draft creation can be attached to an existing deposition.")) + candidate = Zenodo.checked_id(deposition_id) + deposit = Zenodo.get_deposition(client, candidate) + # Validate the publication-specific metadata marker BEFORE persisting any ID. + proposed = merge(copy(s), Dict("deposition_id" => candidate)) + _remote_metadata(proposed, deposit, client) + isempty(get(deposit, "files", [])) && get(deposit, "submitted", false) === false || + throw(PublicationError(409, "unsafe_recovery", "Creation recovery requires the matching unsubmitted, empty draft.")) + s["deposition_id"] = candidate + s["state"] = "draft" + _save(root, s, "creation_recovered") + _prepare_locked!(root, s, client) + end +end + +function refresh!(root, id, client) + with_publication_lock(publication_path(root, id)) do + s = _read(root, id) + _client_matches(s, client) + s["state"] == "published" && return _public(s) + isnothing(s["deposition_id"]) && return _public(s) + try + deposit = Zenodo.get_deposition(client, s["deposition_id"]) + _remote_metadata(s, deposit, client) + if !isnothing(s["bundle_sha256"]) + _remote_files(s, deposit) + _finish!(root, s, deposit, client) + end + return _public(s) + catch e + _remember_error!(root, s, e) + rethrow() + end + end +end + +"""Publish once, only after confirmation of the exact environment, ID and archive SHA-256.""" +function publish!(root, id, client; confirmation, bundle_sha256, acknowledge_public=false) + with_publication_lock(publication_path(root, id)) do + s = _read(root, id) + _client_matches(s, client) + # Even a repeat of a published operation must name the reviewed artifact. + confirmation isa AbstractString && confirmation == confirmation_phrase(s) && + bundle_sha256 isa AbstractString && bundle_sha256 == s["bundle_sha256"] && acknowledge_public === true || + throw(PublicationError(422, "confirmation_required", "Publishing is irreversible and makes every file public. Confirm the exact environment, deposition ID and archive hash, and acknowledge the privacy/licensing review.")) + s["state"] == "published" && return _public(s) + s["state"] == "ready" || throw(PublicationError(409, "publication_not_ready", "Only a verified draft may be published. Pending or uncertain submissions must be reconciled, never replayed.")) + try + _local_integrity(root, s) + deposit = Zenodo.get_deposition(client, s["deposition_id"]) + _remote_metadata(s, deposit, client) + _remote_files(s, deposit) + if get(deposit, "submitted", false) === true + _finish!(root, s, deposit, client) + return _public(s) + end + s["state"] = "publishing" + s["last_error"] = nothing + s["confirmation"] = Dict("phrase" => confirmation, "bundle_sha256" => bundle_sha256, "acknowledge_public" => true) + _save(root, s, "publication_confirmed") # write-ahead BEFORE the irreversible POST + response = try + Zenodo.publish_deposition(client, s["deposition_id"]) + catch e + s["state"] = e isa Zenodo.RemoteError && !e.ambiguous ? "ready" : "publication_uncertain" + rethrow() + end + # 202 is acceptance, NOT proof of a published record. A subsequent GET + # (possibly a later refresh after restart) must report submitted/done. + Zenodo.checked_id(get(response, "id", nothing)) == s["deposition_id"] || + throw(PublicationError(502, "deposition_mismatch", "Publish response did not identify the expected deposition. Refresh to reconcile.")) + _save(root, s, "publication_accepted") + verified = Zenodo.get_deposition(client, s["deposition_id"]) + _finish!(root, s, verified, client) + return _public(s) + catch e + s["state"] == "publishing" && (s["state"] = "publication_uncertain") + _remember_error!(root, s, e) + rethrow() + end + end +end + +function receipt(root, id) + s = _read(root, id) + s["state"] == "published" || throw(PublicationError(409, "not_published", "There is no published receipt yet.")) + path = joinpath(publication_path(root, id), "publication-receipt.json") + isfile(path) && !islink(path) && file_sha256(path) == get(s, "receipt_sha256", nothing) || + throw(PublicationError(409, "receipt_changed", "The immutable publication receipt is missing or changed. Restore the journal and receipt from backup.")) + return read_json(path) +end + +function download_path(root, id, kind) + s = _read(root, id) + if kind == "bundle" + return _local_integrity(root, s), "application/zip", _archive_name(id) + elseif kind == "receipt" + receipt(root, id) # enforce published state + return joinpath(publication_path(root, id), "publication-receipt.json"), "application/json", "publication-" * id * ".json" + elseif kind == "citation" + s["state"] == "published" || throw(PublicationError(409, "not_published", "Cite only a published record, not a reserved DOI.")) + path = joinpath(publication_path(root, id), "payload", "CITATION.cff") + isfile(path) && !islink(path) && read(path, String) == citation_cff(s["metadata"], s["doi"]) || + throw(PublicationError(409, "citation_changed", "The local citation file differs from the published metadata.")) + return path, "text/yaml", "citation-" * id * ".cff" + end + throw(PublicationError(404, "file_not_found", "Unknown publication download.")) +end + +end # module DOIPublications diff --git a/src/doi/Storage.jl b/src/doi/Storage.jl new file mode 100644 index 00000000..78651f8a --- /dev/null +++ b/src/doi/Storage.jl @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: MPL-2.0 +# Publication state is separate from immutable scientific objects. Never store secrets here. +module DOIStorage + +using JSON3, SHA, OrderedCollections + +export PublicationError, atomic_write, atomic_json, read_json, canonical_json, + with_publication_lock, file_sha256, private_dir, FileBody + +struct PublicationError <: Exception + status::Int + code::String + message::String +end +Base.showerror(io::IO, e::PublicationError) = print(io, e.message) + +canonical(x::AbstractDict) = OrderedDict(String(k) => canonical(x[k]) for k in sort!(collect(keys(x)); by=string)) +canonical(x::AbstractVector) = canonical.(x) +canonical(x) = x +canonical_json(x) = JSON3.write(canonical(x)) +file_sha256(path::AbstractString) = open(io -> bytes2hex(sha256(io)), path) +read_json(path::AbstractString) = JSON3.read(read(path, String), Dict{String,Any}) + + +# Lazy HTTP response body: open only while writing, close on success/disconnect, +# and use bounded memory even for large publication archives. +struct FileBody + path::String +end +Base.length(body::FileBody) = filesize(body.path) +function Base.write(destination::IO, body::FileBody) + open(body.path, "r") do source + buffer = Vector{UInt8}(undef, 1024 * 1024) + total = 0 + while !eof(source) + count = readbytes!(source, buffer) + count == 0 && break + total += write(destination, view(buffer, 1:count)) + end + return total + end +end + +function private_dir(path::AbstractString) + islink(path) && throw(PublicationError(409, "unsafe_storage", "Publication storage must not be a symlink.")) + mkpath(path; mode=0o700) + chmod(path, 0o700) + return path +end + +# Supported deployment targets are native Unix and WSL2. Kernel locks are released +# on process death: unlike age-based lockfiles they cannot expire during a slow upload. +# Do not silently substitute a process-local mutex on unsupported platforms. +function with_publication_lock(f::Function, directory::AbstractString) + Sys.isunix() || throw(PublicationError(503, "unsupported_storage", "DOI publication requires a Unix filesystem with flock support (including WSL2).")) + private_dir(directory) + path = joinpath(directory, ".lock") + islink(path) && throw(PublicationError(409, "unsafe_storage", "Publication lock must not be a symlink.")) + open(path, "a+") do io + chmod(path, 0o600) + acquired = ccall(:flock, Cint, (Cint, Cint), fd(io), 2 | 4) == 0 # LOCK_EX | LOCK_NB + acquired || throw(PublicationError(409, "publication_busy", "Another publication operation is running. Refresh its status before retrying.")) + try + return f() + finally + ccall(:flock, Cint, (Cint, Cint), fd(io), 8) # LOCK_UN + end + end +end + +function atomic_write(path::AbstractString, content::AbstractString) + private_dir(dirname(path)) + islink(path) && throw(PublicationError(409, "unsafe_storage", "Publication files must not be symlinks.")) + tmp, io = mktemp(dirname(path); cleanup=false) + try + chmod(tmp, 0o600) + write(io, content) + flush(io) + if Sys.isunix() + rc = ccall(:fsync, Cint, (Cint,), fd(io)) + rc == 0 || error("Could not synchronise publication state") + end + close(io) + # rename, not rm + mv: readers must see either complete version, never a gap. + Base.Filesystem.rename(tmp, path) + if Sys.isunix() + dirfd = ccall(:open, Cint, (Cstring, Cint), dirname(path), 0) + dirfd >= 0 || error("Could not open publication state directory") + try + ccall(:fsync, Cint, (Cint,), dirfd) == 0 || error("Could not synchronise publication state directory") + finally + ccall(:close, Cint, (Cint,), dirfd) + end + end + finally + isopen(io) && close(io) + isfile(tmp) && rm(tmp) + end + return path +end +atomic_json(path::AbstractString, value) = atomic_write(path, canonical_json(value) * "\n") + +end # module DOIStorage diff --git a/src/doi/Web.jl b/src/doi/Web.jl new file mode 100644 index 00000000..da76cb8a --- /dev/null +++ b/src/doi/Web.jl @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: MPL-2.0 +# Julia-authored progressive-enhancement UI. The tiny JS adapter does transport +# and DOM updates only; scientific, metadata and lifecycle decisions stay in Julia. +module DOIWeb + +using JSON3, Dates + +export render_page, html_escape +html_escape(text) = replace(string(text), '&' => "&", '<' => "<", '>' => ">", '"' => """, '\'' => "'") + +function render_page(study, csrf, environment, enabled; selected_config="") + bootstrap = replace(JSON3.write(Dict("study" => study, "csrf" => csrf, "environment" => environment, + "enabled" => enabled, "selected_config" => selected_config)), '<' => "\\u003c", '>' => "\\u003e", '&' => "\\u0026") + example = JSON3.write(Dict("method" => "nb_glm", "formula" => "~ group", "metadata_columns" => ["group"], + "normalization" => Dict("method" => "size_factors"), "created_by" => "Replace with your name")) + return """ + + + + Publish an analysis · MetaManifold + + + + +
+ ← Back to studies +

METAMANIFOLD · EVIDENCE & REPRODUCIBILITY

+

Make an analysis citable

Study: $(html_escape(study))

+

Create a private Zenodo draft, review the exact archive, then explicitly publish its DOI.

+

$(environment == "production" ? "PRODUCTION — real, permanent DOI publication" : "SANDBOX — test records, not production citations")

+
+

Publication is disabled or no server token is configured. An operator must configure Zenodo on the server. Never paste a token into this page.

+
+ + + +

2. Review & publish

+

Only a published record has a DOI badge. A reserved identifier or accepted request is not proof of publication. Refreshing never retries publication.

+
Loading saved publications…
+
+ +
+ +

DANGER — irreversible public publication

+

Publishing registers a permanent DOI and makes every archived file public. Removing a local config will not retract the Zenodo record.

+


+  
+ + +
+
+
+ + +""" +end + +end # module DOIWeb diff --git a/src/doi/Zenodo.jl b/src/doi/Zenodo.jl new file mode 100644 index 00000000..32875661 --- /dev/null +++ b/src/doi/Zenodo.jl @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: MPL-2.0 +# Zenodo's documented deposition-v1 API, not the separate InvenioRDM records API. +module Zenodo + +using HTTP, JSON3, Dates, Logging + +export Client, RemoteError, environment_client, origin, create_deposition, + get_deposition, upload_file, publish_deposition, checked_id, reserved_doi, + checked_doi, retry_after_seconds, API_VERSION + +const API_VERSION = "deposition-v1" +const ORIGINS = Dict("sandbox" => "https://sandbox.zenodo.org", "production" => "https://zenodo.org") + +struct Secret + value::String +end +Base.show(io::IO, ::Secret) = print(io, "[REDACTED]") + +# No remote response bodies or underlying HTTP exceptions escape this boundary. +# Both can contain Authorization headers, request URLs, or reflected credentials. +struct RemoteError <: Exception + status::Int + code::String + message::String + ambiguous::Bool + retry_after::Union{Int,Nothing} +end +Base.showerror(io::IO, e::RemoteError) = print(io, e.message) + +function _http(method, url, headers, body) + # Even an operator's HTTP debug logger must not dump a reflected secret from + # a remote response body. Only the sanitised domain errors leave this boundary. + with_logger(NullLogger()) do + HTTP.request(method, url, headers, body; + redirect=false, retry=false, status_exception=false, logerrors=false, + connect_timeout=10, readtimeout=120) + end +end + +struct Client{T,S,C} + environment::String + token::Secret + transport::T + sleeper::S + clock::C + attempts::Int + retry_budget::Int +end + +function Client(token::AbstractString; environment::String="sandbox", transport=_http, + sleeper=sleep, clock=() -> now(UTC), attempts::Int=4, retry_budget::Int=60) + haskey(ORIGINS, environment) || throw(ArgumentError("Zenodo environment must be sandbox or production")) + isempty(strip(token)) && throw(ArgumentError("A server-side Zenodo token is required")) + any(isspace, token) && throw(ArgumentError("Invalid server-side Zenodo token")) + 1 <= attempts <= 5 || throw(ArgumentError("Zenodo attempts must be between 1 and 5")) + 0 <= retry_budget <= 120 || throw(ArgumentError("Zenodo retry budget must be between 0 and 120 seconds")) + Client(environment, Secret(String(token)), transport, sleeper, clock, attempts, retry_budget) +end +Base.show(io::IO, c::Client) = print(io, "Zenodo.Client(environment=", c.environment, ", token=[REDACTED])") +origin(c::Client) = ORIGINS[c.environment] + +function environment_client(env=ENV) + get(env, "METAMANIFOLD_ZENODO_ENABLED", "false") == "true" || + throw(ArgumentError("Zenodo publication is disabled by the server operator")) + environment = get(env, "METAMANIFOLD_ZENODO_ENVIRONMENT", "sandbox") + haskey(ORIGINS, environment) || throw(ArgumentError("Zenodo environment must be sandbox or production")) + key = environment == "sandbox" ? "ZENODO_SANDBOX_TOKEN" : "ZENODO_TOKEN" + Client(get(env, key, ""); environment) +end + +function checked_id(value) + # Bool is an Integer in Julia, but never a deposition identifier. + text = value isa AbstractString || (value isa Integer && !(value isa Bool)) ? string(value) : "" + occursin(r"^[1-9][0-9]{0,17}$", text) || + throw(RemoteError(502, "invalid_zenodo_response", "Zenodo returned an invalid deposition identifier.", false, nothing)) + return text +end + +function checked_doi(c::Client, value) + prefix = c.environment == "sandbox" ? "10.5072/zenodo." : "10.5281/zenodo." + value isa AbstractString && startswith(value, prefix) && occursin(r"^[1-9][0-9]*$", value[length(prefix)+1:end]) || + throw(RemoteError(502, "invalid_zenodo_response", "Zenodo returned a DOI for an unexpected service or environment.", false, nothing)) + return String(value) +end +reserved_doi(c::Client, deposit) = checked_doi(c, get(get(get(deposit, "metadata", Dict()), "prereserve_doi", Dict()), "doi", nothing)) + +function retry_after_seconds(value::AbstractString, clock::DateTime=now(UTC)) + seconds = tryparse(Int, strip(value)) + !isnothing(seconds) && return max(0, seconds) + occursin(r"^[0-9]+$", strip(value)) && return typemax(Int) + # HTTP-date (RFC 9110 IMF-fixdate); ignore malformed hints, not valid long waits. + endswith(value, " GMT") || return nothing + try + date = DateTime(value[1:end-4], dateformat"e, dd u yyyy HH:MM:SS") + return max(0, ceil(Int, Dates.value(date - clock) / 1000)) + catch + return nothing + end +end + +function _remote_error(status; ambiguous=false, retry_after=nothing) + code, message = if status == 401 + ("zenodo_unauthorized", "Zenodo rejected the server token. Ask the operator to check its environment and validity.") + elseif status == 403 + ("zenodo_forbidden", "Zenodo refused this operation. Check ownership and deposit:write / deposit:actions scopes.") + elseif status == 404 + ("zenodo_not_found", "The Zenodo draft was not found. Check the account and environment; no replacement was created.") + elseif status == 429 + ("zenodo_rate_limited", "Zenodo rate limit reached. Wait before retrying the same operation.") + elseif status in (400, 409, 415, 422) + ("zenodo_rejected", "Zenodo rejected the draft or its metadata. Review the draft on Zenodo; no DOI is claimed as published.") + elseif 300 <= status < 400 + ("zenodo_redirect_refused", "Zenodo redirected a credentialed request. Redirects are refused for token safety.") + else + ("zenodo_unavailable", "Zenodo did not return a usable response. Refresh or reconcile the existing publication; do not create a replacement.") + end + RemoteError(status, code, message, ambiguous, retry_after) +end + +# Factory bodies are reopened on EVERY attempt, including streamed uploads. HTTP.jl's +# automatic retries are disabled so an uncertain POST is never silently replayed. +function _request(c::Client, method::String, url::String; body_factory=() -> "", content_type="application/json", content_length=nothing, expected=(200,)) + # All endpoints are built here; only the bucket link is taken from a response, + # and it goes through a stricter origin/path validator below. + startswith(url, origin(c) * "/api/") || throw(ArgumentError("Refusing foreign Zenodo endpoint")) + headers = ["Authorization" => "Bearer " * c.token.value, + "Content-Type" => content_type, "Accept" => "application/json", + "User-Agent" => "MetaManifold-WebUI/doi-v1"] + isnothing(content_length) || push!(headers, "Content-Length" => string(content_length)) + safe = method in ("GET", "PUT") + waited = 0 + for attempt in 1:c.attempts + response = nothing + body = body_factory() + try + response = c.transport(method, url, headers, body) + catch + if !safe || attempt == c.attempts + throw(_remote_error(503; ambiguous=!safe)) + end + finally + body isa IO && close(body) + end + if !isnothing(response) + if response.status in expected + try + result = JSON3.read(String(response.body), Dict{String,Any}) + return result + catch + throw(RemoteError(502, "invalid_zenodo_response", "Zenodo returned malformed JSON; reconcile the existing operation before retrying.", !safe, nothing)) + end + end + hint = retry_after_seconds(HTTP.header(response, "Retry-After", ""), c.clock()) + # A definite 429 is safe to retry even for POST. 5xx/transport failures + # on create or publish are ambiguous and must be reconciled instead. + retryable = response.status == 429 || (safe && response.status in (500, 502, 503, 504)) + retryable && attempt < c.attempts || + throw(_remote_error(response.status; ambiguous=!safe && (response.status >= 500 || response.status < 400 || response.status == 408), retry_after=hint)) + delay = isnothing(hint) ? 2^(attempt - 1) : hint + else + delay = 2^(attempt - 1) + end + # Never truncate Retry-After and then retry too early. Return the hint to + # the caller when the server requests a wait beyond our bounded budget. + if delay > c.retry_budget - waited + isnothing(response) && throw(_remote_error(503)) + throw(_remote_error(response.status; retry_after=delay)) + end + c.sleeper(delay) + waited += delay + end + error("unreachable retry state") +end + +const DEPOSITIONS = "/api/deposit/depositions" +_endpoint(c, id) = origin(c) * DEPOSITIONS * "/" * checked_id(id) +create_deposition(c::Client, metadata) = _request(c, "POST", origin(c) * DEPOSITIONS; + body_factory=() -> JSON3.write(Dict("metadata" => metadata)), expected=(201,)) +get_deposition(c::Client, id) = _request(c, "GET", _endpoint(c, id)) +publish_deposition(c::Client, id) = _request(c, "POST", _endpoint(c, id) * "/actions/publish"; + body_factory=() -> "{}", expected=(200, 202)) + +function _bucket(c::Client, deposit) + url = get(get(deposit, "links", Dict()), "bucket", nothing) + url isa AbstractString || throw(RemoteError(502, "invalid_bucket", "Zenodo did not provide an upload bucket.", false, nothing)) + prefix = origin(c) * "/api/files/" + startswith(url, prefix) && occursin(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", url[length(prefix)+1:end]) || + throw(RemoteError(502, "unsafe_bucket", "Refusing an upload bucket outside the selected Zenodo origin or API path.", false, nothing)) + return String(url) +end + +function upload_file(c::Client, deposit, path::String, filename::String) + occursin(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,150}\.zip$", filename) || throw(ArgumentError("Invalid bundle filename")) + _request(c, "PUT", _bucket(c, deposit) * "/" * filename; + body_factory=() -> open(path, "r"), content_type="application/zip", content_length=filesize(path), expected=(200, 201)) +end + +end # module Zenodo diff --git a/src/doi/assets/publication.css b/src/doi/assets/publication.css new file mode 100644 index 00000000..5a2d0909 --- /dev/null +++ b/src/doi/assets/publication.css @@ -0,0 +1,45 @@ +/* SPDX-License-Identifier: MPL-2.0 */ +:root { color-scheme: light; font: 16px/1.6 system-ui, sans-serif; color: #243345; background: #f5f7fa; } +* { box-sizing: border-box; } +body { margin: 0; } +main { max-width: 980px; padding: 36px 24px 70px; margin: auto; } +h1 { font-size: clamp(1.8rem, 4vw, 2.6rem); line-height: 1.2; margin: 12px 0; } +h2 { font-size: 1.3rem; } h3 { margin: 0 0 8px; } +a { color: #145b80; text-underline-offset: 3px; } +header { padding: 20px 0; } +.eyebrow { font-size: .75rem; font-weight: 700; letter-spacing: .12em; color: #53677b; } +section, aside { background: white; padding: 24px; margin: 22px 0; border: 1px solid #dbe2eb; border-radius: 10px; } +.environment, .evidence { display: block; padding: 12px 16px; border-radius: 6px; font-weight: 650; } +.sandbox { background: #e9f2fa; color: #214a70; } +.production { background: #fff0e7; color: #8f3500; border: 1px solid #efb490; } +.evidence { background: #e7eeeb; border: 1px solid #abc3b7; } +label { display: block; margin: 15px 0; font-weight: 600; } +input:not([type=checkbox]), select, textarea { width: 100%; display: block; padding: 10px; border: 1px solid #9faebf; border-radius: 5px; background: white; color: inherit; font: inherit; margin-top: 6px; } +input[type=checkbox] { width: 18px; height: 18px; margin-right: 7px; vertical-align: middle; } +textarea { resize: vertical; } +fieldset { border: 0; padding: 0; min-width: 0; } +button { background: #1e5963; color: white; padding: 10px 16px; border: 1px solid transparent; border-radius: 5px; font: inherit; font-weight: 650; cursor: pointer; } +button.secondary { background: white; color: #34495e; border-color: #aebecb; } +button.danger { background: #a82e34; color: white; } +button:disabled { opacity: .55; cursor: not-allowed; } +:focus-visible { outline: 3px solid #118399; outline-offset: 3px; } +.columns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.actions, .section-heading { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.section-heading { justify-content: space-between; } +.publication, #config-summary { overflow-wrap: anywhere; } +.publication { border-top: 1px solid #dbe2eb; padding: 22px 0; } +.publication > a { display: inline-block; margin: 6px 14px 6px 0; } +.state { text-transform: none; font-weight: 600; color: #445f77; } +.warning, #error { background: #fff0ee; color: #9b2929; padding: 12px; border-left: 4px solid #b33333; } +#error { margin: 16px 0; } +#message:not(:empty) { background: #eaf4ed; padding: 12px; border-left: 4px solid #307e4f; } +pre, code { font-family: ui-monospace, monospace; overflow-wrap: anywhere; white-space: pre-wrap; } +pre { background: #f0f3f6; padding: 12px; font-size: .8rem; } +.doi-badge { padding: 6px 12px; border-radius: 4px; background: #e6f2ec; border: 1px solid #729d87; font-weight: 700; } +.ack { font-weight: 500; font-size: .92rem; } +details { margin-top: 24px; } summary { cursor: pointer; font-weight: 600; } +#config-json { font-family: ui-monospace, monospace; font-size: .85rem; } +dialog { width: min(92vw, 680px); max-height: 90vh; overflow: auto; border: 2px solid #b64040; border-radius: 10px; padding: 26px; } +dialog h2 { color: #a82e34; } dialog::backdrop { background: rgb(20 30 40 / 65%); } +[hidden] { display: none !important; } +@media (max-width: 600px) { main { padding: 20px 14px; } section, aside { padding: 16px; } .columns { grid-template-columns: 1fr; gap: 0; } .actions button { width: 100%; } } diff --git a/src/doi/assets/publication.js b/src/doi/assets/publication.js new file mode 100644 index 00000000..568f7306 --- /dev/null +++ b/src/doi/assets/publication.js @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MPL-2.0 +// Progressive-enhancement adapter only. The Julia service owns validation, +// snapshot identities and every lifecycle transition. No tokens or localStorage. +const boot = JSON.parse(document.getElementById('doi-bootstrap').textContent) +const base = `/api/v1/studies/${encodeURIComponent(boot.study)}` +const byId = id => document.getElementById(id) +let configs = [] +let records = [] +let selected = null +let busy = false + +function element(tag, text, className) { + const node = document.createElement(tag) + if (text !== undefined) node.textContent = text + if (className) node.className = className + return node +} + +async function request(path, body) { + const response = await fetch(path, body === undefined ? { cache: 'no-store' } : { + method: 'POST', cache: 'no-store', + headers: { 'Content-Type': 'application/json', 'X-DOI-CSRF': boot.csrf }, + body: JSON.stringify(body), + }) + const data = await response.json().catch(() => ({ message: 'The server returned an unreadable response. Reload saved publications before retrying.' })) + if (!response.ok) { + const wait = response.headers.get('Retry-After') + throw new Error((data.message || 'Publication operation failed.') + (wait ? ` Retry after ${wait} seconds.` : '')) + } + return data +} + +function message(text = '') { byId('message').textContent = text } +function showError(error) { + byId('error').hidden = false + byId('error').textContent = error instanceof Error ? error.message : 'Publication operation failed.' +} +function clearError() { byId('error').hidden = true; byId('error').textContent = '' } + +function setBusy(value) { + busy = value + byId('prepare-fields').disabled = value || !boot.enabled + byId('reload').disabled = value + byId('config-form').querySelector('button').disabled = value + byId('evidence-mode').disabled = value + for (const button of byId('publications').querySelectorAll('button')) button.disabled = value || !boot.enabled + updateConfirmation() +} + +async function operation(fn) { + if (busy) return + clearError() + setBusy(true) + try { await fn() } catch (error) { showError(error); message('No success is assumed. Reload or reconcile the existing publication before retrying.') } + finally { + // Reload even after a lost response: the write-ahead journal may contain a + // newly created draft or an uncertain submission that must not be replayed. + try { await loadPublications() } catch (error) { showError(error) } + setBusy(false) + } +} + +function externalLink(text, url) { + const link = element('a', text) + // Defence in depth; the backend also constructs and validates every URL. + const parsed = new URL(url, location.origin) + if (parsed.protocol !== 'https:') return element('span', text) + link.href = parsed.href + link.target = '_blank' + link.rel = 'noopener noreferrer' + return link +} + +function action(text, run, className) { + const button = element('button', text, className) + button.type = 'button' + button.disabled = busy || !boot.enabled + button.addEventListener('click', () => { + if (!byId('evidence-mode').checked) { + showError(new Error('Enable Evidence Mode before taking a publication action.')) + byId('evidence-mode').focus() + return + } + run() + }) + return button +} + +function renderPublications() { + const host = byId('publications') + host.replaceChildren() + if (!records.length) { host.append(element('p', 'No saved publications. Preparing a draft never publishes it.')); return } + for (const record of records) { + const card = element('article', undefined, 'publication') + card.append(element('h3', record.metadata.title)) + card.append(element('p', `${record.environment.toUpperCase()} · ${record.state.replaceAll('_', ' ')} · ${record.binding.kind === 'configuration' ? 'Configuration only — no analysis results' : 'Selected analysis result'}`, 'state')) + card.append(element('p', `Config ${record.binding.config_id}${record.binding.result_id ? ` · Result ${record.binding.result_id}` : ''}`)) + if (record.binding.dangerous) card.append(element('p', 'DANGER: this configuration contains scientific overrides. Preserve and disclose the archived warning.', 'warning')) + if (record.bundle_sha256) { + card.append(element('pre', `Archive SHA-256\n${record.bundle_sha256}`)) + const download = element('a', 'Download exact archive for review') + download.href = `${base}/doi-publications/${record.id}/download/bundle` + card.append(download) + } + if (record.state === 'published') { + const badge = externalLink(`${record.test_record ? 'TEST DOI (sandbox)' : 'DOI'}: ${record.doi}`, record.doi_url) + badge.className = 'doi-badge' + card.append(badge, element('p', record.citation)) + for (const [kind, label] of [['receipt', 'Download provenance receipt'], ['citation', 'Download CITATION.cff']]) { + const link = element('a', label) + link.href = `${base}/doi-publications/${record.id}/download/${kind}` + card.append(link, document.createTextNode(' · ')) + } + } else { + if (record.reserved_doi) card.append(element('p', `Reserved identifier: ${record.reserved_doi} — not yet a published DOI.`)) + if (record.draft_url) card.append(externalLink('Review draft on Zenodo', record.draft_url)) + if (record.last_error) card.append(element('p', record.last_error.message, 'warning')) + const controls = element('div', undefined, 'actions') + if (['preparing', 'draft'].includes(record.state)) { + controls.append(action('Resume draft preparation', () => operation(async () => { + message('Resuming the same draft and frozen archive…') + await request(`${base}/doi-publications/${record.id}/resume`, {}) + message('Draft preparation completed. Review before publishing.') + }))) + } + if (['creating', 'creation_uncertain'].includes(record.state)) { + card.append(element('p', 'Creation outcome is uncertain. Find the existing draft with this publication marker on Zenodo. Never create a replacement automatically.')) + card.append(element('code', record.id)) + const label = element('label', 'Existing Zenodo deposition ID') + const input = element('input') + input.inputMode = 'numeric' + input.pattern = '[1-9][0-9]*' + label.append(input) + controls.append(label, action('Recover matching draft', () => operation(async () => { + await request(`${base}/doi-publications/${record.id}/recover`, { deposition_id: input.value.trim() }) + message('Recovered the matching draft; no replacement was created.') + }))) + } + if (record.state === 'ready') controls.append(action('Mint DOI…', () => openConfirmation(record), 'danger')) + if (record.deposition_id) controls.append(action('Refresh from Zenodo (never publishes)', () => operation(async () => { + message('Checking the existing Zenodo deposition…') + const current = await request(`${base}/doi-publications/${record.id}/refresh`, {}) + message(current.state === 'published' ? 'Publication verified. Your DOI is ready to cite.' : `Current state: ${current.state}. No publish request was sent.`) + }), 'secondary')) + if (['publishing', 'publication_uncertain'].includes(record.state)) card.append(element('p', 'Publication may still be processing. Refresh to reconcile it. If Zenodo still shows a draft after review, finish publication there manually, then refresh here; this application will not replay an uncertain publish request.')) + card.append(controls) + } + host.append(card) + } +} + +async function loadPublications() { + const data = await request(`${base}/doi-publications`) + records = data.publications + renderPublications() +} + +async function loadConfigs(preferred) { + const data = await request(`${base}/analysis-config`) + configs = data.configs + const select = byId('config-id') + select.replaceChildren(new Option(configs.length ? 'Choose an immutable saved configuration' : 'No saved configurations — use the JSON form below', '')) + for (const config of configs) select.add(new Option(`${config.method} · ${config.formula} · ${config.id}`, config.id)) + if (preferred && configs.some(c => c.id === preferred)) select.value = preferred + await chooseConfig() +} + +async function chooseConfig() { + const id = byId('config-id').value + const config = configs.find(c => c.id === id) + byId('config-summary').textContent = config ? `Config SHA-256: ${config.hash}${config.dangerous ? ' — DANGER: scientific overrides present' : ''}` : '' + byId('result-id').replaceChildren(new Option('Choose explicitly: configuration only or a completed result', '')) + if (!config) return + byId('result-id').add(new Option('Configuration only — no scientific result', 'config-only')) + const data = await request(`${base}/analysis-config/${encodeURIComponent(id)}/results`) + if (byId('config-id').value !== id) return // stale asynchronous selection + for (const result of data.results) { + const option = new Option(`${result.id} · ${result.publishable ? result.hash : 'mock/empty — not publishable'}`, result.id) + option.disabled = !result.publishable + byId('result-id').add(option) + } +} + +function openConfirmation(record) { + selected = record + byId('confirm-summary').textContent = `${record.environment.toUpperCase()} · ${record.metadata.title} · ${record.metadata.license} · ${record.binding.kind}` + byId('confirm-hash').textContent = `Deposition ${record.deposition_id}\nSHA-256 ${record.bundle_sha256}` + byId('expected-phrase').textContent = record.confirmation_phrase + byId('confirmation').value = '' + byId('public-ack').checked = false + updateConfirmation() + byId('publish-dialog').showModal() + byId('confirmation').focus() +} +function updateConfirmation() { + byId('confirm-publish').disabled = busy || !selected || !byId('public-ack').checked || byId('confirmation').value !== selected.confirmation_phrase +} + +byId('evidence-mode').addEventListener('change', () => { byId('advanced').hidden = !byId('evidence-mode').checked }) +byId('config-id').addEventListener('change', () => chooseConfig().catch(showError)) +byId('reload').addEventListener('click', () => operation(async () => { await loadConfigs(byId('config-id').value); message('Saved state reloaded. No Zenodo mutation was requested.') })) +byId('confirmation').addEventListener('input', updateConfirmation) +byId('public-ack').addEventListener('change', updateConfirmation) +byId('cancel-publish').addEventListener('click', () => { byId('publish-dialog').close(); selected = null }) +byId('publish-dialog').addEventListener('cancel', () => { selected = null }) + +byId('config-form').addEventListener('submit', event => { + event.preventDefault() + operation(async () => { + const data = await request(`${base}/analysis-config`, JSON.parse(byId('config-json').value)) + await loadConfigs(data.config.id) + message('Saved a new immutable configuration. No analysis was executed or DOI published.') + }) +}) +byId('prepare-form').addEventListener('submit', event => { + event.preventDefault() + operation(async () => { + const payload = byId('result-id').value + if (!payload || !byId('upload-ack').checked) throw new Error('Choose a payload and acknowledge the upload first.') + message('Preparing a private draft and verifying the frozen archive. Please wait…') + await request(`${base}/doi-publications`, { + config_id: byId('config-id').value, + result_id: payload === 'config-only' ? null : payload, + acknowledge_upload: true, + metadata: { + title: byId('title').value, description: byId('description').value, + creators: byId('creators').value.split('\n').map(name => name.trim()).filter(Boolean).map(name => ({ name })), + license: byId('license').value, version: byId('version').value, + publication_date: byId('publication-date').value, + github_release_url: byId('release-url').value || null, + github_project_url: byId('project-url').value || null, + }, + }) + message('Draft prepared — not published. Download and review its exact archive, then choose Mint DOI.') + }) +}) +byId('publish-form').addEventListener('submit', event => { + event.preventDefault() + if (!selected || byId('confirm-publish').disabled) return + const record = selected + const confirmation = byId('confirmation').value + byId('publish-dialog').close() + selected = null + operation(async () => { + message('Submitting the one confirmed publication request…') + const result = await request(`${base}/doi-publications/${record.id}/publish`, { + confirmation, bundle_sha256: record.bundle_sha256, acknowledge_public: true, + }) + message(result.state === 'published' ? 'Published and verified. Download the receipt and citation.' : 'Zenodo accepted the request but has not confirmed publication. Refresh the existing record; do not resubmit.') + }) +}) + +Promise.all([loadConfigs(boot.selected_config), loadPublications()]).catch(showError) diff --git a/src/server/routes/analysis_config.jl b/src/server/routes/analysis_config.jl index 7f0f1cf4..a34aca89 100644 --- a/src/server/routes/analysis_config.jl +++ b/src/server/routes/analysis_config.jl @@ -9,22 +9,16 @@ using MetaManifold: AnalysisConfig using MetaManifold.Epistemic using MetaManifold.CladeCumulus -# In-memory store for configs (in production, would be per-study persistent) -const _ANALYSIS_CONFIG_STORE = Dict{String,Dict{String,AnalysisConfig.AnalysisConfigStruct}}() # study -> id -> config -const _ANALYSIS_RESULT_STORE = Dict{String,Dict{String,AnalysisConfig.AnalysisResult}}() # study -> id -> result -const _ANALYSIS_CONFIG_LOCK = ReentrantLock() - -function _get_study_configs(study::String) - lock(_ANALYSIS_CONFIG_LOCK) do - get!(_ANALYSIS_CONFIG_STORE, study, Dict{String,AnalysisConfig.AnalysisConfigStruct}()) - end -end - -function _get_study_results(study::String) - lock(_ANALYSIS_CONFIG_LOCK) do - get!(_ANALYSIS_RESULT_STORE, study, Dict{String,AnalysisConfig.AnalysisResult}()) - end +# Durable per-study records; publication journals retain independent snapshots. +using MetaManifold: AnalysisStore, DOIBundles, DOIStorage +function _analysis_store_dir(study::String) + _valid_name(study) || throw(DOIStorage.PublicationError(400, "invalid_study", "Invalid study name.")) + project = joinpath(ServerState.projects_dir(), study) + islink(project) && throw(DOIStorage.PublicationError(403, "unsafe_storage", "Analysis storage must not be a symlink.")) + joinpath(project, ".analysis") end +_get_study_configs(study::String) = AnalysisStore.configs(_analysis_store_dir(study)) +_get_study_results(study::String) = AnalysisStore.results(_analysis_store_dir(study)) # List available metadata columns for a study (from first run's merged table or from study config) function _available_metadata_columns(study::String)::Vector{String} @@ -69,6 +63,8 @@ end normalization = AnalysisConfig.NormalizationConfig( method=String(norm_method), pseudocount=Float64(get(norm_body, "pseudocount", 0.5)), + epsilon=Float64(get(norm_body, "epsilon", 1e-6)), + zero_policy=String(get(norm_body, "zero_policy", "pseudocount")), ilr_basis=get(norm_body, "ilr_basis", nothing) isa Nothing ? nothing : String(get(norm_body, "ilr_basis", nothing)), multiplicative_replacement_delta=get(norm_body, "multiplicative_replacement_delta", nothing) isa Nothing ? nothing : Float64(get(norm_body, "multiplicative_replacement_delta", nothing)), # The declared scaling parameters travel with the request, are validated by the @@ -92,6 +88,9 @@ end advanced = AnalysisConfig.AdvancedOverrides( dispersion_method=String(get(adv_body, "dispersion_method", "parametric")), zero_handling=String(get(adv_body, "zero_handling", "pseudocount")), + zero_policy=String(get(adv_body, "zero_policy", get(adv_body, "zero_handling", "pseudocount"))), + pseudocount=Float64(get(adv_body, "pseudocount", 0.5)), + epsilon=Float64(get(adv_body, "epsilon", 1e-6)), min_prevalence=Float64(get(adv_body, "min_prevalence", 0.1)), min_abundance=Float64(get(adv_body, "min_abundance", 0.0)), max_features=get(adv_body, "max_features", nothing) isa Nothing ? nothing : Int(get(adv_body, "max_features", nothing)), @@ -115,7 +114,7 @@ end available_cols = _available_metadata_columns(study) errors = AnalysisConfig.validate_config(cfg, available_cols; strict=false) if !isempty(errors) - return json_error(400, "validation_failed", "AnalysisConfig validation failed", join(errors, "\n")) + return json_error(400, "validation_failed", "AnalysisConfig validation failed"; detail=join(errors, "\n")) end # Check DANGER @@ -126,10 +125,7 @@ end end # Store - lock(_ANALYSIS_CONFIG_LOCK) do - study_configs = _get_study_configs(study) - study_configs[cfg.id] = cfg - end + AnalysisStore.save_config!(_analysis_store_dir(study), cfg) # Return with danger banner if any resp = OrderedDict{String,Any}( @@ -146,7 +142,7 @@ end catch e if e isa ArgumentError - return json_error(400, "invalid_config", "Invalid AnalysisConfig: $(e.msg)", sprint(showerror, e)) + return json_error(400, "invalid_config", "Invalid AnalysisConfig: $(e.msg)"; detail=sprint(showerror, e)) else @error "Failed to create AnalysisConfig" exception=(e, catch_backtrace()) return json_error(500, "internal_error", "Failed to create AnalysisConfig: $(sprint(showerror, e))") @@ -195,11 +191,11 @@ end @delete "/api/v1/studies/{study}/analysis-config/{id}" function(req, study::String, id::String) study in _study_names() || return json_error(404, "study_not_found", "Study '$study' not found") - lock(_ANALYSIS_CONFIG_LOCK) do - study_configs = _get_study_configs(study) - haskey(study_configs, id) || return json_error(404, "config_not_found", "AnalysisConfig '$id' not found") - delete!(study_configs, id) - end + haskey(_get_study_configs(study), id) || return json_error(404, "config_not_found", "AnalysisConfig '$id' not found") + # Keep reviewed config identities addressable for both drafts and citations. + any(p -> p["binding"]["config_id"] == id, MetaManifold.DOIPublications.publications(_doi_store_dir(study))) && + return json_error(409, "config_has_publication", "This configuration has a DOI publication journal and cannot be deleted") + AnalysisStore.delete_config!(_analysis_store_dir(study), id) json(OrderedDict("deleted" => id)) end @@ -260,14 +256,12 @@ end "config_id" => cfg.id, "config_hash" => cfg.hash, "method" => AnalysisConfig.METHOD_TO_STRING[cfg.method], + "mock" => true, "note" => "Mock result — real implementation requires R packages DESeq2, compositions, etc. This is v1 scaffold with provenance chain intact.", ), ) - lock(_ANALYSIS_CONFIG_LOCK) do - study_results = _get_study_results(study) - study_results[result.id] = result - end + AnalysisStore.save_result!(_analysis_store_dir(study), result) json(OrderedDict( "result" => OrderedDict( @@ -297,48 +291,29 @@ end title = String(get(body, "title", "MetaManifold Analysis Bundle for $study")) license = String(get(body, "license", "CC-BY-4.0")) - # Find latest result for this config if any + # Explicit selection only: never bind a publication to a moving "latest" result. + result_id = get(body, "result_id", nothing) study_results = _get_study_results(study) - latest_result = nothing - for r in values(study_results) - if r.config_id == id - if isnothing(latest_result) || r.created_at > latest_result.created_at - latest_result = r - end - end - end - - mktempdir() do tmpdir - bundle_dir = joinpath(tmpdir, "bundle_$(cfg.id)") - bundle_path = AnalysisConfig.create_doi_bundle(cfg, latest_result, bundle_dir; authors, title, license) - - # Zip the bundle for download - zip_path = bundle_dir * ".zip" - try - run(`zip -r $zip_path $bundle_dir`) - catch - # Fallback: just return the directory path if zip fails - return json(OrderedDict( - "bundle_path" => bundle_path, - "config_id" => cfg.id, - "config_hash" => cfg.hash, - "doi_ready" => true, - "files" => readdir(bundle_path), - "datacite" => JSON3.read(read(joinpath(bundle_path, "datacite.json"), String)), - )) + !isnothing(result_id) && !haskey(study_results, result_id) && + return json_error(404, "result_not_found", "Selected analysis result was not found") + selected_result = isnothing(result_id) ? nothing : study_results[result_id] + try + mktempdir() do tmpdir + bundle_dir = joinpath(tmpdir, "bundle") + AnalysisConfig.create_doi_bundle(cfg, selected_result, bundle_dir; authors, title, license, + description=String(get(body, "description", "Analysis configuration export"))) + zip_path = joinpath(tmpdir, "bundle.zip") + DOIBundles.archive_bundle(bundle_dir, zip_path) + HTTP.Response(200, ["Content-Type" => "application/zip", "Cache-Control" => "no-store", + "Content-Disposition" => "attachment; filename=\"metamanifold-doi-bundle.zip\""]; body=read(zip_path)) end - - if isfile(zip_path) - data = read(zip_path) - HTTP.Response(200, ["Content-Type" => "application/zip", "Content-Disposition" => "attachment; filename=\"$(study)_$(id)_doi_bundle.zip\""], body=data) - else - json(OrderedDict( - "bundle_path" => bundle_path, - "config_id" => cfg.id, - "config_hash" => cfg.hash, - "doi_ready" => true, - )) + catch e + if e isa DOIStorage.PublicationError + return json_error(e.status, e.code, e.message) + elseif e isa ArgumentError + return json_error(422, "invalid_bundle", "The result does not belong to this exact configuration or the bundle is invalid") end + return json_error(500, "bundle_failed", "Could not create the DOI bundle; no temporary path is returned") end end diff --git a/src/server/routes/doi.jl b/src/server/routes/doi.jl new file mode 100644 index 00000000..a0b99606 --- /dev/null +++ b/src/server/routes/doi.jl @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: MPL-2.0 +# Opt-in, local/single-user publication API. Never accepts tokens or remote API URLs. +using MetaManifold: Zenodo, DOIPublications, DOIWeb +using Random + +const _DOI_CSRF = bytes2hex(rand(RandomDevice(), UInt8, 32)) +const _DOI_CLIENT_FACTORY = Ref{Function}(() -> Zenodo.environment_client()) +_doi_client() = _DOI_CLIENT_FACTORY[]() + +function _doi_store_dir(study::String) + _valid_name(study) || throw(DOIStorage.PublicationError(400, "invalid_study", "Invalid study name.")) + project = joinpath(ServerState.projects_dir(), study) + islink(project) && throw(DOIStorage.PublicationError(403, "unsafe_storage", "DOI study storage must not be a symlink.")) + root = joinpath(project, ".doi") + islink(root) && throw(DOIStorage.PublicationError(403, "unsafe_storage", "DOI storage must not be a symlink.")) + return root +end + +function _doi_capabilities() + environment = get(ENV, "METAMANIFOLD_ZENODO_ENVIRONMENT", "sandbox") + environment in ("sandbox", "production") || (environment = "sandbox") + enabled = try + client = _doi_client() + environment = client.environment + true + catch + false + end + Dict("enabled" => enabled, "environment" => environment, "api_version" => Zenodo.API_VERSION, "csrf" => _DOI_CSRF) +end + +# Browser mutations must be same-origin (or the one explicitly configured proxy +# origin) AND carry the per-process CSRF token fetched from the same-origin UI. +# This is CSRF protection, not user authentication. Do not expose this local API +# publicly without a separate authenticated reverse proxy. +function _doi_same_origin(req) + origin = HTTP.header(req, "Origin", "") + isempty(origin) && return true # non-browser clients still need JSON + CSRF + configured = get(ENV, "METAMANIFOLD_PUBLIC_ORIGIN", "") + !isempty(configured) && origin == configured && return true + uri = try HTTP.URIs.URI(origin) catch; return false end + uri.scheme in ("http", "https") && isempty(uri.userinfo) && isempty(uri.query) && isempty(uri.fragment) && isempty(uri.path) || return false + authority = uri.host * (isempty(uri.port) ? "" : ":" * uri.port) + lowercase(authority) == lowercase(HTTP.header(req, "Host", "")) +end + +function _doi_body(req; allowed=String[]) + HTTP.header(req, "X-DOI-CSRF", "") == _DOI_CSRF && _doi_same_origin(req) || + throw(DOIStorage.PublicationError(403, "publication_intent_required", "Reload the same-origin DOI page and retry. Its CSRF token or origin is missing or stale.")) + lowercase(strip(first(split(HTTP.header(req, "Content-Type", ""), ';')))) == "application/json" || + throw(DOIStorage.PublicationError(415, "json_required", "DOI operations require application/json.")) + length(req.body) <= 65_536 || throw(DOIStorage.PublicationError(413, "request_too_large", "DOI request exceeds 64 KiB.")) + body = try JSON3.read(String(copy(req.body)), Dict{String,Any}) catch + throw(DOIStorage.PublicationError(400, "invalid_json", "Expected a JSON object.")) + end + all(k -> k in allowed, keys(body)) || throw(DOIStorage.PublicationError(400, "unknown_field", "Unexpected DOI request field; tokens and API URLs must never be sent by the browser.")) + return body +end + +function _doi_json(value; status=200, headers=Pair{String,String}[]) + HTTP.Response(status, vcat(["Content-Type" => "application/json", "Cache-Control" => "no-store"], headers); body=JSON3.write(value)) +end + +function _doi_error(e) + if e isa DOIStorage.PublicationError + return _doi_json(Dict("error" => e.code, "message" => e.message); status=e.status) + elseif e isa Zenodo.RemoteError + status = e.status == 429 ? 429 : e.status in (400, 409, 415, 422) ? 422 : 502 + headers = isnothing(e.retry_after) ? Pair{String,String}[] : ["Retry-After" => string(e.retry_after)] + return _doi_json(Dict("error" => e.code, "message" => e.message, "outcome_uncertain" => e.ambiguous); status, headers) + elseif e isa ArgumentError + return _doi_json(Dict("error" => "publication_unavailable", "message" => "Check the server's Zenodo enable flag, environment and token, and the request values. No token is accepted through this API."); status=503) + end + # Do not log the exception or its backtrace: HTTP exceptions can carry headers. + @warn "DOI operation failed locally; inspect the publication journal (credentials omitted)" + _doi_json(Dict("error" => "publication_failed", "message" => "Publication failed locally. Reload saved publications and reconcile the existing operation before retrying."); status=500) +end + +function _doi_api(f::Function, req, study; mutation=false) + try + _valid_name(study) && study in _study_names() || throw(DOIStorage.PublicationError(404, "study_not_found", "Study not found.")) + if mutation + # Also excludes study rename/deletion while a network operation owns + # this journal. The per-record lock protects library/CLI callers too. + return DOIStorage.with_publication_lock(_doi_store_dir(study)) do + study in _study_names() || throw(DOIStorage.PublicationError(404, "study_not_found", "Study not found.")) + f() + end + end + f() + catch e + _doi_error(e) + end +end + +# Called by existing destructive study routes. Keeping a publication journal is +# part of the idempotency guarantee, not disposable UI cache. +function _doi_protect_study_mutation(f::Function, study) + try + DOIStorage.with_publication_lock(_doi_store_dir(study)) do + isempty(DOIPublications.publications(_doi_store_dir(study))) || + throw(DOIStorage.PublicationError(409, "study_has_publications", "This study has DOI publication journals. Keep its name and archive/back up the study instead of deleting its publication history.")) + f() + end + catch e + _doi_error(e) + end +end + +@get "/api/v1/doi/capabilities" function(req) + _doi_json(_doi_capabilities()) +end + +@get "/api/v1/doi/assets/{name}" function(req, name::String) + name in ("publication.js", "publication.css") || return json_error(404, "file_not_found", "Unknown DOI asset") + mime = endswith(name, ".js") ? "text/javascript" : "text/css" + path = joinpath(@__DIR__, "..", "..", "doi", "assets", name) + HTTP.Response(200, ["Content-Type" => mime, "X-Content-Type-Options" => "nosniff"]; body=read(path)) +end + +@get "/api/v1/studies/{study}/doi-ui" function(req, study::String) + _doi_api(req, study) do + capabilities = _doi_capabilities() + selected = get(HTTP.queryparams(req), "config", "") + html = DOIWeb.render_page(study, _DOI_CSRF, capabilities["environment"], capabilities["enabled"]; selected_config=selected) + HTTP.Response(200, ["Content-Type" => "text/html; charset=utf-8", "Cache-Control" => "no-store", + "X-Content-Type-Options" => "nosniff", "Referrer-Policy" => "no-referrer", + "Content-Security-Policy" => "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"]; body=html) + end +end + +@get "/api/v1/studies/{study}/analysis-config/{id}/results" function(req, study::String, id::String) + _doi_api(req, study) do + haskey(_get_study_configs(study), id) || throw(DOIStorage.PublicationError(404, "config_not_found", "Saved configuration not found.")) + results = [Dict("id" => r.id, "hash" => r.hash, "created_at" => string(r.created_at), + "publishable" => !isempty(r.results) && get(r.provenance, "mock", false) !== true) + for r in values(_get_study_results(study)) if r.config_id == id] + _doi_json(Dict("results" => results)) + end +end + +@get "/api/v1/studies/{study}/doi-publications" function(req, study::String) + _doi_api(req, study) do + _doi_json(Dict("publications" => DOIPublications.publications(_doi_store_dir(study)))) + end +end + +@post "/api/v1/studies/{study}/doi-publications" function(req, study::String) + _doi_api(req, study; mutation=true) do + body = _doi_body(req; allowed=["config_id", "result_id", "metadata", "acknowledge_upload"]) + get(body, "acknowledge_upload", false) === true || throw(DOIStorage.PublicationError(422, "upload_consent_required", "Review privacy and consent to sending the selected bundle to Zenodo before preparing a draft.")) + haskey(body, "result_id") || throw(DOIStorage.PublicationError(422, "payload_selection_required", "Explicitly select result_id, or null for a configuration-only bundle. Latest-result selection is not supported.")) + config_id = get(body, "config_id", nothing) + config_id isa AbstractString || throw(DOIStorage.PublicationError(422, "config_required", "Select a saved configuration.")) + configs = _get_study_configs(study) + haskey(configs, config_id) || throw(DOIStorage.PublicationError(404, "config_not_found", "Saved configuration not found.")) + metadata = get(body, "metadata", nothing) + metadata isa AbstractDict || throw(DOIStorage.PublicationError(422, "metadata_required", "Explicit publication metadata is required.")) + metadata = DOIBundles.validate_metadata(metadata) + result_id = body["result_id"] + results = _get_study_results(study) + result = if isnothing(result_id) + nothing + elseif result_id isa AbstractString && haskey(results, result_id) + results[result_id] + else + throw(DOIStorage.PublicationError(404, "result_not_found", "Selected result not found.")) + end + client = _doi_client() + value = mktempdir() do tmp + bundle = joinpath(tmp, "bundle") + AnalysisConfig.create_doi_bundle(configs[config_id], result, bundle; + title=metadata["title"], authors=String[c["name"] for c in metadata["creators"]], + license=metadata["license"], description=metadata["description"]) + DOIPublications.prepare!(_doi_store_dir(study), bundle, metadata, client) + end + _doi_json(value) + end +end + +@get "/api/v1/studies/{study}/doi-publications/{id}" function(req, study::String, id::String) + _doi_api(req, study) do + _doi_json(DOIPublications.status(_doi_store_dir(study), id)) + end +end + +@post "/api/v1/studies/{study}/doi-publications/{id}/resume" function(req, study::String, id::String) + _doi_api(req, study; mutation=true) do + _doi_body(req) + _doi_json(DOIPublications.resume!(_doi_store_dir(study), id, _doi_client())) + end +end + +@post "/api/v1/studies/{study}/doi-publications/{id}/refresh" function(req, study::String, id::String) + _doi_api(req, study; mutation=true) do + _doi_body(req) + _doi_json(DOIPublications.refresh!(_doi_store_dir(study), id, _doi_client())) + end +end + +@post "/api/v1/studies/{study}/doi-publications/{id}/recover" function(req, study::String, id::String) + _doi_api(req, study; mutation=true) do + body = _doi_body(req; allowed=["deposition_id"]) + _doi_json(DOIPublications.recover_creation!(_doi_store_dir(study), id, get(body, "deposition_id", nothing), _doi_client())) + end +end + +@post "/api/v1/studies/{study}/doi-publications/{id}/publish" function(req, study::String, id::String) + _doi_api(req, study; mutation=true) do + body = _doi_body(req; allowed=["confirmation", "bundle_sha256", "acknowledge_public"]) + _doi_json(DOIPublications.publish!(_doi_store_dir(study), id, _doi_client(); + confirmation=get(body, "confirmation", nothing), bundle_sha256=get(body, "bundle_sha256", nothing), + acknowledge_public=get(body, "acknowledge_public", false))) + end +end + +@get "/api/v1/studies/{study}/doi-publications/{id}/download/{kind}" function(req, study::String, id::String, kind::String) + _doi_api(req, study) do + path, mime, name = DOIPublications.download_path(_doi_store_dir(study), id, kind) + HTTP.Response(200, ["Content-Type" => mime, "Cache-Control" => "no-store", + "Content-Disposition" => "attachment; filename=\"$name\"", "X-Content-Type-Options" => "nosniff", + "Content-Length" => string(filesize(path))]; body=DOIStorage.FileBody(path)) + end +end diff --git a/src/server/routes/studies.jl b/src/server/routes/studies.jl index 6dd158ee..4b580720 100644 --- a/src/server/routes/studies.jl +++ b/src/server/routes/studies.jl @@ -225,9 +225,11 @@ end "Study '$new_name' already exists") _study_has_active_jobs(study) && return json_error(409, "jobs_active", "Study '$study' has active jobs - wait for them to finish before renaming") - _safe_move(joinpath(ServerState.data_dir(), study), joinpath(ServerState.data_dir(), new_name)) - _safe_move(joinpath(ServerState.projects_dir(), study), joinpath(ServerState.projects_dir(), new_name)) - json(_study_data(new_name)) + _doi_protect_study_mutation(study) do + _safe_move(joinpath(ServerState.data_dir(), study), joinpath(ServerState.data_dir(), new_name)) + _safe_move(joinpath(ServerState.projects_dir(), study), joinpath(ServerState.projects_dir(), new_name)) + json(_study_data(new_name)) + end end @delete "/api/v1/studies/{study}" function(req, study::String) @@ -235,9 +237,11 @@ end "Study '$study' not found") _study_has_active_jobs(study) && return json_error(409, "jobs_active", "Study '$study' has active jobs - wait for them to finish before deleting") - rm(joinpath(ServerState.data_dir(), study); recursive=true, force=true) - rm(joinpath(ServerState.projects_dir(), study); recursive=true, force=true) - json((; deleted=study)) + _doi_protect_study_mutation(study) do + rm(joinpath(ServerState.data_dir(), study); recursive=true, force=true) + rm(joinpath(ServerState.projects_dir(), study); recursive=true, force=true) + json((; deleted=study)) + end end ## Group management diff --git a/src/server/server.jl b/src/server/server.jl index 4c1622b9..37112d07 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -22,7 +22,9 @@ module Server using MetaManifold.Tools, MetaManifold.TaxonomyTableTools, MetaManifold.ProjectSetup using MetaManifold.DADA2, MetaManifold.OTUPipeline using MetaManifold.DiversityMetrics, MetaManifold.Analysis - using MetaManifold.Epistemic, MetaManifold.AnalysisConfig, MetaManifold.CladeCumulus + using MetaManifold.Epistemic, MetaManifold.CladeCumulus + # Bind the module, not its same-named exported struct. + using MetaManifold: AnalysisConfig ## EPIPE log filter # HTTP.jl logs every broken-pipe error from SSE streams as @error @@ -88,6 +90,7 @@ module Server include(joinpath(@__DIR__, "routes", "analysis.jl")) include(joinpath(@__DIR__, "routes", "composition.jl")) include(joinpath(@__DIR__, "routes", "analysis_config.jl")) + include(joinpath(@__DIR__, "routes", "doi.jl")) ## R-runtime busy middleware # The embedded R interpreter is shared between the pipeline and the analysis @@ -115,15 +118,17 @@ module Server # Same-origin request - no CORS headers needed return next(req) end - # Only allow localhost origins + # Same-origin proxy/preview deployments are permitted. Split remote + # deployments must name their public origin explicitly; this is not + # an authentication mechanism for exposing the local backend. origin_url = try HTTP.URIs.URI(origin) catch; nothing end - if isnothing(origin_url) || !(lowercase(origin_url.host) in ("localhost", "127.0.0.1", "::1")) + if !_doi_same_origin(req) && (isnothing(origin_url) || !(lowercase(origin_url.host) in ("localhost", "127.0.0.1", "::1"))) return HTTP.Response(403, "Forbidden: non-localhost origin") end cors_headers = [ "Access-Control-Allow-Origin" => origin, "Access-Control-Allow-Methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers" => "Content-Type", + "Access-Control-Allow-Headers" => "Content-Type, X-DOI-CSRF", ] # Handle preflight if req.method == "OPTIONS" @@ -155,12 +160,21 @@ module Server # rest may be {run}/... or {group}/{run}/... (group paths have an extra segment) m = match(r"^/files/([^/]+)/runs/(.+)$", uri) if !isnothing(m) - candidate = abspath(joinpath(ServerState.projects_dir(), HTTP.URIs.unescapeuri(m[1]), HTTP.URIs.unescapeuri(m[2]))) + # Private analysis/publication journals are not generic run files. + # They are available only through the explicit DOI API allowlist. + study = HTTP.URIs.unescapeuri(m[1]) + _valid_name(study) || return HTTP.Response(403, "Invalid study path") + segments = split(replace(HTTP.URIs.unescapeuri(m[2]), '\\' => '/'), '/') + any(segment -> startswith(segment, "."), segments) && return HTTP.Response(403, "Private application state") + candidate = abspath(joinpath(ServerState.projects_dir(), study, HTTP.URIs.unescapeuri(m[2]))) isfile(candidate) || return HTTP.Response(404, "File not found") full = realpath(candidate) projects = realpath(ServerState.projects_dir()) startswith(full, projects * Base.Filesystem.path_separator) || return HTTP.Response(403, "Forbidden") + # A harmless-looking symlink must not bypass the hidden-path rule. + any(segment -> startswith(segment, "."), splitpath(relpath(full, projects))) && + return HTTP.Response(403, "Private application state") ext = last(splitext(full)) mime = get(_mime_map, ext, "application/octet-stream") return HTTP.Response(200, ["Content-Type" => mime]; body=read(full)) @@ -170,9 +184,13 @@ module Server startswith(uri, "/api/") && return next(req) # SPA catch-all: serve frontend build or index.html - rel = lstrip(uri, '/') - target = joinpath(_frontend_dir, rel) + rel = HTTP.URIs.unescapeuri(lstrip(uri, '/')) + any(segment -> startswith(segment, "."), split(replace(rel, '\\' => '/'), '/')) && + return HTTP.Response(403, "Forbidden") + target = abspath(joinpath(_frontend_dir, rel)) if isfile(target) + startswith(realpath(target), realpath(_frontend_dir) * Base.Filesystem.path_separator) || + return HTTP.Response(403, "Forbidden") ext = last(splitext(target)) mime = get(_mime_map, ext, "application/octet-stream") # Content-hashed assets (js/css in assets/) are immutable. diff --git a/test/doi/Manifest.toml b/test/doi/Manifest.toml new file mode 100644 index 00000000..d7c89f1d --- /dev/null +++ b/test/doi/Manifest.toml @@ -0,0 +1,230 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.12.5" +manifest_format = "2.0" +project_hash = "bdcabc733ed06a313c9ba9e37f415885352b4deb" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" +version = "1.11.0" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +version = "1.11.0" + +[[deps.BitFlags]] +git-tree-sha1 = "0691e34b3bb8be9307330f88d1a3c3f25466c24d" +uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35" +version = "0.1.9" + +[[deps.CodecZlib]] +deps = ["TranscodingStreams", "Zlib_jll"] +git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" +uuid = "944b1d66-785c-5afd-91f1-9de20f533193" +version = "0.7.8" + +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "21d088c496ea22914fe80906eb5bce65755e5ec8" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.5.1" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" +version = "1.11.0" + +[[deps.ExceptionUnwrapping]] +deps = ["Test"] +git-tree-sha1 = "d36f682e590a83d63d1c7dbd287573764682d12a" +uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4" +version = "0.1.11" + +[[deps.HTTP]] +deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "PrecompileTools", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] +git-tree-sha1 = "51059d23c8bb67911a2e6fd5130229113735fc7e" +uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" +version = "1.11.0" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +version = "1.11.0" + +[[deps.JLLWrappers]] +deps = ["Artifacts", "Preferences"] +git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.7.1" + +[[deps.JSON3]] +deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"] +git-tree-sha1 = "411eccfe8aba0814ffa0fdf4860913ed09c34975" +uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +version = "1.14.3" + + [deps.JSON3.extensions] + JSON3ArrowExt = ["ArrowTypes"] + + [deps.JSON3.weakdeps] + ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" + +[[deps.JuliaSyntaxHighlighting]] +deps = ["StyledStrings"] +uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" +version = "1.12.0" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" +version = "1.11.0" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +version = "1.11.0" + +[[deps.LoggingExtras]] +deps = ["Dates", "Logging"] +git-tree-sha1 = "f00544d95982ea270145636c181ceda21c4e2575" +uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36" +version = "1.2.0" + +[[deps.Markdown]] +deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +version = "1.11.0" + +[[deps.MD5]] +deps = ["Random", "SHA"] +git-tree-sha1 = "1576f756617d31eb397a4a517b68562fd28dc2b4" +uuid = "6ac74813-4b46-53a4-afec-0b5dc9d7885c" +version = "0.2.3" + +[[deps.MbedTLS]] +deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"] +git-tree-sha1 = "8785729fa736197687541f7053f6d8ab7fc44f92" +uuid = "739be429-bea8-5141-9913-cc70e7f3736d" +version = "1.1.10" + +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "ff69a2b1330bcb730b9ac1ab7dd680176f5896b8" +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.1010+0" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" +version = "1.11.0" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2025.11.4" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.3.0" + +[[deps.OpenSSL]] +deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "NetworkOptions", "OpenSSL_jll", "Sockets"] +git-tree-sha1 = "1d1aaa7d449b58415f97d2839c318b70ffb525a0" +uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" +version = "1.6.1" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.5.4+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "05868e21324cede2207c6f0f466b4bfef6d5e7ee" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.8.1" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "7d2f8f21da5db6a806faf7b9b292296da42b2810" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.3" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "07a921781cab75691315adc645096ed5e370cb77" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.3.3" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "8b770b60760d4451834fe79dd483e318eee709c4" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.5.2" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +version = "1.11.0" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +version = "1.11.0" + +[[deps.SimpleBufferStream]] +git-tree-sha1 = "f305871d2f381d21527c770d4788c06c097c9bc1" +uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7" +version = "1.2.0" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" +version = "1.11.0" + +[[deps.StructTypes]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" +uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" +version = "1.11.0" + +[[deps.StyledStrings]] +uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" +version = "1.11.0" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +version = "1.11.0" + +[[deps.TranscodingStreams]] +git-tree-sha1 = "0c45878dcfdcfa8480052b6ab162cdd138781742" +uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" +version = "0.11.3" + +[[deps.URIs]] +git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +version = "1.6.1" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +version = "1.11.0" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +version = "1.11.0" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.3.1+2" + diff --git a/test/doi/Project.toml b/test/doi/Project.toml new file mode 100644 index 00000000..3398ef81 --- /dev/null +++ b/test/doi/Project.toml @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# Credential-free DOI contract lane. No R, biological databases, or live Zenodo. +[deps] +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" +MD5 = "6ac74813-4b46-53a4-afec-0b5dc9d7885c" +OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[compat] +HTTP = "=1.11.0" +JSON3 = "=1.14.3" +MD5 = "=0.2.3" +OrderedCollections = "=1.8.1" +julia = "1.12" diff --git a/test/doi/bootstrap.jl b/test/doi/bootstrap.jl new file mode 100644 index 00000000..7b62f6de --- /dev/null +++ b/test/doi/bootstrap.jl @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: MPL-2.0 +# Load the actual publication implementation without importing the scientific/R stack. +module DOIIsolated +const SOURCE = joinpath(@__DIR__, "..", "..", "src", "doi") +include(joinpath(SOURCE, "Storage.jl")) +include(joinpath(SOURCE, "Zenodo.jl")) +include(joinpath(SOURCE, "Bundles.jl")) +include(joinpath(SOURCE, "Publications.jl")) +include(joinpath(SOURCE, "Web.jl")) +end diff --git a/test/doi/browser.test.js b/test/doi/browser.test.js new file mode 100644 index 00000000..3f5cbaf8 --- /dev/null +++ b/test/doi/browser.test.js @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MPL-2.0 +// Browser adapter contracts. HTML must be emitted by DOIWeb.render_page in the +// Julia suite (DOI_CONTRACT_ARTIFACTS). HTTP below is an explicitly synthetic API; +// no request can reach Zenodo, a real study, or a GitHub release. +import { test, expect, beforeAll, afterAll } from 'bun:test' +import { chromium, expect as browserExpect } from '@playwright/test' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const root = resolve(import.meta.dir, '../..') +const artifacts = process.env.DOI_CONTRACT_ARTIFACTS +const configId = '12345678-1234-4234-8234-123456789012' +let browser +beforeAll(async () => { + if (!artifacts) throw new Error('DOI_CONTRACT_ARTIFACTS must point to Julia-emitted publication HTML. This browser lane does not silently skip missing fixtures.') + browser = await chromium.launch({ + headless: true, ...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE } : {}), + args: ['--no-sandbox', '--disable-dev-shm-usage'], + }) +}, 30000) +afterAll(async () => { await browser?.close() }) + +function record(environment = 'sandbox') { + return { id: 'a'.repeat(64), state: 'ready', environment, metadata: { title: 'Reviewed fixture', license: 'CC-BY-4.0' }, + binding: { kind: 'configuration', config_id: configId, result_id: null, dangerous: false }, + bundle_sha256: 'b'.repeat(64), deposition_id: '101', reserved_doi: `${environment === 'sandbox' ? '10.5072' : '10.5281'}/zenodo.101`, + doi: null, doi_url: null, draft_url: `https://${environment === 'sandbox' ? 'sandbox.' : ''}zenodo.org/deposit/101`, + last_error: null, confirmation_phrase: `PUBLISH ${environment} 101`, test_record: environment === 'sandbox', + } +} +function published(row) { + return { ...row, state: 'published', doi: row.reserved_doi, doi_url: `https://doi.org/${row.reserved_doi}`, citation: `Example, Ada. Fixture. https://doi.org/${row.reserved_doi}` } +} + +async function fixture(fn, { initial = [], variant = 'publication', failPublish = false } = {}) { + let rows = structuredClone(initial) + const calls = [] + const html = readFileSync(join(artifacts, `${variant}.html`), 'utf8') + const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } }) + const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) { + const path = new URL(req.url).pathname + if (path === '/') return new Response(html, { headers: { 'Content-Type': 'text/html' } }) + if (path.startsWith('/api/v1/doi/assets/')) { + const file = path.split('/').at(-1) + if (!['publication.js', 'publication.css'].includes(file)) return new Response('', { status: 404 }) + return new Response(readFileSync(join(root, 'src/doi/assets', file)), { headers: { 'Content-Type': file.endsWith('.js') ? 'text/javascript' : 'text/css' } }) + } + if (req.method === 'GET') { + if (path.endsWith('/analysis-config')) return json({ configs: [{ id: configId, method: 'nb_glm', formula: '~ group', hash: 'c'.repeat(64), dangerous: false }] }) + if (path.endsWith('/results')) return json({ results: [{ id: 'mock-fixture', hash: 'd'.repeat(64), publishable: false }] }) + if (path.endsWith('/doi-publications')) return json({ publications: rows }) + if (path.includes('/download/')) return new Response('synthetic reviewed fixture') + } + const body = await req.json() + calls.push({ method: req.method, path, body, csrf: req.headers.get('X-DOI-CSRF') }) + if (path.endsWith('/doi-publications')) { + rows = [{ ...record(), metadata: body.metadata }] + return json(rows[0]) + } + if (path.endsWith('/publish')) { + if (failPublish) { + rows[0].state = 'publication_uncertain' + return json({ message: 'Lost Zenodo response. Reconcile the existing publication.' }, 502) + } + rows[0] = published(rows[0]) + return json(rows[0]) + } + if (path.endsWith('/refresh')) { rows[0] = published(rows[0]); return json(rows[0]) } + return json({ message: 'Unexpected fixture request' }, 500) + } }) + const context = await browser.newContext() + const page = await context.newPage() + const errors = [] + page.on('pageerror', error => errors.push(error.message)) + try { + await page.goto(`http://127.0.0.1:${server.port}`) + await browserExpect(page.locator('#config-id')).toContainText(configId) + await fn(page, calls) + expect(errors).toEqual([]) + } finally { await context.close(); server.stop(true) } +} + +const enable = page => page.getByRole('checkbox', { name: 'Enable Evidence Mode' }).check() +const publishes = calls => calls.filter(c => c.path.endsWith('/publish')) + +test('prepare is explicit, secret-free and not a publish operation', async () => { + await fixture(async (page, calls) => { + await browserExpect(page.locator('#advanced')).toBeHidden() + await page.setViewportSize({ width: 375, height: 812 }) + await enable(page) + await page.locator('#config-id').selectOption(configId) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await browserExpect(page.locator('#result-id option[value="mock-fixture"]')).toHaveJSProperty('disabled', true) + await page.locator('#result-id').selectOption('config-only') + await page.locator('#title').fill(' Citation fixture') + await page.locator('#description').fill('Configuration only, no results.') + await page.locator('#creators').fill('Example, Ada\nExample, Grace') + await page.locator('#license').selectOption('CC-BY-4.0') + await page.locator('#upload-ack').check() + await page.getByRole('button', { name: 'Prepare Zenodo draft' }).click() + await browserExpect(page.getByRole('button', { name: 'Mint DOI…' })).toBeVisible() + expect(calls.length).toBe(1) + expect(calls[0].body.result_id).toBeNull() + expect(calls[0].body.acknowledge_upload).toBe(true) + expect(calls[0].body.metadata.creators).toHaveLength(2) + expect(calls[0].csrf).toBe('fixture-csrf') + expect(JSON.stringify(calls)).not.toContain('access_token') + expect(publishes(calls)).toHaveLength(0) + await browserExpect(page.locator('#publications img')).toHaveCount(0) + await browserExpect(page.locator('.doi-badge')).toHaveCount(0) + await browserExpect(page.locator('#publications')).toContainText('not yet a published DOI') + }) +}, 30000) + +test('wrong phrase, missing acknowledgement and cancel cannot publish; correct confirmation posts once', async () => { + await fixture(async (page, calls) => { + await page.getByRole('button', { name: 'Mint DOI…' }).click() + await browserExpect(page.getByRole('dialog')).not.toBeVisible() + await enable(page) + await page.getByRole('button', { name: 'Mint DOI…' }).click() + await browserExpect(page.getByRole('dialog')).toBeVisible() + await browserExpect(page.getByRole('button', { name: 'Publish & mint DOI' })).toBeDisabled() + await page.locator('#confirmation').fill('PUBLISH production 101') + await page.locator('#public-ack').check() + await browserExpect(page.getByRole('button', { name: 'Publish & mint DOI' })).toBeDisabled() + await page.getByRole('button', { name: 'Cancel — keep draft' }).click() + expect(publishes(calls)).toHaveLength(0) + await page.getByRole('button', { name: 'Mint DOI…' }).click() + await page.locator('#confirmation').fill('PUBLISH sandbox 101') + await browserExpect(page.getByRole('button', { name: 'Publish & mint DOI' })).toBeDisabled() + await page.locator('#public-ack').check() + await page.getByRole('button', { name: 'Publish & mint DOI' }).click() + await browserExpect(page.locator('.doi-badge')).toHaveText('TEST DOI (sandbox): 10.5072/zenodo.101') + expect(publishes(calls)).toHaveLength(1) + expect(publishes(calls)[0].body).toEqual({ confirmation: 'PUBLISH sandbox 101', bundle_sha256: 'b'.repeat(64), acknowledge_public: true }) + await page.getByRole('button', { name: 'Reload saved publications' }).click() + expect(publishes(calls)).toHaveLength(1) + await browserExpect(page.getByRole('link', { name: 'Download provenance receipt' })).toBeVisible() + }, { initial: [record()] }) +}, 30000) + +test('lost publish response exposes reconciliation, not a retry-publish button', async () => { + await fixture(async (page, calls) => { + await enable(page) + await page.getByRole('button', { name: 'Mint DOI…' }).click() + await page.locator('#confirmation').fill('PUBLISH sandbox 101') + await page.locator('#public-ack').check() + await page.getByRole('button', { name: 'Publish & mint DOI' }).click() + await browserExpect(page.getByRole('alert')).toContainText('Lost Zenodo response') + await browserExpect(page.getByRole('button', { name: 'Mint DOI…' })).toHaveCount(0) + await browserExpect(page.locator('.doi-badge')).toHaveCount(0) + await page.getByRole('button', { name: 'Refresh from Zenodo' }).click() + await browserExpect(page.locator('.doi-badge')).toBeVisible() + expect(publishes(calls)).toHaveLength(1) + }, { initial: [record()], failPublish: true }) +}, 30000) + +test('production and sandbox badges are distinct, and disabled servers remain read-only', async () => { + await fixture(async (page, calls) => { + await browserExpect(page.locator('.production')).toContainText('real, permanent') + await browserExpect(page.locator('.doi-badge')).toHaveText('DOI: 10.5281/zenodo.101') + expect(calls).toHaveLength(0) + }, { initial: [published(record('production'))], variant: 'publication-production' }) + await fixture(async (page, calls) => { + await enable(page) + await browserExpect(page.locator('#disabled-notice')).toBeVisible() + await browserExpect(page.getByRole('button', { name: 'Prepare Zenodo draft' })).toBeDisabled() + await browserExpect(page.getByRole('button', { name: 'Mint DOI…' })).toBeDisabled() + expect(calls).toHaveLength(0) + }, { initial: [record()], variant: 'publication-disabled' }) +}, 30000) diff --git a/test/doi/bun.lock b/test/doi/bun.lock new file mode 100644 index 00000000..4f57b5d6 --- /dev/null +++ b/test/doi/bun.lock @@ -0,0 +1,36 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "metamanifold-doi-contracts", + "devDependencies": { + "@playwright/test": "1.63.0", + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "yaml": "2.8.2", + }, + }, + }, + "packages": { + "@playwright/test": ["@playwright/test@1.63.0", "", { "dependencies": { "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], + + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + } +} diff --git a/test/doi/check-nickel.sh b/test/doi/check-nickel.sh new file mode 100755 index 00000000..b06451d2 --- /dev/null +++ b/test/doi/check-nickel.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Requires actual artifacts from the Julia suite; no generated substitute. +set -euo pipefail +cd "$(dirname "$0")/../.." +: "${DOI_CONTRACT_ARTIFACTS:?Run the Julia lifecycle suite with an artifact directory first}" +nickel=${NICKEL:-nickel} +command -v "$nickel" >/dev/null || { echo 'Nickel is required for this contract gate' >&2; exit 1; } +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +contract=$(jq -nc --arg path "$PWD/config/schemas/doi_publication.ncl" '$path') +for env in sandbox production; do + file="$DOI_CONTRACT_ARTIFACTS/attestation-$env" + input=$(jq -nc --arg path "$file.ncl" '$path') + "$nickel" export --format json --expr "let C = import $contract in (import $input) | C" > "$tmp/export.json" + diff -u <(jq -S . "$file.json") <(jq -S . "$tmp/export.json") + for change in '.state="published"' '.config_hash="wrong"' '.doi="10.5281/zenodo.999"' '.kind="analysis_result" | .result_id=null'; do + jq "$change" "$file.json" > "$tmp/invalid.json" + invalid=$(jq -nc --arg path "$tmp/invalid.json" '$path') + if "$nickel" export --format json --expr "let C = import $contract in (import $invalid) | C" > /dev/null 2>&1; then + echo "Nickel accepted an invalid $env attestation ($change)" >&2; exit 1 + fi + done +done +printf 'Nickel attestation contracts and negative cases passed\n' diff --git a/test/doi/citation-validation.js b/test/doi/citation-validation.js new file mode 100644 index 00000000..ec363785 --- /dev/null +++ b/test/doi/citation-validation.js @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MPL-2.0 +import Ajv from 'ajv' +import addFormats from 'ajv-formats' +import { parse } from 'yaml' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const ajv = new Ajv({ allErrors: true, strict: false }) +addFormats(ajv) +const check = ajv.compile(JSON.parse(readFileSync(join(import.meta.dir, 'vendor/cff-1.2.0.schema.json'), 'utf8'))) +export function validateCitation(text) { + const data = parse(text) + if (!check(data)) throw new Error(`CFF 1.2.0: ${ajv.errorsText(check.errors)}`) + return data +} diff --git a/test/doi/fixtures.jl b/test/doi/fixtures.jl new file mode 100644 index 00000000..594c0f83 --- /dev/null +++ b/test/doi/fixtures.jl @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: MPL-2.0 +# Explicit synthetic contract fixtures, never scientific estimates or live deposits. +const FAKE_TOKEN = "not-a-real-token-DO-NOT-LOG" +const CONFIG_ID = "12345678-1234-4234-8234-123456789012" +const RESULT_ID = "12345678-1234-4234-8234-123456789013" + +function metadata_fixture() + Dict{String,Any}("title" => "Reproducible analysis fixture", "description" => "Synthetic publication protocol fixture, not a scientific result.", + "creators" => [Dict("name" => "Example, Ada", "orcid" => "0000-0002-1825-0097")], + "license" => "CC-BY-4.0", "publication_date" => "2026-01-01", "version" => "1.0.0", + "github_release_url" => "https://github.com/example/research/releases/tag/v1.0.0", + "github_project_url" => "https://github.com/users/example/projects/1") +end + +function bundle_fixture(root; result=false, mock=false, dangerous=false) + dir = joinpath(root, "bundle") + mkpath(dir) + cfg = Dict("id" => CONFIG_ID, "hash" => repeat("a", 64), "dangerous" => dangerous, + "method" => "nb_glm", "created_at" => "2026-01-01T00:00:00", "created_by" => "Example, Ada") + write(joinpath(dir, "analysis_config.json"), JSON3.write(cfg)) + write(joinpath(dir, "analysis_config.ncl"), "{ method = \"nb_glm\" }\n") + write(joinpath(dir, "analysis_config_chora.deed"), "(repo-deed :schema-version \"1.0.0\" :dangerous #f)\n") + write(joinpath(dir, "provenance.json"), "{\"fixture\":true}") + write(joinpath(dir, "datacite.json"), "{}") + write(joinpath(dir, "README.md"), "# Synthetic protocol fixture\n") + write(joinpath(dir, "content_hash.txt"), cfg["hash"]) + dangerous && write(joinpath(dir, "DANGER_BANNER.txt"), "DANGER: synthetic unsafe configuration fixture") + if result + write(joinpath(dir, "analysis_result.json"), JSON3.write(Dict("id" => RESULT_ID, + "config_id" => CONFIG_ID, "config_hash" => cfg["hash"], "hash" => repeat("b", 64), + "method" => "nb_glm", "provenance" => Dict("mock" => mock), + "results" => Dict("synthetic_fixture" => Dict("status" => "fixture"))))) + end + B.write_checksums!(dir) + return dir +end + +mutable struct FakeZenodo + deposit::Dict{String,Any} + calls::Vector{Tuple{String,String}} + uploaded::Vector{UInt8} + faults::Vector{Function} + publish_done::Bool + environment::String +end +FakeZenodo(; environment="sandbox") = FakeZenodo(Dict{String,Any}(), Tuple{String,String}[], UInt8[], Function[], true, environment) + +function fake_response(value; status=200, headers=Pair{String,String}[]) + HTTP.Response(status, headers; body=JSON3.write(value)) +end + +function transport(fake::FakeZenodo) + return function(method, url, headers, body) + @test get(Dict(headers), "Authorization", "") == "Bearer " * FAKE_TOKEN + @test !occursin(FAKE_TOKEN, url) + @test !occursin("access_token", url) + @test startswith(url, Z.ORIGINS[fake.environment] * "/api/") + push!(fake.calls, (method, url)) + if !isempty(fake.faults) + fault = popfirst!(fake.faults) + response = fault(method, url, body) + !isnothing(response) && return response + end + if method == "POST" && endswith(url, "/deposit/depositions") + @test isempty(fake.deposit) # a duplicate create is a test failure + metadata = JSON3.read(String(body), Dict{String,Any})["metadata"] + prefix = fake.environment == "sandbox" ? "10.5072/zenodo." : "10.5281/zenodo." + metadata["prereserve_doi"] = Dict("doi" => prefix * "101", "recid" => 101) + fake.deposit = Dict{String,Any}("id" => 101, "state" => "unsubmitted", "submitted" => false, + "metadata" => metadata, "files" => Any[], "links" => Dict("bucket" => Z.ORIGINS[fake.environment] * "/api/files/11111111-1111-4111-8111-111111111111")) + return fake_response(fake.deposit; status=201) + elseif method == "GET" && endswith(url, "/101") + return fake_response(fake.deposit) + elseif method == "PUT" && occursin("/api/files/", url) + @test body isa IO # streaming upload, not an in-memory ZIP body + fake.uploaded = read(body) + file = Dict{String,Any}("name" => last(split(url, '/')), "checksum" => bytes2hex(md5(fake.uploaded)), "filesize" => string(length(fake.uploaded))) + fake.deposit["files"] = [file] + return fake_response(Dict("key" => file["name"], "checksum" => "md5:" * file["checksum"], "size" => length(fake.uploaded)); status=201) + elseif method == "POST" && endswith(url, "/101/actions/publish") + if fake.publish_done + mark_published!(fake) + end + return fake_response(fake.deposit; status=202) + end + error("Unexpected synthetic Zenodo request: $method $url") + end +end + +function mark_published!(fake) + fake.deposit["state"] = "done" + fake.deposit["submitted"] = true + fake.deposit["doi"] = fake.deposit["metadata"]["prereserve_doi"]["doi"] + fake.deposit["record_id"] = 101 +end +client(fake) = Z.Client(FAKE_TOKEN; environment=fake.environment, transport=transport(fake), sleeper=_ -> nothing) +count_calls(fake, method, suffix) = count(c -> c[1] == method && endswith(c[2], suffix), fake.calls) + +function prepared_fixture(f; result=false, dangerous=false, environment="sandbox") + mktempdir() do tmp + source = bundle_fixture(tmp; result, dangerous) + root = joinpath(tmp, "publications") + fake = FakeZenodo(; environment) + c = client(fake) + prepared = P.prepare!(root, source, metadata_fixture(), c) + f(tmp, root, source, fake, c, prepared) + end +end + +function publish_fixture(root, prepared, c) + P.publish!(root, prepared["id"], c; confirmation=prepared["confirmation_phrase"], + bundle_sha256=prepared["bundle_sha256"], acknowledge_public=true) +end + +function captured_error(f) + try f(); nothing catch e; e end +end diff --git a/test/doi/linker.test.js b/test/doi/linker.test.js new file mode 100644 index 00000000..32a410c6 --- /dev/null +++ b/test/doi/linker.test.js @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MPL-2.0 +// These tests execute the real Bash/gh linker against an isolated fake gh binary. +import { describe, test, expect } from 'bun:test' +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { validateCitation } from './citation-validation.js' + +const root = resolve(import.meta.dir, '../..') +const id = 'a'.repeat(64) +const marker = `` +const receipt = () => ({ + schema_version: '1.0.0', id, state: 'published', environment: 'production', test_record: false, + doi: '10.5281/zenodo.101', reserved_doi: '10.5281/zenodo.101', doi_url: 'https://doi.org/10.5281/zenodo.101', + bundle_sha256: 'b'.repeat(64), + binding: { config_hash: 'c'.repeat(64), kind: 'configuration', dangerous: true }, + metadata: { title: 'Analysis citation', creators: [{ name: 'Example, Ada' }], version: '1.0.0', publication_date: '2026-01-01', license: 'CC-BY-4.0', + github_release_url: 'https://github.com/example/research/releases/tag/v1.0.0', github_project_url: 'https://github.com/users/example/projects/2' }, +}) +const gh = `#!/usr/bin/env bash +set -euo pipefail +state="$FAKE_GH_STATE" +jq -cn --args '$ARGS.positional' -- "$@" >> "$state/calls.jsonl" +if [[ "\${GH_FAIL:-}" == "$1 $2" ]]; then echo 'raw error FAKE-SENSITIVE-TOKEN' >&2; exit 1; fi +kind="$1 $2"; shift 2 +getarg() { local key="$1"; shift; while [[ $# -gt 0 ]]; do if [[ "$1" == "$key" ]]; then printf '%s' "$2"; return; fi; shift; done; } +case "$kind" in + 'release view') cat "$state/release.json" ;; + 'release edit') notes=$(getarg --notes-file "$@"); jq --rawfile body "$notes" '.body=$body' "$state/release.json" > "$state/new.json"; mv "$state/new.json" "$state/release.json" ;; + 'release upload') for arg in "$@"; do if [[ -f "$arg" ]]; then cp "$arg" "$state/assets/"; fi; done ;; + 'project --help') exit 0 ;; + 'project item-list') cat "$state/items.json" ;; + 'project item-create') body=$(getarg --body "$@"); jq --arg body "$body" '.items += [{id:"PVTI_fixture",content:{id:"DI_fixture",type:"DraftIssue",body:$body}}] | .totalCount=(.items|length)' "$state/items.json" > "$state/new.json"; mv "$state/new.json" "$state/items.json"; echo '{"id":"PVTI_fixture"}' ;; + 'project item-edit') [[ $(getarg --id "$@") == DI_fixture ]] || exit 1; body=$(getarg --body "$@"); jq --arg body "$body" '.items[0].content.body=$body' "$state/items.json" > "$state/new.json"; mv "$state/new.json" "$state/items.json" ;; + *) echo 'unexpected gh command' >&2; exit 1 ;; +esac +` + +function fixture(fn) { + const dir = mkdtempSync(join(tmpdir(), 'doi-linker-')) + try { + mkdirSync(join(dir, 'bin')); mkdirSync(join(dir, 'assets')) + writeFileSync(join(dir, 'bin/gh'), gh); chmodSync(join(dir, 'bin/gh'), 0o700) + writeFileSync(join(dir, 'calls.jsonl'), '') + writeFileSync(join(dir, 'receipt.json'), JSON.stringify(receipt())) + writeFileSync(join(dir, 'release.json'), JSON.stringify({ body: 'Original release notes.\nDo not remove.', url: receipt().metadata.github_release_url, isDraft: false })) + writeFileSync(join(dir, 'items.json'), JSON.stringify({ items: [], totalCount: 0 })) + const run = (apply = false, extraEnv = {}) => spawnSync('bash', [join(root, 'scripts/link-doi.sh'), '--receipt', join(dir, 'receipt.json'), ...(apply ? ['--apply'] : [])], { + encoding: 'utf8', env: { ...process.env, PATH: `${join(dir, 'bin')}:${process.env.PATH}`, FAKE_GH_STATE: dir, ...extraEnv }, + }) + const calls = () => readFileSync(join(dir, 'calls.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(s => JSON.parse(s)) + fn({ dir, run, calls }) + } finally { rmSync(dir, { recursive: true, force: true }) } +} + +describe('link an existing DOI without ever minting another', () => { + test('default dry run is validated, descriptive, and completely offline', () => fixture(({ run, calls }) => { + const out = run() + expect(out.status).toBe(0) + expect(out.stdout).toContain('DRY RUN') + expect(out.stdout).toContain('https://doi.org/10.5281/zenodo.101') + expect(out.stdout).toContain('Configuration only') + expect(calls()).toEqual([]) + })) + + test('apply preserves notes, attaches citable assets, and upserts a single project item', () => fixture(({ dir, run, calls }) => { + let out = run(true) + expect(out.stderr).toBe('') + expect(out.status).toBe(0) + out = run(true) + expect(out.stderr).toBe('') + expect(out.status).toBe(0) + const release = JSON.parse(readFileSync(join(dir, 'release.json'), 'utf8')) + expect(release.body).toContain('Original release notes.\nDo not remove.') + expect(release.body.split(marker).length - 1).toBe(1) + expect(release.body).toContain('DANGER') + const items = JSON.parse(readFileSync(join(dir, 'items.json'), 'utf8')) + expect(items.items.length).toBe(1) + expect(items.items[0].content.body).toContain(marker) + expect(calls().filter(c => c[0] === 'project' && c[1] === 'item-create').length).toBe(1) + expect(calls().filter(c => c[0] === 'project' && c[1] === 'item-edit').length).toBe(1) + expect(calls().some(c => c[0] === 'release' && c[1] === 'create')).toBe(false) + expect(readFileSync(join(dir, `assets/publication-${id}.json`), 'utf8')).toContain('10.5281/zenodo.101') + const citation = validateCitation(readFileSync(join(dir, `assets/citation-${id}.cff`), 'utf8')) + expect(citation.type).toBe('dataset') + expect(citation.doi).toBe('10.5281/zenodo.101') + })) + + test('sandbox, pending, malformed and foreign-link receipts fail before any gh invocation', () => { + for (const mutate of [r => { r.environment = 'sandbox' }, r => { r.state = 'ready' }, r => { r.reserved_doi = '10.5281/zenodo.102' }, + r => { r.metadata.github_release_url = 'https://attacker.example/a/b' }, r => { r.metadata.github_release_url += '?token=secret' }, r => { r.bundle_sha256 = '../bad' }]) { + fixture(({ dir, run, calls }) => { + const data = receipt(); mutate(data) + writeFileSync(join(dir, 'receipt.json'), JSON.stringify(data)) + expect(run(true).status).not.toBe(0) + expect(calls()).toEqual([]) + }) + } + }) + + test('draft releases and ambiguous release blocks are not edited', () => { + for (const value of [{ isDraft: true }, { body: marker }, { body: `${marker}\n${marker}\n` }]) { + fixture(({ dir, run, calls }) => { + writeFileSync(join(dir, 'release.json'), JSON.stringify({ url: receipt().metadata.github_release_url, body: '', isDraft: false, ...value })) + expect(run(true).status).not.toBe(0) + expect(calls().filter(c => c[0] === 'release' && c[1] === 'edit')).toEqual([]) + }) + } + }) + + test('an incomplete project listing cannot create a duplicate item', () => fixture(({ dir, run, calls }) => { + writeFileSync(join(dir, 'items.json'), JSON.stringify({ items: [], totalCount: 10001 })) + const out = run(true) + expect(out.status).not.toBe(0) + expect(out.stderr).toContain('incomplete') + expect(calls().filter(c => c[0] === 'project' && c[1] === 'item-create')).toEqual([]) + })) + + test('titles cannot inject managed markers or release Markdown', () => fixture(({ dir, run }) => { + const data = receipt(); data.metadata.title = marker + writeFileSync(join(dir, 'receipt.json'), JSON.stringify(data)) + expect(run(true).status).toBe(0) + expect(run(true).status).toBe(0) + const notes = JSON.parse(readFileSync(join(dir, 'release.json'), 'utf8')).body + expect(notes.split(marker).length - 1).toBe(1) + expect(notes).toContain('\\ +# Citation File Format test schema + +`cff-1.2.0.schema.json` is the unmodified Citation File Format 1.2.0 schema, +by the Citation File Format developers, from +. + +Upstream licence: **CC-BY-4.0**; a copy is in `LICENSES/CC-BY-4.0.txt`. +SHA-256: `0b8d22140da702d766df318dcff3a91af2f39521298dcf36d76315fd99cc169b`. + +Vendored solely so citation validation is reproducible and does not require a +network request or secret. This is upstream test data, not application code. diff --git a/test/doi/vendor/cff-1.2.0.schema.json b/test/doi/vendor/cff-1.2.0.schema.json new file mode 100644 index 00000000..762194be --- /dev/null +++ b/test/doi/vendor/cff-1.2.0.schema.json @@ -0,0 +1,1882 @@ +{ + "$id": "https://citation-file-format.github.io/1.2.0/schema.json", + "$schema": "http://json-schema.org/draft-07/schema", + "additionalProperties": false, + "definitions": { + "address": { + "description": "An address.", + "minLength": 1, + "type": "string" + }, + "alias": { + "description": "An alias.", + "minLength": 1, + "type": "string" + }, + "city": { + "description": "A city", + "minLength": 1, + "type": "string" + }, + "commit": { + "description": "The (e.g., Git) commit hash or (e.g., Subversion) revision number of the work.", + "minLength": 1, + "type": "string" + }, + "country": { + "$comment": "ISO 3166-1 alpha-2 codes can be found at https://en.wikipedia.org/wiki/ISO_3166-1", + "description": "The ISO 3166-1 alpha-2 country code for a country.", + "enum": [ + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CU", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IR", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KP", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SD", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SY", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW" + ], + "type": "string" + }, + "date": { + "$comment": "Note to tool implementers: it is necessary to cast YAML 'date' objects to string objects when validating against this schema.", + "examples": [ + "1900-01-01", + "2020-12-31" + ], + "format": "date", + "pattern": "^[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "doi": { + "description": "The DOI of the work (i.e., 10.5281/zenodo.1003150, not the resolver URL http://doi.org/10.5281/zenodo.1003150).", + "examples": [ + "10.5281/zenodo.1003150" + ], + "pattern": "^10\\.\\d{4,9}(\\.\\d+)?/[A-Za-z0-9:/_;\\-\\.\\(\\)\\[\\]\\\\]+$", + "type": "string" + }, + "email": { + "description": "An email address.", + "pattern": "^[\\S]+@[\\S]+\\.[\\S]{2,}$", + "type": "string" + }, + "entity": { + "additionalProperties": false, + "description": "An entity, i.e., an institution, team, research group, company, conference, etc., as opposed to a single natural person.", + "properties": { + "address": { + "$ref": "#/definitions/address", + "description": "The entity's address." + }, + "alias": { + "$ref": "#/definitions/alias", + "description": "The entity's alias." + }, + "city": { + "$ref": "#/definitions/city", + "description": "The entity's city." + }, + "country": { + "$ref": "#/definitions/country", + "description": "The entity's country." + }, + "date-end": { + "$ref": "#/definitions/date", + "description": "The entity's ending date, e.g., when the entity is a conference." + }, + "date-start": { + "$ref": "#/definitions/date", + "description": "The entity's starting date, e.g., when the entity is a conference." + }, + "email": { + "$ref": "#/definitions/email", + "description": "The entity's email address." + }, + "fax": { + "$ref": "#/definitions/fax", + "description": "The entity's fax number." + }, + "location": { + "description": "The entity's location, e.g., when the entity is a conference.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "The entity's name.", + "minLength": 1, + "type": "string" + }, + "orcid": { + "$ref": "#/definitions/orcid", + "description": "The entity's orcid." + }, + "post-code": { + "$ref": "#/definitions/post-code", + "description": "The entity's post code." + }, + "region": { + "$ref": "#/definitions/region", + "description": "The entity's region." + }, + "tel": { + "$ref": "#/definitions/tel", + "description": "The entity's telephone number." + }, + "website": { + "$ref": "#/definitions/url", + "description": "The entity's website." + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "fax": { + "description": "A fax number.", + "minLength": 1, + "type": "string" + }, + "identifier": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "doi" + ], + "type": "string" + }, + "value": { + "$ref": "#/definitions/doi" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "url" + ], + "type": "string" + }, + "value": { + "$ref": "#/definitions/url" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "swh" + ], + "type": "string" + }, + "value": { + "$ref": "#/definitions/swh-identifier" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/definitions/identifier-description" + }, + "type": { + "enum": [ + "other" + ], + "type": "string" + }, + "value": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + } + ], + "description": "An identifier for a work." + }, + "identifier-description": { + "description": "A description for a specific identifier value.", + "examples": [ + "The version DOI for this version, which has a relation childOf with the concept DOI specified in the doi field in the root of this file.", + "The identifier provided by Archival Repository, which points to this version of the software." + ], + "minLength": 1, + "type": "string" + }, + "license": { + "description": "An SPDX license identifier.", + "oneOf": [ + { + "$ref": "#/definitions/license-enum", + "examples": [ + "Apache-2.0", + "MIT" + ] + }, + { + "$comment": "When there are multiple licenses, it is assumed their relationship is OR, not AND", + "examples": [ + [ + "Apache-2.0", + "MIT" + ], + [ + "GPL-3.0", + "GPL-3.0-or-later" + ] + ], + "items": { + "$ref": "#/definitions/license-enum" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + }, + "license-enum": { + "$comment": "Use https://github.com/citation-file-format/get-spdx-licenses to update this enum in the future", + "description": "SPDX license list; releaseDate=2021-05-14; source=https://raw.githubusercontent.com/spdx/license-list-data/master/json/licenses.json", + "enum": [ + "0BSD", + "AAL", + "Abstyles", + "Adobe-2006", + "Adobe-Glyph", + "ADSL", + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "Afmparse", + "AGPL-1.0", + "AGPL-1.0-only", + "AGPL-1.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "Aladdin", + "AMDPLPA", + "AML", + "AMPAS", + "ANTLR-PD", + "ANTLR-PD-fallback", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APAFML", + "APL-1.0", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "Artistic-1.0", + "Artistic-1.0-cl8", + "Artistic-1.0-Perl", + "Artistic-2.0", + "Bahyph", + "Barr", + "Beerware", + "BitTorrent-1.0", + "BitTorrent-1.1", + "blessing", + "BlueOak-1.0.0", + "Borceux", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-2-Clause-Patent", + "BSD-2-Clause-Views", + "BSD-3-Clause", + "BSD-3-Clause-Attribution", + "BSD-3-Clause-Clear", + "BSD-3-Clause-LBNL", + "BSD-3-Clause-Modification", + "BSD-3-Clause-No-Nuclear-License", + "BSD-3-Clause-No-Nuclear-License-2014", + "BSD-3-Clause-No-Nuclear-Warranty", + "BSD-3-Clause-Open-MPI", + "BSD-4-Clause", + "BSD-4-Clause-Shortened", + "BSD-4-Clause-UC", + "BSD-Protection", + "BSD-Source-Code", + "BSL-1.0", + "BUSL-1.1", + "bzip2-1.0.5", + "bzip2-1.0.6", + "C-UDA-1.0", + "CAL-1.0", + "CAL-1.0-Combined-Work-Exception", + "Caldera", + "CATOSL-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-3.0", + "CC-BY-3.0-AT", + "CC-BY-3.0-US", + "CC-BY-4.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-4.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-ND-3.0-IGO", + "CC-BY-NC-ND-4.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-NC-SA-4.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-ND-4.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.0-UK", + "CC-BY-SA-2.1-JP", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC-BY-SA-3.0-AT", + "CC-BY-SA-4.0", + "CC-PDDC", + "CC0-1.0", + "CDDL-1.0", + "CDDL-1.1", + "CDL-1.0", + "CDLA-Permissive-1.0", + "CDLA-Sharing-1.0", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-2.1", + "CECILL-B", + "CECILL-C", + "CERN-OHL-1.1", + "CERN-OHL-1.2", + "CERN-OHL-P-2.0", + "CERN-OHL-S-2.0", + "CERN-OHL-W-2.0", + "ClArtistic", + "CNRI-Jython", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "Condor-1.1", + "copyleft-next-0.3.0", + "copyleft-next-0.3.1", + "CPAL-1.0", + "CPL-1.0", + "CPOL-1.02", + "Crossword", + "CrystalStacker", + "CUA-OPL-1.0", + "Cube", + "curl", + "D-FSL-1.0", + "diffmark", + "DOC", + "Dotseqn", + "DRL-1.0", + "DSDP", + "dvipdfm", + "ECL-1.0", + "ECL-2.0", + "eCos-2.0", + "EFL-1.0", + "EFL-2.0", + "eGenix", + "Entessa", + "EPICS", + "EPL-1.0", + "EPL-2.0", + "ErlPL-1.1", + "etalab-2.0", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "EUPL-1.2", + "Eurosym", + "Fair", + "Frameworx-1.0", + "FreeBSD-DOC", + "FreeImage", + "FSFAP", + "FSFUL", + "FSFULLR", + "FTL", + "GD", + "GFDL-1.1", + "GFDL-1.1-invariants-only", + "GFDL-1.1-invariants-or-later", + "GFDL-1.1-no-invariants-only", + "GFDL-1.1-no-invariants-or-later", + "GFDL-1.1-only", + "GFDL-1.1-or-later", + "GFDL-1.2", + "GFDL-1.2-invariants-only", + "GFDL-1.2-invariants-or-later", + "GFDL-1.2-no-invariants-only", + "GFDL-1.2-no-invariants-or-later", + "GFDL-1.2-only", + "GFDL-1.2-or-later", + "GFDL-1.3", + "GFDL-1.3-invariants-only", + "GFDL-1.3-invariants-or-later", + "GFDL-1.3-no-invariants-only", + "GFDL-1.3-no-invariants-or-later", + "GFDL-1.3-only", + "GFDL-1.3-or-later", + "Giftware", + "GL2PS", + "Glide", + "Glulxe", + "GLWTPL", + "gnuplot", + "GPL-1.0", + "GPL-1.0-only", + "GPL-1.0-or-later", + "GPL-1.0+", + "GPL-2.0", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-2.0+", + "GPL-3.0", + "GPL-3.0-only", + "GPL-3.0-or-later", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "GPL-3.0+", + "gSOAP-1.3b", + "HaskellReport", + "Hippocratic-2.1", + "HPND", + "HPND-sell-variant", + "HTMLTIDY", + "IBM-pibs", + "ICU", + "IJG", + "ImageMagick", + "iMatix", + "Imlib2", + "Info-ZIP", + "Intel", + "Intel-ACPI", + "Interbase-1.0", + "IPA", + "IPL-1.0", + "ISC", + "JasPer-2.0", + "JPNIC", + "JSON", + "LAL-1.2", + "LAL-1.3", + "Latex2e", + "Leptonica", + "LGPL-2.0", + "LGPL-2.0-only", + "LGPL-2.0-or-later", + "LGPL-2.0+", + "LGPL-2.1", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-2.1+", + "LGPL-3.0", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "LGPL-3.0+", + "LGPLLR", + "Libpng", + "libpng-2.0", + "libselinux-1.0", + "libtiff", + "LiLiQ-P-1.1", + "LiLiQ-R-1.1", + "LiLiQ-Rplus-1.1", + "Linux-OpenIB", + "LPL-1.0", + "LPL-1.02", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3a", + "LPPL-1.3c", + "MakeIndex", + "MirOS", + "MIT", + "MIT-0", + "MIT-advertising", + "MIT-CMU", + "MIT-enna", + "MIT-feh", + "MIT-Modern-Variant", + "MIT-open-group", + "MITNFA", + "Motosoto", + "mpich2", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "MS-PL", + "MS-RL", + "MTLL", + "MulanPSL-1.0", + "MulanPSL-2.0", + "Multics", + "Mup", + "NAIST-2003", + "NASA-1.3", + "Naumen", + "NBPL-1.0", + "NCGL-UK-2.0", + "NCSA", + "Net-SNMP", + "NetCDF", + "Newsletr", + "NGPL", + "NIST-PD", + "NIST-PD-fallback", + "NLOD-1.0", + "NLPL", + "Nokia", + "NOSL", + "Noweb", + "NPL-1.0", + "NPL-1.1", + "NPOSL-3.0", + "NRL", + "NTP", + "NTP-0", + "Nunit", + "O-UDA-1.0", + "OCCT-PL", + "OCLC-2.0", + "ODbL-1.0", + "ODC-By-1.0", + "OFL-1.0", + "OFL-1.0-no-RFN", + "OFL-1.0-RFN", + "OFL-1.1", + "OFL-1.1-no-RFN", + "OFL-1.1-RFN", + "OGC-1.0", + "OGDL-Taiwan-1.0", + "OGL-Canada-2.0", + "OGL-UK-1.0", + "OGL-UK-2.0", + "OGL-UK-3.0", + "OGTSL", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.2.2", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OLDAP-2.8", + "OML", + "OpenSSL", + "OPL-1.0", + "OSET-PL-2.1", + "OSL-1.0", + "OSL-1.1", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "Parity-6.0.0", + "Parity-7.0.0", + "PDDL-1.0", + "PHP-3.0", + "PHP-3.01", + "Plexus", + "PolyForm-Noncommercial-1.0.0", + "PolyForm-Small-Business-1.0.0", + "PostgreSQL", + "PSF-2.0", + "psfrag", + "psutils", + "Python-2.0", + "Qhull", + "QPL-1.0", + "Rdisc", + "RHeCos-1.1", + "RPL-1.1", + "RPL-1.5", + "RPSL-1.0", + "RSA-MD", + "RSCPL", + "Ruby", + "SAX-PD", + "Saxpath", + "SCEA", + "Sendmail", + "Sendmail-8.23", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "SHL-0.5", + "SHL-0.51", + "SimPL-2.0", + "SISSL", + "SISSL-1.2", + "Sleepycat", + "SMLNJ", + "SMPPL", + "SNIA", + "Spencer-86", + "Spencer-94", + "Spencer-99", + "SPL-1.0", + "SSH-OpenSSH", + "SSH-short", + "SSPL-1.0", + "StandardML-NJ", + "SugarCRM-1.1.3", + "SWL", + "TAPR-OHL-1.0", + "TCL", + "TCP-wrappers", + "TMate", + "TORQUE-1.1", + "TOSL", + "TU-Berlin-1.0", + "TU-Berlin-2.0", + "UCL-1.0", + "Unicode-DFS-2015", + "Unicode-DFS-2016", + "Unicode-TOU", + "Unlicense", + "UPL-1.0", + "Vim", + "VOSTROM", + "VSL-1.0", + "W3C", + "W3C-19980720", + "W3C-20150513", + "Watcom-1.0", + "Wsuipa", + "WTFPL", + "wxWindows", + "X11", + "Xerox", + "XFree86-1.1", + "xinetd", + "Xnet", + "xpp", + "XSkat", + "YPL-1.0", + "YPL-1.1", + "Zed", + "Zend-2.0", + "Zimbra-1.3", + "Zimbra-1.4", + "Zlib", + "zlib-acknowledgement", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1" + ], + "type": "string" + }, + "orcid": { + "description": "Identifier for an author, see https://orcid.org.", + "format": "uri", + "pattern": "https://orcid\\.org/[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]{1}", + "type": "string" + }, + "person": { + "additionalProperties": false, + "description": "A person.", + "properties": { + "address": { + "$ref": "#/definitions/address", + "description": "The person's address." + }, + "affiliation": { + "description": "The person's affilitation.", + "minLength": 1, + "type": "string" + }, + "alias": { + "$ref": "#/definitions/alias", + "description": "The person's alias." + }, + "city": { + "$ref": "#/definitions/city", + "description": "The person's city." + }, + "country": { + "$ref": "#/definitions/country", + "description": "The person's country." + }, + "email": { + "$ref": "#/definitions/email", + "description": "The person's email address." + }, + "family-names": { + "description": "The person's family names.", + "minLength": 1, + "type": "string" + }, + "fax": { + "$ref": "#/definitions/fax", + "description": "The person's fax number." + }, + "given-names": { + "description": "The person's given names.", + "minLength": 1, + "type": "string" + }, + "name-particle": { + "description": "The person's name particle, e.g., a nobiliary particle or a preposition meaning 'of' or 'from' (for example 'von' in 'Alexander von Humboldt').", + "examples": [ + "von" + ], + "minLength": 1, + "type": "string" + }, + "name-suffix": { + "description": "The person's name-suffix, e.g. 'Jr.' for Sammy Davis Jr. or 'III' for Frank Edwin Wright III.", + "examples": [ + "Jr.", + "III" + ], + "minLength": 1, + "type": "string" + }, + "orcid": { + "$ref": "#/definitions/orcid", + "description": "The person's ORCID." + }, + "post-code": { + "$ref": "#/definitions/post-code", + "description": "The person's post-code." + }, + "region": { + "$ref": "#/definitions/region", + "description": "The person's region." + }, + "tel": { + "$ref": "#/definitions/tel", + "description": "The person's phone number." + }, + "website": { + "$ref": "#/definitions/url", + "description": "The person's website." + } + }, + "type": "object" + }, + "post-code": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "A post code." + }, + "reference": { + "additionalProperties": false, + "description": "A reference to a work.", + "properties": { + "abbreviation": { + "description": "The abbreviation of a work.", + "minLength": 1, + "type": "string" + }, + "abstract": { + "description": "The abstract of a work.", + "minLength": 1, + "type": "string" + }, + "authors": { + "description": "The author(s) of a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "collection-doi": { + "$ref": "#/definitions/doi", + "description": "The DOI of a collection containing the work." + }, + "collection-title": { + "description": "The title of a collection or proceedings.", + "minLength": 1, + "type": "string" + }, + "collection-type": { + "description": "The type of a collection.", + "minLength": 1, + "type": "string" + }, + "commit": { + "$ref": "#/definitions/commit" + }, + "conference": { + "$ref": "#/definitions/entity", + "description": "The conference where the work was presented." + }, + "contact": { + "description": "The contact person, group, company, etc. for a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "copyright": { + "description": "The copyright information pertaining to the work.", + "minLength": 1, + "type": "string" + }, + "data-type": { + "description": "The data type of a data set.", + "minLength": 1, + "type": "string" + }, + "database": { + "description": "The name of the database where a work was accessed/is stored.", + "minLength": 1, + "type": "string" + }, + "database-provider": { + "$ref": "#/definitions/entity", + "description": "The provider of the database where a work was accessed/is stored." + }, + "date-accessed": { + "$ref": "#/definitions/date", + "description": "The date the work was accessed." + }, + "date-downloaded": { + "$ref": "#/definitions/date", + "description": "The date the work has been downloaded." + }, + "date-published": { + "$ref": "#/definitions/date", + "description": "The date the work has been published." + }, + "date-released": { + "$ref": "#/definitions/date", + "description": "The date the work has been released." + }, + "department": { + "description": "The department where a work has been produced.", + "minLength": 1, + "type": "string" + }, + "doi": { + "$ref": "#/definitions/doi", + "description": "The DOI of the work." + }, + "edition": { + "description": "The edition of the work.", + "minLength": 1, + "type": "string" + }, + "editors": { + "description": "The editor(s) of a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "editors-series": { + "description": "The editor(s) of a series in which a work has been published.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "end": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The end page of the work." + }, + "entry": { + "description": "An entry in the collection that constitutes the work.", + "minLength": 1, + "type": "string" + }, + "filename": { + "description": "The name of the electronic file containing the work.", + "minLength": 1, + "type": "string" + }, + "format": { + "description": "The format in which a work is represented.", + "minLength": 1, + "type": "string" + }, + "identifiers": { + "description": "The identifier(s) of the work.", + "items": { + "$ref": "#/definitions/identifier" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "institution": { + "$ref": "#/definitions/entity", + "description": "The institution where a work has been produced or published." + }, + "isbn": { + "description": "The ISBN of the work.", + "pattern": "^[0-9\\- ]{10,17}X?$", + "type": "string" + }, + "issn": { + "description": "The ISSN of the work.", + "pattern": "^\\d{4}-\\d{3}[\\dxX]$", + "type": "string" + }, + "issue": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The issue of a periodical in which a work appeared." + }, + "issue-date": { + "description": "The publication date of the issue of a periodical in which a work appeared.", + "minLength": 1, + "type": "string" + }, + "issue-title": { + "description": "The name of the issue of a periodical in which the work appeared.", + "minLength": 1, + "type": "string" + }, + "journal": { + "description": "The name of the journal/magazine/newspaper/periodical where the work was published.", + "minLength": 1, + "type": "string" + }, + "keywords": { + "description": "Keywords pertaining to the work.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "languages": { + "description": "The language identifier(s) of the work according to ISO 639 language strings.", + "items": { + "maxLength": 3, + "minLength": 2, + "pattern": "^[a-z]{2,3}$", + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "license": { + "$ref": "#/definitions/license" + }, + "license-url": { + "$ref": "#/definitions/url", + "description": "The URL of the license text under which the work is licensed (only for non-standard licenses not included in the SPDX License List)." + }, + "loc-end": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The line of code in the file where the work ends." + }, + "loc-start": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The line of code in the file where the work starts." + }, + "location": { + "$ref": "#/definitions/entity", + "description": "The location of the work." + }, + "medium": { + "description": "The medium of the work.", + "minLength": 1, + "type": "string" + }, + "month": { + "anyOf": [ + { + "maximum": 12, + "minimum": 1, + "type": "integer" + }, + { + "enum": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12" + ], + "type": "string" + } + ], + "description": "The month in which a work has been published." + }, + "nihmsid": { + "description": "The NIHMSID of a work.", + "minLength": 1, + "type": "string" + }, + "notes": { + "description": "Notes pertaining to the work.", + "minLength": 1, + "type": "string" + }, + "number": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The accession number for a work." + }, + "number-volumes": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The number of volumes making up the collection in which the work has been published." + }, + "pages": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The number of pages of the work." + }, + "patent-states": { + "description": "The states for which a patent is granted.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "pmcid": { + "description": "The PMCID of a work.", + "pattern": "^PMC[0-9]{7}$", + "type": "string" + }, + "publisher": { + "$ref": "#/definitions/entity", + "description": "The publisher who has published the work." + }, + "recipients": { + "description": "The recipient(s) of a personal communication.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/entity" + }, + { + "$ref": "#/definitions/person" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "repository": { + "$ref": "#/definitions/url", + "description": "The URL of the work in a repository (when the repository is neither a source code repository nor a build artifact repository)." + }, + "repository-artifact": { + "$ref": "#/definitions/url", + "description": "The URL of the work in a build artifact/binary repository." + }, + "repository-code": { + "$ref": "#/definitions/url", + "description": "The URL of the work in a source code repository." + }, + "scope": { + "description": "The scope of the reference, e.g., the section of the work it adheres to.", + "minLength": 1, + "type": "string" + }, + "section": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ], + "description": "The section of a work that is referenced." + }, + "senders": { + "description": "The sender(s) of a personal communication.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/entity" + }, + { + "$ref": "#/definitions/person" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "start": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The start page of the work." + }, + "status": { + "description": "The publication status of the work.", + "enum": [ + "abstract", + "advance-online", + "in-preparation", + "in-press", + "preprint", + "submitted" + ], + "type": "string" + }, + "term": { + "description": "The term being referenced if the work is a dictionary or encyclopedia.", + "minLength": 1, + "type": "string" + }, + "thesis-type": { + "description": "The type of the thesis that is the work.", + "minLength": 1, + "type": "string" + }, + "title": { + "description": "The title of the work.", + "minLength": 1, + "type": "string" + }, + "translators": { + "description": "The translator(s) of a work.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/entity" + }, + { + "$ref": "#/definitions/person" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "type": { + "description": "The type of the work.", + "enum": [ + "art", + "article", + "audiovisual", + "bill", + "blog", + "book", + "catalogue", + "conference-paper", + "conference", + "data", + "database", + "dictionary", + "edited-work", + "encyclopedia", + "film-broadcast", + "generic", + "government-document", + "grant", + "hearing", + "historical-work", + "legal-case", + "legal-rule", + "magazine-article", + "manual", + "map", + "multimedia", + "music", + "newspaper-article", + "pamphlet", + "patent", + "personal-communication", + "proceedings", + "report", + "serial", + "slides", + "software-code", + "software-container", + "software-executable", + "software-virtual-machine", + "software", + "sound-recording", + "standard", + "statute", + "thesis", + "unpublished", + "video", + "website" + ], + "type": "string" + }, + "url": { + "$ref": "#/definitions/url", + "description": "The URL of the work." + }, + "version": { + "$ref": "#/definitions/version", + "description": "The version of the work." + }, + "volume": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The volume of the periodical in which a work appeared." + }, + "volume-title": { + "description": "The title of the volume in which the work appeared.", + "minLength": 1, + "type": "string" + }, + "year": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The year in which a work has been published." + }, + "year-original": { + "anyOf": [ + { + "type": "integer" + }, + { + "minLength": 1, + "type": "string" + } + ], + "description": "The year of the original publication." + } + }, + "required": [ + "authors", + "title", + "type" + ], + "type": "object" + }, + "region": { + "description": "A region.", + "minLength": 1, + "type": "string" + }, + "swh-identifier": { + "$comment": "Software Heritage identifiers are documented here: https://docs.softwareheritage.org/devel/swh-model/persistent-identifiers.html.", + "description": "The Software Heritage identifier (without further qualifiers such as origin, visit, anchor, path).", + "examples": [ + "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2", + "swh:1:dir:d198bc9d7a6bcf6db04f476d29314f157507d505", + "swh:1:rev:309cf2674ee7a0749978cf8265ab91a60aea0f7d", + "swh:1:rel:22ece559cc7cc2364edc5e5593d63ae8bd229f9f", + "swh:1:snp:c7c108084bc0bf3d81436bf980b46e98bd338453" + ], + "pattern": "^swh:1:(snp|rel|rev|dir|cnt):[0-9a-fA-F]{40}$", + "type": "string" + }, + "tel": { + "description": "A phone number.", + "minLength": 1, + "type": "string" + }, + "url": { + "format": "uri", + "pattern": "^(https|http|ftp|sftp)://.+", + "type": "string" + }, + "version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "description": "A file with citation metadata for software or datasets.", + "properties": { + "abstract": { + "description": "A description of the software or dataset.", + "minLength": 1, + "type": "string" + }, + "authors": { + "description": "The author(s) of the software or dataset.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "cff-version": { + "description": "The version of CFF used for providing the citation metadata.", + "examples": [ + "1.2.0" + ], + "pattern": "^1\\.2\\.0$", + "type": "string" + }, + "commit": { + "$ref": "#/definitions/commit" + }, + "contact": { + "description": "The contact person, group, company, etc. for the software or dataset.", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/person" + }, + { + "$ref": "#/definitions/entity" + } + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "date-released": { + "$ref": "#/definitions/date", + "description": "The date the work has been released." + }, + "doi": { + "$ref": "#/definitions/doi" + }, + "identifiers": { + "description": "The identifiers of the software or dataset.", + "items": { + "$ref": "#/definitions/identifier" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "keywords": { + "description": "Keywords that describe the work.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "license": { + "$ref": "#/definitions/license" + }, + "license-url": { + "$ref": "#/definitions/url", + "description": "The URL of the license text under which the software or dataset is licensed (only for non-standard licenses not included in the SPDX License List)." + }, + "message": { + "default": "If you use this software, please cite it using the metadata from this file.", + "description": "A message to the human reader of the file to let them know what to do with the citation metadata.", + "examples": [ + "If you use this software, please cite it using the metadata from this file.", + "Please cite this software using these metadata.", + "Please cite this software using the metadata from 'preferred-citation'." + ], + "minLength": 1, + "type": "string" + }, + "preferred-citation": { + "$ref": "#/definitions/reference", + "description": "A reference to another work that should be cited instead of the software or dataset itself." + }, + "references": { + "description": "Reference(s) to other creative works.", + "items": { + "$ref": "#/definitions/reference" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "repository": { + "$ref": "#/definitions/url", + "description": "The URL of the software or dataset in a repository (when the repository is neither a source code repository nor a build artifact repository).", + "examples": [ + "https://edoc.hu-berlin.de/handle/18452/23016", + "https://ascl.net/2105.013" + ] + }, + "repository-artifact": { + "$ref": "#/definitions/url", + "description": "The URL of the software in a build artifact/binary repository." + }, + "repository-code": { + "$ref": "#/definitions/url", + "description": "The URL of the software or dataset in a source code repository." + }, + "title": { + "description": "The name of the software or dataset.", + "minLength": 1, + "type": "string" + }, + "type": { + "default": "software", + "description": "The type of the work.", + "enum": [ + "dataset", + "software" + ], + "type": "string" + }, + "url": { + "$ref": "#/definitions/url", + "description": "The URL of a landing page/website for the software or dataset." + }, + "version": { + "$ref": "#/definitions/version", + "description": "The version of the software or dataset." + } + }, + "required": [ + "authors", + "cff-version", + "message", + "title" + ], + "title": "Citation File Format", + "type": "object" +} diff --git a/test/runtests.jl b/test/runtests.jl index 8eef0f69..db8d2863 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -64,6 +64,8 @@ using MetaManifold: AnalysisConfig include("unit/test_install_pins.jl") include("unit/test_migrate_composition.jl") include("unit/test_analysis_config.jl") + include("unit/test_doi.jl") + include("unit/test_doi_routes.jl") include("unit/test_execution.jl") include("unit/test_scaling.jl") include("unit/test_estimation.jl") diff --git a/test/unit/test_doi.jl b/test/unit/test_doi.jl new file mode 100644 index 00000000..1fd0c002 --- /dev/null +++ b/test/unit/test_doi.jl @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: MPL-2.0 +# The same contracts also run inside the full scientific application environment. +using MetaManifold: DOIStorage, DOIBundles, Zenodo, DOIPublications, DOIWeb, AnalysisStore +include(joinpath(@__DIR__, "..", "doi", "tests.jl")) + +@testset "Analysis persistence and actual DOI bundles" begin + using OrderedCollections + for topic in ("doi", "doi.environment", "doi.confirmation", "doi.recovery", "doi.github") + @test !startswith(AnalysisConfig.context_help(topic), "No help available") + end + mktempdir() do tmp + cfg = AnalysisConfig.AnalysisConfigStruct(method="nb_glm", formula="~ group", + metadata_columns=["group"], created_by="Example, Ada") + original = AnalysisConfig.to_json(cfg) + store = joinpath(tmp, "analysis") + AnalysisStore.save_config!(store, cfg) + reloaded = AnalysisStore.configs(store)[cfg.id] + @test reloaded.hash == cfg.hash + @test AnalysisConfig.to_json(reloaded) == original + @test AnalysisStore.save_config!(store, cfg) == cfg.id + result = AnalysisConfig.AnalysisResult(config_id=cfg.id, config_hash=cfg.hash, method=cfg.method, + results=OrderedDict{String,Any}("z_fixture" => Dict("status" => "fixture"), "a_fixture" => Dict("status" => "fixture"))) + AnalysisStore.save_result!(store, result) + @test AnalysisStore.results(store)[result.id].hash == result.hash + bundle = AnalysisConfig.create_doi_bundle(reloaded, result; output_dir=joinpath(tmp, "bundle")) + @test DOIBundles.verify_checksums(bundle) + @test DOIBundles.snapshot(bundle)["result_id"] == result.id + @test_throws ArgumentError AnalysisConfig.create_doi_bundle(cfg; output_dir=bundle) + wrong = AnalysisConfig.AnalysisResult(config_id=cfg.id, config_hash=repeat("a", 64), method=cfg.method) + @test_throws ArgumentError AnalysisConfig.create_doi_bundle(cfg, wrong; output_dir=joinpath(tmp, "wrong")) + # Persistence reads do not trust hashes supplied in stored JSON. + path = joinpath(store, "configs", cfg.id * ".json") + data = JSON3.read(read(path, String), Dict{String,Any}) + data["formula"] = "~ group + batch" + DOIStorage.atomic_json(path, data) + @test_throws DOIStorage.PublicationError AnalysisStore.configs(store) + end +end diff --git a/test/unit/test_doi_routes.jl b/test/unit/test_doi_routes.jl new file mode 100644 index 00000000..771c3bb9 --- /dev/null +++ b/test/unit/test_doi_routes.jl @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: MPL-2.0 +using HTTP, Oxygen +using MetaManifold: DOIStorage, AnalysisStore, DOIBundles, DOIPublications + +if !isdefined(Main, :Server) + include(joinpath(@__DIR__, "..", "..", "src", "server", "server.jl")) +end + +@testset "DOI HTTP boundary and persistence" begin + server = Main.Server + oldroot = server.ServerState._root[] + factory = server._DOI_CLIENT_FACTORY[] + fake = Main.DOIContractTests.FakeZenodo() + server._DOI_CLIENT_FACTORY[] = () -> Main.DOIContractTests.client(fake) + headers = ["Host" => "localhost:8080", "Origin" => "http://localhost:8080", + "Content-Type" => "application/json", "X-DOI-CSRF" => server._DOI_CSRF] + call(method, path, body=nothing; hs=headers) = Oxygen.internalrequest(HTTP.Request(method, path, hs, + isnothing(body) ? "" : JSON3.write(body)); catch_errors=false) + decode(response) = JSON3.read(String(response.body), Dict{String,Any}) + try + mktempdir() do tmp + mkpath(joinpath(tmp, "data", "example")) + server.ServerState.set_root!(tmp) + base = "/api/v1/studies/example" + @test call("GET", "/api/v1/studies/nope/doi-ui").status == 404 + response = call("GET", base * "/doi-ui") + @test response.status == 200 + @test occursin("text/html", HTTP.header(response, "Content-Type")) + @test occursin("script-src 'self'", HTTP.header(response, "Content-Security-Policy")) + @test HTTP.header(response, "Cache-Control") == "no-store" + @test call("GET", "/api/v1/doi/assets/publication.js").status == 200 + @test call("GET", "/api/v1/doi/assets/secrets.env").status == 404 + @test decode(call("GET", "/api/v1/doi/capabilities"))["enabled"] + @test call("POST", base * "/doi-publications", Dict(); hs=["Content-Type" => "application/json"]).status == 403 + foreign = [h for h in headers if first(h) != "Origin"] + push!(foreign, "Origin" => "https://attacker.example") + @test call("POST", base * "/doi-publications", Dict(); hs=foreign).status == 403 + badtype = [h for h in headers if first(h) != "Content-Type"] + push!(badtype, "Content-Type" => "text/plain") + @test call("POST", base * "/doi-publications", Dict(); hs=badtype).status == 415 + @test call("POST", base * "/doi-publications", Dict("token" => "never-send-this")).status == 400 + @test isempty(fake.calls) + # The existing creation API now writes a durable config, and keeps + # fields which were previously silently dropped by the route parser. + created = call("POST", base * "/analysis-config", Dict("method" => "nb_glm", "formula" => "~ group", "metadata_columns" => ["group"], + "created_by" => "Example, Ada", "normalization" => Dict("method" => "size_factors", "epsilon" => 0.00001), + "advanced" => Dict("pseudocount" => 0.7, "epsilon" => 0.00002))) + @test created.status == 200 + cfg = decode(created)["config"] + @test cfg["normalization"]["epsilon"] == 0.00001 + @test cfg["advanced"]["pseudocount"] == 0.7 + @test isfile(joinpath(tmp, "projects", "example", ".analysis", "configs", cfg["id"] * ".json")) + @test only(decode(call("GET", base * "/analysis-config"))["configs"])["hash"] == cfg["hash"] + @test isempty(decode(call("GET", base * "/analysis-config/" * cfg["id"] * "/results"))["results"]) + request = Dict{String,Any}("config_id" => cfg["id"], "result_id" => nothing, + "metadata" => Main.DOIContractTests.metadata_fixture(), "acknowledge_upload" => true) + missing = copy(request); delete!(missing, "result_id") + @test call("POST", base * "/doi-publications", missing).status == 422 + @test call("POST", base * "/doi-publications", merge(request, Dict("acknowledge_upload" => false))).status == 422 + @test isempty(fake.calls) + # Legacy mock endpoint is clearly marked, persists as such, and cannot + # be elevated to a public scientific result by the publication API. + mock_response = call("POST", base * "/analysis-config/" * cfg["id"] * "/run", Dict()) + @test mock_response.status == 200 + result_id = decode(mock_response)["result"]["id"] + listed = only(decode(call("GET", base * "/analysis-config/" * cfg["id"] * "/results"))["results"]) + @test !listed["publishable"] + @test call("POST", base * "/doi-publications", merge(request, Dict("result_id" => result_id))).status == 422 + @test isempty(fake.calls) + prepared_response = call("POST", base * "/doi-publications", request) + @test prepared_response.status == 200 + prepared = decode(prepared_response) + @test prepared["state"] == "ready" + @test prepared["binding"]["result_id"] === nothing # never silently includes mock/latest result + publication = base * "/doi-publications/" * prepared["id"] + @test call("POST", publication * "/publish", Dict()).status == 422 + @test Main.DOIContractTests.count_calls(fake, "POST", "/actions/publish") == 0 + archive_response = call("GET", publication * "/download/bundle") + @test archive_response.status == 200 + @test HTTP.header(archive_response, "Content-Length") == string(prepared["bundle_size"]) + archive_bytes = IOBuffer() + write(archive_bytes, archive_response.body) + @test bytes2hex(sha256(take!(archive_bytes))) == prepared["bundle_sha256"] + @test call("GET", publication * "/download/receipt").status == 409 + @test call("GET", publication * "/download/state.json").status == 404 + # Study-level destruction and generic file serving cannot erase or + # leak the private journal; the explicit downloads remain available. + @test call("DELETE", base).status == 409 + @test call("POST", base * "/rename", Dict("name" => "other")).status == 409 + @test call("DELETE", base * "/analysis-config/" * cfg["id"]).status == 409 + middleware = server._file_middleware(_ -> HTTP.Response(200)) + @test middleware(HTTP.Request("GET", "/files/example/runs/.doi/" * prepared["id"] * "/state.json")).status == 403 + @test middleware(HTTP.Request("GET", "/files/example/runs/%2Eanalysis/configs/" * cfg["id"] * ".json")).status == 403 + @test middleware(HTTP.Request("GET", "/files/example%2F.doi/runs/" * prepared["id"] * "/state.json")).status == 403 + symlink(joinpath(tmp, "projects", "example", ".doi", prepared["id"], "state.json"), joinpath(tmp, "projects", "example", "public-alias.json")) + @test middleware(HTTP.Request("GET", "/files/example/runs/public-alias.json")).status == 403 + @test middleware(HTTP.Request("GET", "/../projects/example/.doi/state.json")).status == 403 + @test middleware(HTTP.Request("GET", "/%2E%2E/projects/example/.doi/state.json")).status == 403 + # Preview/same-origin proxy requests work without wildcard CORS. + preview_headers = ["Host" => "8080-test.e2b.app", "Origin" => "https://8080-test.e2b.app"] + cors = server._cors_middleware(_ -> HTTP.Response(200)) + @test cors(HTTP.Request("GET", "/api/v1/doi/capabilities", preview_headers)).status == 200 + @test cors(HTTP.Request("GET", "/api/v1/doi/capabilities", foreign)).status == 403 + published_response = call("POST", publication * "/publish", Dict("confirmation" => prepared["confirmation_phrase"], + "bundle_sha256" => prepared["bundle_sha256"], "acknowledge_public" => true)) + @test published_response.status == 200 + @test decode(published_response)["state"] == "published" + @test call("GET", publication * "/download/receipt").status == 200 + @test call("GET", publication * "/download/citation").status == 200 + # Restarts/disabled credentials do not remove access to existing receipts. + server._DOI_CLIENT_FACTORY[] = () -> throw(ArgumentError("disabled")) + @test !decode(call("GET", "/api/v1/doi/capabilities"))["enabled"] + @test call("GET", publication).status == 200 + @test call("GET", publication * "/download/receipt").status == 200 + @test call("POST", publication * "/refresh", Dict()).status == 503 + end + finally + server.ServerState._root[] = oldroot + server._DOI_CLIENT_FACTORY[] = factory + end +end