commit f9cf30bc7246e08ddd1e2070728474940b2179c6 Author: reachableceo Date: Sat Aug 29 06:12:22 2026 -0500 v0: stdlib-only Discourse client โ€” categories/topics/posts + raw passthrough Covers the MOPAC briefing/report surface (Redmine 495 Part A): category list/create (create needs the admin-scoped key; current key 403s, typed as ErrForbidden), topic create/list/latest/get, post create/update/get, current-user identity probe, and a Do() JSON passthrough so unmodeled endpoints need no client release. Key is env/constructor-only, never a flag, never logged; errors classify via errors.Is. Fake-server unit tests + containerized end-to-end smoke (redaction sweep included); live reads verified against community.turnsys.com. ๐Ÿ’˜ Generated with Crush Assisted-by: Crush:glm-5.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff55937 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +.smoke/ +*.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cce9a33 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +# Makefile: thin front door over dev.sh so `make build/vet/test/check/smoke` +# route through the digest-pinned Docker builder (ALL dev work in Docker โ€” +# the host never runs a Go toolchain). +.PHONY: build vet test check smoke + +build: + @./dev.sh build + +vet: + @./dev.sh vet + +test: + @./dev.sh test + +check: + @./dev.sh check + +smoke: + @./dev.sh smoke diff --git a/README.md b/README.md new file mode 100644 index 0000000..7095e7d --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# mopac-discourse-go + +A 100% Go, stdlib-only client for the Discourse REST API (admin API key +auth). One static binary, zero third-party modules, no official SDK โ€” +the MOPAC outbound-CLI for Discourse (SPEC-20260829 "supporting cast": +Discourse is where briefings, usage reports and agent documentation +land). AGPLv3. + +Status: 2026-08-29 โ€” v0 complete and green: categories (list/create), +topics (create/list/latest/get), posts (create/update/get), current-user +identity probe, raw JSON passthrough for everything else, typed error +classes, thin CLI. Built and tested entirely against a fake Discourse +(no live writes). Live reads verified against community.turnsys.com; +category creation 403s with the current key (needs the admin-scoped key) +โ€” see "Live verification" below. + +## What it implements + +The wire protocol, in plain REST with stdlib: + +| Surface | Wire | +|---|---| +| whoami | `GET /session/current.json` โ€” cheapest url+key+username check | +| categories list | `GET /categories.json` | +| categories create | `POST /categories.json` โ€” name, color, text_color, permissions (group name -> 1/2/3); needs an admin-scoped key | +| topics create | `POST /posts.json` โ€” first post with `title`+`raw`+`category`; response carries `id` (first post), `topic_id`, `topic_slug` | +| topics list | `GET /c/.json`, `GET /c/.json`, `GET /c//.json` โ€” category topic lists | +| topics latest | `GET /latest.json` | +| topics get | `GET /t/.json` | +| posts create | `POST /posts.json` โ€” `topic_id`+`raw` | +| posts update | `PUT /posts/.json` โ€” `{post: {raw, edit_reason}}` | +| posts get | `GET /posts/.json` | +| raw anything | `Do(ctx, METHOD, path, body, out)` โ€” JSON in, JSON out; unmodeled endpoints never block on a client release | + +Auth is the header pair `Api-Key` + `Api-Username` on every request. + +## Library surface (what `harness brief` calls) + +```go +import "git.knownelement.com/ukrrs/mopac-discourse-go" + +c, err := discourse.New(baseURL, apiKey, apiUsername) // or NewFromEnv() + +cats, _ := c.ListCategories(ctx) +cat, _ := c.FindCategory(ctx, 0, "mopac-briefings") // id OR slug +cat, err := c.CreateCategory(ctx, discourse.CreateCategoryRequest{ + Name: "MOPAC Briefings", Color: "3AB54A", TextColor: "FFFFFF", + Permissions: map[string]int{"staff": 3}, +}) // ErrForbidden until the admin-scoped key lands + +res, _ := c.CreateTopic(ctx, discourse.CreateTopicRequest{ + Title: "MOPAC briefing 2026-09-01", Raw: markdown, Category: cat.ID, +}) +res.URL(baseURL) // https://forum/t// + +p, _ := c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: res.TopicID, Raw: "reply"}) +_, _ = c.UpdatePost(ctx, p.PostID, "edited", "typo") +``` + +Errors classify via `errors.Is`: `ErrForbidden` (403 โ€” key lacks scope), +`ErrUnauthorized` (401), `ErrNotFound`, `ErrRateLimited` (429, with +`APIError.RetryAfter`), `ErrServer`, `ErrUnreachable`, +`ErrMalformedResponse`, `ErrInvalidRequest`. Every server failure is +also an `*APIError` carrying method, path, status and the +Discourse-reported reasons. + +The key is constructor/env-only, never a flag, never logged: +`Client.String()` renders base url + acting username, and error strings +carry only paths and status codes (the redaction sweep in the smoke +greps every captured output for the key). + +## Quickstart (verified 2026-08-29, all against the fake server) + +All dev work happens inside a Docker builder (host stays toolchain-free). +```sh +./dev.sh check # = go build + go vet + go test, inside golang:1.26-bookworm +``` +Expected output (tail): +```text +ok git.knownelement.com/ukrrs/mopac-discourse-go +``` + +End-to-end smoke โ€” builds the CLI, boots the fake Discourse in a +container on `127.0.0.1:8610`, drives the real binary from the host +through a 0600 env file (whoami / category list+create / topic create / +topic list by id and slug / post reply / post update / raw passthrough / +typed 404 / redaction sweep / bad-key exit code): +```sh +./dev.sh smoke +``` +Expected output (tail): +```text +smoke: OK +``` + +### Configure + +Credentials NEVER arrive via flags or arguments: +```sh +mkdir -p ~/.config/discourse-go && umask 077 +cp env.example ~/.config/discourse-go/env +# fill in DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME +``` +Then `set -a; . ~/.config/discourse-go/env; set +a` before the CLI (or +source it in the service env). For the harness, the key resolves through +its `key_ref` mechanism (env:/file:/mpk:) exactly like the Redmine key. + +## CLI reference + +``` +discourse-go whoami +discourse-go categories list +discourse-go categories create NAME [-color HEX] [-text-color HEX] [-perm GROUP=LEVEL]... +discourse-go topics list [-category ID] [-slug SLUG] [-latest] +discourse-go topics show ID +discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT] +discourse-go posts create -topic ID [-file PATH | -raw TEXT] +discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT] +discourse-go raw METHOD PATH [-data JSON|@file] +``` +Perm levels: 1 = reply/see, 2 = create posts, 3 = full. Output is JSON. +Exit codes: 0 ok, 1 usage/config, 2 API/transport error (the message +names the class). + +## Live verification (community.turnsys.com, 2026-08-29) + +- `whoami` + `categories list` with the current user-scoped key: OK + (HTTP 200). +- `categories create`: **HTTP 403** with the current key โ€” category + creation is admin-scoped. The client fully supports it and maps it to + `ErrForbidden`; live creation waits for the admin key (Charles's 1900 + list). The harness briefing pipeline is unaffected: it posts TOPICS + into an existing category, which the current key allows. + +## License + +AGPLv3 (see LICENSE) โ€” maximally viral, per the MOPAC spec. diff --git a/categories.go b/categories.go new file mode 100644 index 0000000..38ce694 --- /dev/null +++ b/categories.go @@ -0,0 +1,102 @@ +// Categories: list (GET /categories.json) and create (POST /categories.json). +// Creation needs an admin-scoped key โ€” the current MOPAC key gets 403 +// there (ErrForbidden); listing works with a standard key. +package discourse + +import ( + "context" + "fmt" + "net/url" + "strings" +) + +// Category is the subset of the Discourse category payload callers use. +type Category struct { + ID int `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Color string `json:"color"` + TextColor string `json:"text_color"` + Position int `json:"position"` + TopicCount int `json:"topic_count"` +} + +// CreateCategoryRequest is the POST /categories.json body. Permissions +// maps a GROUP NAME to an access level: 1 = reply/see, 2 = create posts, +// 3 = full (the security group numbers Discourse's admin UI uses). Empty +// means "default: everyone full" โ€” for restricted categories set it +// explicitly, e.g. {"staff": 3, "trust_level_0": 1}. +type CreateCategoryRequest struct { + Name string `json:"name"` + Color string `json:"color"` // 6 hex digits, no '#' + TextColor string `json:"text_color"` // 6 hex digits, no '#' + Permissions map[string]int `json:"permissions,omitempty"` + Position int `json:"position,omitempty"` + ParentCategoryID int `json:"parent_category_id,omitempty"` + Description string `json:"description,omitempty"` +} + +// ListCategories returns the instance's categories (one request; the +// endpoint returns the full list). +func (c *Client) ListCategories(ctx context.Context) ([]Category, error) { + var payload struct { + CategoryList struct { + Categories []Category `json:"categories"` + } `json:"category_list"` + } + if err := c.Get(ctx, "/categories.json", &payload); err != nil { + return nil, err + } + return payload.CategoryList.Categories, nil +} + +// FindCategory resolves a category by id or by slug (case-insensitive). +// One of the two must be non-zero/non-empty. +func (c *Client) FindCategory(ctx context.Context, id int, slug string) (*Category, error) { + cats, err := c.ListCategories(ctx) + if err != nil { + return nil, err + } + for i := range cats { + if (id != 0 && cats[i].ID == id) || + (slug != "" && strings.EqualFold(cats[i].Slug, slug)) { + return &cats[i], nil + } + } + return nil, &APIError{ + Method: "GET", + Path: "/categories.json", + Status: 404, + Errors: []string{fmt.Sprintf("category id=%d slug=%q not found", id, slug)}, + } +} + +// CreateCategory creates a category and returns the created record. +// Colors are normalized (uppercased, '#' stripped); Discourse requires +// valid hex or it 422s. +func (c *Client) CreateCategory(ctx context.Context, req CreateCategoryRequest) (*Category, error) { + if strings.TrimSpace(req.Name) == "" { + return nil, fmt.Errorf("%w: category name is required", ErrInvalidRequest) + } + req.Color = normHex(req.Color, "3AB54A") + req.TextColor = normHex(req.TextColor, "FFFFFF") + var created Category + if err := c.Post(ctx, "/categories.json", req, &created); err != nil { + return nil, err + } + return &created, nil +} + +func normHex(v, fallback string) string { + v = strings.TrimPrefix(strings.TrimSpace(strings.ToUpper(v)), "#") + if v == "" { + return fallback + } + return v +} + +// quotePathSegment escapes a path segment (slugs in /c//.json). +func quotePathSegment(s string) string { + return url.PathEscape(s) +} diff --git a/cmd/discourse-go/commands.go b/cmd/discourse-go/commands.go new file mode 100644 index 0000000..ec35d16 --- /dev/null +++ b/cmd/discourse-go/commands.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "strings" + + "git.knownelement.com/ukrrs/mopac-discourse-go" +) + +func runCategories(ctx context.Context, c *discourse.Client, args []string) (any, error) { + if len(args) == 0 { + return nil, fmt.Errorf("categories needs a subcommand (list|create)") + } + switch args[0] { + case "list": + if len(args) != 1 { + return nil, fmt.Errorf("categories list takes no arguments") + } + return c.ListCategories(ctx) + case "create": + fs := flag.NewFlagSet("categories create", flag.ContinueOnError) + color := fs.String("color", "", "hex color, 6 digits") + textColor := fs.String("text-color", "", "hex text color, 6 digits") + perms := multiFlag{} + fs.Var(&perms, "perm", "GROUP=LEVEL (1 reply/see, 2 create, 3 full); repeatable") + if err := fs.Parse(flagsFirst(args[1:])); err != nil { + return nil, err + } + if fs.NArg() != 1 { + return nil, fmt.Errorf("categories create needs exactly one NAME") + } + req := discourse.CreateCategoryRequest{ + Name: fs.Arg(0), + Color: *color, + TextColor: *textColor, + } + for _, p := range perms { + parts := strings.SplitN(p, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("-perm %q must be GROUP=LEVEL", p) + } + lvl, err := strconv.Atoi(parts[1]) + if err != nil || lvl < 1 || lvl > 3 { + return nil, fmt.Errorf("-perm %q level must be 1, 2 or 3", p) + } + if req.Permissions == nil { + req.Permissions = map[string]int{} + } + req.Permissions[parts[0]] = lvl + } + return c.CreateCategory(ctx, req) + } + return nil, fmt.Errorf("unknown categories subcommand %q", args[0]) +} + +func runTopics(ctx context.Context, c *discourse.Client, args []string) (any, error) { + if len(args) == 0 { + return nil, fmt.Errorf("topics needs a subcommand (list|show|create)") + } + switch args[0] { + case "list": + fs := flag.NewFlagSet("topics list", flag.ContinueOnError) + catID := fs.Int("category", 0, "category id") + slug := fs.String("slug", "", "category slug") + latest := fs.Bool("latest", false, "instance-wide latest topics") + if err := fs.Parse(args[1:]); err != nil { + return nil, err + } + if *latest { + return c.LatestTopics(ctx) + } + return c.ListTopics(ctx, *catID, *slug) + case "show": + if len(args) != 2 { + return nil, fmt.Errorf("topics show needs exactly one ID") + } + id, err := strconv.Atoi(args[1]) + if err != nil { + return nil, fmt.Errorf("topic id %q is not a number", args[1]) + } + return c.GetTopic(ctx, id) + case "create": + fs := flag.NewFlagSet("topics create", flag.ContinueOnError) + title := fs.String("title", "", "topic title (required)") + category := fs.Int("category", 0, "category id (required)") + raw := fs.String("raw", "", "markdown body inline") + file := fs.String("file", "", "markdown body from file (overrides -raw)") + if err := fs.Parse(flagsFirst(args[1:])); err != nil { + return nil, err + } + body, err := bodyFrom(*file, *raw) + if err != nil { + return nil, err + } + return c.CreateTopic(ctx, discourse.CreateTopicRequest{Title: *title, Raw: body, Category: *category}) + } + return nil, fmt.Errorf("unknown topics subcommand %q", args[0]) +} + +func runPosts(ctx context.Context, c *discourse.Client, args []string) (any, error) { + if len(args) == 0 { + return nil, fmt.Errorf("posts needs a subcommand (create|update)") + } + switch args[0] { + case "create": + fs := flag.NewFlagSet("posts create", flag.ContinueOnError) + topic := fs.Int("topic", 0, "topic id (required)") + raw := fs.String("raw", "", "markdown body inline") + file := fs.String("file", "", "markdown body from file (overrides -raw)") + if err := fs.Parse(flagsFirst(args[1:])); err != nil { + return nil, err + } + body, err := bodyFrom(*file, *raw) + if err != nil { + return nil, err + } + return c.CreatePost(ctx, discourse.CreatePostRequest{TopicID: *topic, Raw: body}) + case "update": + fs := flag.NewFlagSet("posts update", flag.ContinueOnError) + raw := fs.String("raw", "", "markdown body inline") + file := fs.String("file", "", "markdown body from file (overrides -raw)") + reason := fs.String("reason", "", "edit reason") + if err := fs.Parse(flagsFirst(args[1:])); err != nil { + return nil, err + } + if fs.NArg() != 1 { + return nil, fmt.Errorf("posts update needs exactly one ID") + } + id, err := strconv.Atoi(fs.Arg(0)) + if err != nil { + return nil, fmt.Errorf("post id %q is not a number", fs.Arg(0)) + } + body, err := bodyFrom(*file, *raw) + if err != nil { + return nil, err + } + return c.UpdatePost(ctx, id, body, *reason) + } + return nil, fmt.Errorf("unknown posts subcommand %q", args[0]) +} + +func runRaw(ctx context.Context, c *discourse.Client, args []string) (any, error) { + if len(args) < 2 { + return nil, fmt.Errorf("raw needs METHOD and PATH") + } + method := strings.ToUpper(args[0]) + path := args[1] + fs := flag.NewFlagSet("raw", flag.ContinueOnError) + data := fs.String("data", "", "JSON body inline or @file") + if err := fs.Parse(args[2:]); err != nil { + return nil, err + } + var body any + if *data != "" { + raw := []byte(*data) + if strings.HasPrefix(*data, "@") { + b, err := os.ReadFile(strings.TrimPrefix(*data, "@")) + if err != nil { + return nil, err + } + raw = b + } + if err := json.Unmarshal(raw, &body); err != nil { + return nil, fmt.Errorf("-data is not valid JSON: %v", err) + } + } + var out any + if err := c.Do(ctx, method, path, body, &out); err != nil { + return nil, err + } + return out, nil +} + +func bodyFrom(file, raw string) (string, error) { + if file != "" { + b, err := os.ReadFile(file) + if err != nil { + return "", err + } + return string(b), nil + } + if raw == "" { + return "", fmt.Errorf("body required: -raw TEXT or -file PATH") + } + return raw, nil +} + +// flagsFirst lets flags appear after positional args ("create NAME -color +// X"): every flag here takes a value, so tokens starting with "-" grab +// their neighbor; the rest are positional. Reordered as flags-then- +// positionals for flag.Parse. +func flagsFirst(args []string) []string { + var flags, pos []string + for i := 0; i < len(args); i++ { + if strings.HasPrefix(args[i], "-") && args[i] != "-" { + flags = append(flags, args[i]) + if i+1 < len(args) { + i++ + flags = append(flags, args[i]) + } + } else { + pos = append(pos, args[i]) + } + } + return append(flags, pos...) +} + +// multiFlag collects repeatable string flags (-perm a=1 -perm b=2). +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { + *m = append(*m, v) + return nil +} diff --git a/cmd/discourse-go/main.go b/cmd/discourse-go/main.go new file mode 100644 index 0000000..ce2c794 --- /dev/null +++ b/cmd/discourse-go/main.go @@ -0,0 +1,108 @@ +// Command discourse-go is a thin CLI over the client library: enough to +// drive and verify a Discourse instance by hand (categories, topics, +// posts, raw passthrough). Credentials arrive via environment only +// (DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME, see +// env.example) โ€” never as flags, so they never land in shell history or +// process listings. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "git.knownelement.com/ukrrs/mopac-discourse-go" +) + +const usage = `discourse-go โ€” thin Discourse client CLI (config via env) + +Usage: + discourse-go whoami + discourse-go categories list + discourse-go categories create NAME [-color HEX] [-text-color HEX] [-perm GROUP=LEVEL]... + discourse-go topics list [-category ID] [-slug SLUG] [-latest] + discourse-go topics show ID + discourse-go topics create -title TITLE -category ID [-file PATH | -raw TEXT] + discourse-go posts create -topic ID [-file PATH | -raw TEXT] + discourse-go posts update ID [-file PATH | -raw TEXT] [-reason TEXT] + discourse-go raw METHOD PATH [-data JSON] + +Environment (0600 env file, sourced before the call): + DISCOURSE_URL instance root, e.g. https://community.turnsys.com + DISCOURSE_API_KEY API key (never a flag, never logged) + DISCOURSE_API_USERNAME the user the key acts as (default: system) + +Perm levels: 1 = reply/see, 2 = create posts, 3 = full. +raw PATH is everything after the instance root, e.g. /groups.json. +Exit codes: 0 ok ยท 1 usage/config ยท 2 API/transport error (message says +which class: forbidden/unauthorized/not-found/rate-limited/server/ +unreachable/malformed).` + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + if len(args) == 0 { + fmt.Fprint(os.Stderr, usage) + return 1 + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + client, err := discourse.NewFromEnv() + if err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) + return 1 + } + + var out any + switch args[0] { + case "whoami": + if len(args) != 1 { + return usageErr() + } + out, err = client.CurrentUser(ctx) + + case "categories": + out, err = runCategories(ctx, client, args[1:]) + case "topics": + out, err = runTopics(ctx, client, args[1:]) + case "posts": + out, err = runPosts(ctx, client, args[1:]) + case "raw": + out, err = runRaw(ctx, client, args[1:]) + case "help", "-h", "--help": + fmt.Print(usage) + return 0 + default: + fmt.Fprintf(os.Stderr, "discourse-go: unknown command %q\n\n%s", args[0], usage) + return 1 + } + if err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: %v\n", err) + if errors.Is(err, discourse.ErrInvalidRequest) { + return 1 + } + return 2 + } + if out == nil { + return 0 + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintf(os.Stderr, "discourse-go: encode output: %v\n", err) + return 1 + } + return 0 +} + +func usageErr() int { + fmt.Fprint(os.Stderr, usage) + return 1 +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..eda128f --- /dev/null +++ b/dev.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# mopac-discourse-go dev wrapper. EVERY compile/vet/test path routes +# through the digest-pinned Docker builder (MOPAC big rule: ALL dev work +# in Docker โ€” the host runs containers, never toolchains). +# +# Usage: ./dev.sh {build|vet|test|check|smoke|shell} [args...] +# +# build compile ./cmd/discourse-go into bin/ +# vet go vet ./... +# test go test ./... +# check build + vet + test (the pre-commit gate) +# smoke end-to-end smoke against the in-repo fake Discourse +# shell interactive sh inside the builder +set -e + +IMAGE="golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514" # = golang:1.26-bookworm + +run() { + docker run --rm -v "$PWD:/h" -w /h \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \ + "$IMAGE" "$@" +} + +cmd=${1:-check} +shift || true + +case "$cmd" in +build) + run go build -o bin/discourse-go ./cmd/discourse-go + ;; +vet) + run go vet ./... + ;; +test) + run go test "$@" ./... + ;; +check) + run sh -c 'go build -o bin/discourse-go ./cmd/discourse-go && go vet ./... && go test ./...' + ;; +smoke) + ./smoke/smoke.sh + ;; +shell) + run sh + ;; +*) + echo "dev.sh: unknown command $cmd (want build|vet|test|check|smoke|shell)" >&2 + exit 1 + ;; +esac diff --git a/discourse.go b/discourse.go new file mode 100644 index 0000000..9be1d6a --- /dev/null +++ b/discourse.go @@ -0,0 +1,269 @@ +// Package discourse is a 100% Go, stdlib-only client for the Discourse +// REST API (admin API key auth). It covers what the MOPAC stack needs โ€” +// categories, topics, posts โ€” plus a raw JSON passthrough for everything +// else, so no future endpoint needs another release cycle here. +// +// Keys: the API key arrives via constructor or environment +// (DISCOURSE_URL / DISCOURSE_API_KEY / DISCOURSE_API_USERNAME), never via +// flags or arguments, and is never logged, never rendered by Client. +// String, and never echoed in error strings (it only ever travels in the +// Api-Key header). +package discourse + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +// Sentinel error classes; use errors.Is. Every server failure is also an +// *APIError carrying status, path and the Discourse-reported reasons. +var ( + // ErrForbidden: HTTP 403 โ€” the key lacks the scope for the call (the + // known MOPAC case: the current key cannot create categories; an + // admin-scoped key is needed). + ErrForbidden = errors.New("discourse: forbidden (key lacks scope for this call)") + // ErrUnauthorized: HTTP 401 โ€” bad key or unknown Api-Username. + ErrUnauthorized = errors.New("discourse: unauthorized (bad key or username)") + // ErrNotFound: HTTP 404. + ErrNotFound = errors.New("discourse: not found") + // ErrRateLimited: HTTP 429; APIError.RetryAfter holds the server hint. + ErrRateLimited = errors.New("discourse: rate limited") + // ErrServer: HTTP 5xx. + ErrServer = errors.New("discourse: server error") + // ErrUnreachable: transport-level failure (DNS, refused, timeout). + ErrUnreachable = errors.New("discourse: unreachable") + // ErrMalformedResponse: non-JSON or unexpected payload shape. + ErrMalformedResponse = errors.New("discourse: malformed response") + // ErrInvalidRequest: locally rejected call (bad args) โ€” never sent. + ErrInvalidRequest = errors.New("discourse: invalid request") +) + +// APIError is a non-2xx response. Errors list the Discourse-reported +// reasons (error_description / errors[] fields) when present. +type APIError struct { + Method string + Path string + Status int + Errors []string + RetryAfter time.Duration +} + +func (e *APIError) Error() string { + msgs := e.Errors + if len(msgs) == 0 { + msgs = []string{"(no detail in response body)"} + } + return fmt.Sprintf("%s %s: HTTP %d: %s", e.Method, e.Path, e.Status, strings.Join(msgs, "; ")) +} + +// Unwrap maps the status onto the sentinel classes. +func (e *APIError) Unwrap() error { + switch e.Status { + case http.StatusForbidden: + return ErrForbidden + case http.StatusUnauthorized: + return ErrUnauthorized + case http.StatusNotFound: + return ErrNotFound + case http.StatusTooManyRequests: + return ErrRateLimited + case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusInternalServerError: + return ErrServer + } + return nil +} + +// Client talks to one Discourse instance with one API key. +type Client struct { + baseURL string // scheme + host, no trailing slash, no path + apiKey string + apiUsername string + http *http.Client +} + +// New builds a client. baseURL is the instance root +// (https://forum.example.com); apiKey is a Discourse API key (global or +// user-scoped); apiUsername is the user the key acts as (e.g. "system"). +func New(baseURL, apiKey, apiUsername string) (*Client, error) { + if apiKey == "" { + return nil, fmt.Errorf("%w: empty API key", ErrInvalidRequest) + } + if apiUsername == "" { + apiUsername = "system" + } + u := strings.TrimSuffix(strings.TrimSpace(baseURL), "/") + if u == "" || !strings.Contains(u, "://") { + return nil, fmt.Errorf("%w: base url %q must be scheme://host", ErrInvalidRequest, redact(baseURL)) + } + parsed, err := url.Parse(u) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("%w: base url %q does not parse", ErrInvalidRequest, redact(baseURL)) + } + return &Client{ + baseURL: u, + apiKey: apiKey, + apiUsername: apiUsername, + http: &http.Client{Timeout: 60 * time.Second}, + }, nil +} + +// NewFromEnv builds a client from DISCOURSE_URL, DISCOURSE_API_KEY and +// DISCOURSE_API_USERNAME (default "system"). The intended source is a +// 0600 env file (see env.example), sourced before the process starts. +func NewFromEnv() (*Client, error) { + return New(os.Getenv("DISCOURSE_URL"), os.Getenv("DISCOURSE_API_KEY"), os.Getenv("DISCOURSE_API_USERNAME")) +} + +// String renders the client for logs: base url + acting username only. +// The key never appears. +func (c *Client) String() string { + return fmt.Sprintf("discourse.Client(%s as %s)", c.baseURL, c.apiUsername) +} + +// BaseURL exposes the instance root (safe to log). +func (c *Client) BaseURL() string { return c.baseURL } + +// Do is the raw JSON passthrough: method + path ("/categories.json", +// query included) + an optional JSON-marshalable body, decoded into out +// (when non-nil). Anything the typed helpers do not cover goes through +// here, so the client never blocks an upstream endpoint. +func (c *Client) Do(ctx context.Context, method, path string, body, out any) error { + var rd io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("%w: marshal body: %v", ErrInvalidRequest, err) + } + rd = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidRequest, err) + } + resp, err := c.roundTrip(req, body != nil) + if err != nil { + return err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return fmt.Errorf("%w: read %s: %v", ErrUnreachable, path, err) + } + if out == nil { + return nil + } + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("%w: decode %s: %v", ErrMalformedResponse, path, err) + } + return nil +} + +// Get is Do with GET and no body. +func (c *Client) Get(ctx context.Context, path string, out any) error { + return c.Do(ctx, http.MethodGet, path, nil, out) +} + +// Post is Do with POST and a JSON body. +func (c *Client) Post(ctx context.Context, path string, body, out any) error { + return c.Do(ctx, http.MethodPost, path, body, out) +} + +// Put is Do with PUT and a JSON body. +func (c *Client) Put(ctx context.Context, path string, body, out any) error { + return c.Do(ctx, http.MethodPut, path, body, out) +} + +// roundTrip sends req with the admin API key headers and classifies the +// response: 2xx returns the open response for the caller to drain; 4xx/5xx +// returns a closed-body *APIError; transport errors return ErrUnreachable. +func (c *Client) roundTrip(req *http.Request, hasBody bool) (*http.Response, error) { + req.Header.Set("Api-Key", c.apiKey) + req.Header.Set("Api-Username", c.apiUsername) + req.Header.Set("Accept", "application/json") + if hasBody { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnreachable, redact(err.Error())) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + apiErr := &APIError{ + Method: req.Method, + Path: req.URL.Path, + Status: resp.StatusCode, + RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After")), + } + apiErr.Errors = responseErrors(raw) + if len(apiErr.Errors) == 0 { + apiErr.Errors = []string{truncate(strings.TrimSpace(string(raw)), 300)} + } + return nil, apiErr + } + return resp, nil +} + +// responseErrors pulls the human-readable reasons out of a Discourse +// error body ("error" / "errors[]" / "error_description" fields). +func responseErrors(raw []byte) []string { + var payload struct { + Error string `json:"error"` + Errors []string `json:"errors"` + ErrorDescription string `json:"error_description"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil + } + var out []string + if payload.Error != "" { + out = append(out, payload.Error) + } + out = append(out, payload.Errors...) + if payload.ErrorDescription != "" { + out = append(out, payload.ErrorDescription) + } + return out +} + +func parseRetryAfter(v string) time.Duration { + if v == "" { + return 0 + } + if secs, err := strconv.Atoi(v); err == nil && secs >= 0 { + return time.Duration(secs) * time.Second + } + return 0 +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +// redact strips any query string and userinfo from url-ish strings so a +// misconfigured hop cannot echo key material into logs. +func redact(s string) string { + if i := strings.Index(s, "?"); i >= 0 { + s = s[:i] + "?..." + } + if at := strings.Index(s, "@"); at >= 0 && strings.Contains(s, "://") { + if slash := strings.Index(s, "://"); slash >= 0 && at > slash { + s = s[:slash+3] + s[at+1:] + } + } + return s +} diff --git a/discourse_test.go b/discourse_test.go new file mode 100644 index 0000000..303d9e4 --- /dev/null +++ b/discourse_test.go @@ -0,0 +1,453 @@ +package discourse + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +const testKey = "test-api-key-0123456789" + +// fakeDiscourse is a minimal in-memory Discourse for client tests: it +// enforces the Api-Key/Api-Username headers, serves the endpoints the +// client covers, and records every request for assertions. +type fakeDiscourse struct { + mu sync.Mutex + requests []string // "METHOD path" + cats map[int]Category + nextCat int + posts map[int]postRec // post id -> record + nextPost int + topics map[int]topicRec + nextT int + srv *httptest.Server +} + +type postRec struct { + Post + EditReason string +} + +type topicRec struct { + ID int + Title string + Slug string + Cat int +} + +func newFake(t *testing.T) *fakeDiscourse { + t.Helper() + f := &fakeDiscourse{ + cats: map[int]Category{ + 1: {ID: 1, Name: "General", Slug: "general", Color: "0088CC", TextColor: "FFFFFF"}, + 2: {ID: 2, Name: "MOPAC Briefings", Slug: "mopac-briefings", Color: "3AB54A", TextColor: "FFFFFF"}, + }, + nextCat: 3, + posts: map[int]postRec{}, + nextPost: 100, + topics: map[int]topicRec{}, + nextT: 10, + } + mux := http.NewServeMux() + mux.HandleFunc("/session/current.json", func(w http.ResponseWriter, r *http.Request) { + if !f.auth(w, r) { + return + } + writeJSON(w, 200, map[string]any{ + "current_user": map[string]any{"id": 5, "username": "reachableceo", "admin": false}, + }) + }) + mux.HandleFunc("/categories.json", func(w http.ResponseWriter, r *http.Request) { + if !f.auth(w, r) { + return + } + switch r.Method { + case http.MethodGet: + cats := make([]Category, 0, len(f.cats)) + for _, c := range f.cats { + cats = append(cats, c) + } + writeJSON(w, 200, map[string]any{"category_list": map[string]any{"categories": cats}}) + case http.MethodPost: + var req CreateCategoryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}}) + return + } + if req.Name == "denied" { // simulate a non-admin key + writeJSON(w, 403, map[string]any{ + "errors": []string{"You are not permitted to view the requested resource."}, + "error_description": "Access denied", + }) + return + } + f.mu.Lock() + cat := Category{ + ID: f.nextCat, Name: req.Name, + Slug: strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")), + Color: req.Color, TextColor: req.TextColor, Position: req.Position, + } + f.nextCat++ + f.cats[cat.ID] = cat + f.mu.Unlock() + writeJSON(w, 200, cat) + default: + writeJSON(w, 405, nil) + } + }) + mux.HandleFunc("/posts.json", func(w http.ResponseWriter, r *http.Request) { + if !f.auth(w, r) { + return + } + if r.Method != http.MethodPost { + writeJSON(w, 405, nil) + return + } + var req CreatePostRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]any{"errors": []string{"bad json"}}) + return + } + f.mu.Lock() + defer f.mu.Unlock() + if req.TopicID == 0 { // topic creation + if strings.TrimSpace(req.Title) == "" || req.Category == 0 { + writeJSON(w, 422, map[string]any{"errors": []string{"Title can't be blank"}}) + return + } + tid := f.nextT + f.nextT++ + slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-")) + f.topics[tid] = topicRec{ID: tid, Title: req.Title, Slug: slug, Cat: req.Category} + pid := f.nextPost + f.nextPost++ + f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: tid, PostNumber: 1, Raw: req.Raw, CreatedAt: "2026-08-29T06:30:00.000Z"}} + writeJSON(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug}) + return + } + pid := f.nextPost + f.nextPost++ + f.posts[pid] = postRec{Post: Post{ID: pid, TopicID: req.TopicID, PostNumber: 2, Raw: req.Raw}} + writeJSON(w, 200, map[string]any{"id": pid, "topic_id": req.TopicID, "topic_slug": "existing"}) + }) + mux.HandleFunc("/posts/", func(w http.ResponseWriter, r *http.Request) { + if !f.auth(w, r) { + return + } + var id int + if _, err := fmt.Sscanf(strings.Split(r.URL.Path, "/")[2], "%d", &id); err != nil { + writeJSON(w, 404, nil) + return + } + f.mu.Lock() + defer f.mu.Unlock() + switch r.Method { + case http.MethodGet: + p, ok := f.posts[id] + if !ok { + writeJSON(w, 404, map[string]any{"errors": []string{"The requested URL or resource could not be found."}}) + return + } + writeJSON(w, 200, map[string]any{"post": p}) + case http.MethodPut: + var body struct { + Post struct { + Raw string `json:"raw"` + EditReason string `json:"edit_reason"` + } `json:"post"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, 400, nil) + return + } + p, ok := f.posts[id] + if !ok { + writeJSON(w, 404, nil) + return + } + p.Raw = body.Post.Raw + p.EditReason = body.Post.EditReason + f.posts[id] = p + writeJSON(w, 200, map[string]any{"post": p}) + default: + writeJSON(w, 405, nil) + } + }) + mux.HandleFunc("/latest.json", func(w http.ResponseWriter, r *http.Request) { + if !f.auth(w, r) { + return + } + writeJSON(w, 200, map[string]any{"topic_list": map[string]any{"topics": []map[string]any{ + {"id": 7, "title": "Latest one", "slug": "latest-one", "category_id": 1, "posts_count": 2}, + }}}) + }) + mux.HandleFunc("/c/", func(w http.ResponseWriter, r *http.Request) { + if !f.auth(w, r) { + return + } + writeJSON(w, 200, map[string]any{"topic_list": map[string]any{"topics": []map[string]any{ + {"id": 9, "title": "In category", "slug": "in-category", "category_id": 2, "posts_count": 1}, + }}}) + }) + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeDiscourse) auth(w http.ResponseWriter, r *http.Request) bool { + f.mu.Lock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + f.mu.Unlock() + if r.Header.Get("Api-Key") != testKey || r.Header.Get("Api-Username") != "system" { + writeJSON(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}}) + return false + } + return true +} + +func (f *fakeDiscourse) saw(substr string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, r := range f.requests { + if strings.Contains(r, substr) { + return true + } + } + return false +} + +func writeJSON(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func testClient(t *testing.T, f *fakeDiscourse) *Client { + t.Helper() + c, err := New(f.srv.URL, testKey, "") + if err != nil { + t.Fatalf("New: %v", err) + } + return c +} + +func TestNewRejectsBadInput(t *testing.T) { + if _, err := New("https://ok.example", "", ""); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("empty key: want ErrInvalidRequest, got %v", err) + } + if _, err := New("not-a-url", testKey, ""); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("bad url: want ErrInvalidRequest, got %v", err) + } +} + +func TestCurrentUser(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + u, err := c.CurrentUser(context.Background()) + if err != nil { + t.Fatalf("CurrentUser: %v", err) + } + if u.Username != "reachableceo" || u.Admin { + t.Fatalf("unexpected user %+v", u) + } + if !f.saw("GET /session/current.json") { + t.Fatal("wrong path hit") + } +} + +func TestListCategoriesParsesAndAuths(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + cats, err := c.ListCategories(context.Background()) + if err != nil { + t.Fatalf("ListCategories: %v", err) + } + if len(cats) != 2 || cats[0].Name == "" || cats[1].Slug != "mopac-briefings" && cats[0].Slug != "mopac-briefings" { + t.Fatalf("unexpected categories: %+v", cats) + } + found, err := c.FindCategory(context.Background(), 0, "MOPAC-Briefings") + if err != nil || found.ID != 2 { + t.Fatalf("FindCategory by slug: %v %+v", err, found) + } + found, err = c.FindCategory(context.Background(), 1, "") + if err != nil || found.ID != 1 { + t.Fatalf("FindCategory by id: %v %+v", err, found) + } + if _, err := c.FindCategory(context.Background(), 99, ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing category: want ErrNotFound, got %v", err) + } +} + +func TestCreateCategory(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + cat, err := c.CreateCategory(context.Background(), CreateCategoryRequest{ + Name: "Ops Reports", + Color: "#25AAE2", + Permissions: map[string]int{"staff": 3}, + }) + if err != nil { + t.Fatalf("CreateCategory: %v", err) + } + if cat.ID != 3 || cat.Slug != "ops-reports" { + t.Fatalf("unexpected created category: %+v", cat) + } + if cat.Color != "25AAE2" { // normalized: # stripped, uppercased + t.Fatalf("color not normalized: %q", cat.Color) + } +} + +func TestCreateCategoryForbiddenIsTypedAndRedacted(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + _, err := c.CreateCategory(context.Background(), CreateCategoryRequest{Name: "denied"}) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("want ErrForbidden, got %v", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != 403 { + t.Fatalf("want APIError 403, got %v", err) + } + if strings.Contains(err.Error(), testKey) { + t.Fatalf("error leaks the api key: %v", err) + } + if !strings.Contains(err.Error(), "not permitted") { + t.Fatalf("error misses discourse reasons: %v", err) + } +} + +func TestCreateTopicAndURL(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + res, err := c.CreateTopic(context.Background(), CreateTopicRequest{ + Title: "MOPAC briefing 2026-08-29", Raw: "body", Category: 2, + }) + if err != nil { + t.Fatalf("CreateTopic: %v", err) + } + if res.TopicID == 0 || res.PostID == 0 { + t.Fatalf("bad result: %+v", res) + } + want := f.srv.URL + "/t/mopac-briefing-2026-08-29/" + fmt.Sprint(res.TopicID) + if got := res.URL(f.srv.URL); got != want { + t.Fatalf("URL: want %s got %s", want, got) + } + if !f.saw("POST /posts.json") { + t.Fatal("topic create must go through POST /posts.json") + } + if _, err := c.CreateTopic(context.Background(), CreateTopicRequest{Title: "x", Raw: "y"}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("missing category: want ErrInvalidRequest, got %v", err) + } +} + +func TestListTopicsByCategoryAndLatest(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + topics, err := c.ListTopics(context.Background(), 2, "mopac-briefings") + if err != nil { + t.Fatalf("ListTopics id+slug: %v", err) + } + if len(topics) != 1 || topics[0].ID != 9 { + t.Fatalf("unexpected topics: %+v", topics) + } + topics, err = c.ListTopics(context.Background(), 2, "") + if err != nil || len(topics) != 1 { + t.Fatalf("ListTopics id only: %v %+v", err, topics) + } + topics, err = c.ListTopics(context.Background(), 0, "mopac-briefings") + if err != nil || len(topics) != 1 { + t.Fatalf("ListTopics slug only: %v %+v", err, topics) + } + if _, err := c.ListTopics(context.Background(), 0, ""); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("no ref: want ErrInvalidRequest, got %v", err) + } + if _, err := c.LatestTopics(context.Background()); err != nil { + t.Fatalf("LatestTopics: %v", err) + } +} + +func TestCreateAndUpdatePost(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + res, err := c.CreatePost(context.Background(), CreatePostRequest{TopicID: 5, Raw: "reply body"}) + if err != nil { + t.Fatalf("CreatePost: %v", err) + } + if res.TopicID != 5 { + t.Fatalf("bad result: %+v", res) + } + post, err := c.UpdatePost(context.Background(), res.PostID, "edited body", "typo") + if err != nil { + t.Fatalf("UpdatePost: %v", err) + } + if post.ID != res.PostID { + t.Fatalf("unexpected post: %+v", post) + } + got, err := c.GetPost(context.Background(), res.PostID) + if err != nil { + t.Fatalf("GetPost: %v", err) + } + if got.Raw != "edited body" { + t.Fatalf("update did not stick: %+v", got) + } + if _, err := c.CreatePost(context.Background(), CreatePostRequest{TopicID: 0, Raw: "x"}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("no topic: want ErrInvalidRequest, got %v", err) + } + if _, err := c.UpdatePost(context.Background(), 99999, "x", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing post: want ErrNotFound, got %v", err) + } +} + +func TestRawPassthrough(t *testing.T) { + f := newFake(t) + c := testClient(t, f) + var out map[string]any + if err := c.Get(context.Background(), "/latest.json", &out); err != nil { + t.Fatalf("raw get: %v", err) + } + if err := c.Do(context.Background(), http.MethodPost, "/echo.json", map[string]string{"a": "b"}, nil); err == nil || !errors.Is(err, ErrNotFound) { + t.Fatalf("raw post to unknown path: want ErrNotFound, got %v", err) + } +} + +func TestUnreachableIsTypedAndRedacted(t *testing.T) { + c, err := New("http://127.0.0.1:1", testKey, "system") + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = c.CurrentUser(context.Background()) + if !errors.Is(err, ErrUnreachable) { + t.Fatalf("want ErrUnreachable, got %v", err) + } + if strings.Contains(err.Error(), testKey) { + t.Fatalf("error leaks the api key: %v", err) + } +} + +func TestBadKeyIsUnauthorized(t *testing.T) { + f := newFake(t) + c, err := New(f.srv.URL, "wrong-key", "system") + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = c.CurrentUser(context.Background()) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("want ErrForbidden (auth header rejected), got %v", err) + } +} + +func TestClientStringNeverLeaksKey(t *testing.T) { + c, err := New("https://forum.example.com", testKey, "system") + if err != nil { + t.Fatal(err) + } + if s := c.String(); strings.Contains(s, testKey) { + t.Fatalf("String leaks key: %s", s) + } +} diff --git a/env.example b/env.example new file mode 100644 index 0000000..56272fb --- /dev/null +++ b/env.example @@ -0,0 +1,14 @@ +# mopac-discourse-go credentials (copy to a 0600 file, e.g. +# ~/.config/discourse-go/env, source it, and fill in). Values are NEVER +# passed as flags or arguments. Any *.env file in this repo is gitignored. +# +# Instance root, no trailing slash. +DISCOURSE_URL=https://community.turnsys.com + +# Discourse API key (Admin API > Keys, or a user API key). Creating +# categories requires an admin-scoped key; a standard key 403s there. +DISCOURSE_API_KEY=replace-me + +# The user the key acts as (must match the key's allowed username, or be +# "system" for global keys). +DISCOURSE_API_USERNAME=reachableceo diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3a11f3b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.knownelement.com/ukrrs/mopac-discourse-go + +go 1.24 diff --git a/posts.go b/posts.go new file mode 100644 index 0000000..01fe9f2 --- /dev/null +++ b/posts.go @@ -0,0 +1,121 @@ +// Posts: create (POST /posts.json โ€” replies or topic creation, the same +// endpoint), update (PUT /posts/.json), and the session identity +// check (GET /session/current.json) used by smoke tests and health probes. +package discourse + +import ( + "context" + "fmt" + "strings" +) + +// Post is the post payload subset callers use. +type Post struct { + ID int `json:"id"` + TopicID int `json:"topic_id"` + PostNumber int `json:"post_number"` + Raw string `json:"raw"` + Cooked string `json:"cooked"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// CreatePostRequest posts into an existing topic (topic_id set) or +// creates a topic (topic_id zero + title + category โ€” prefer +// CreateTopic for that path). +type CreatePostRequest struct { + TopicID int `json:"topic_id"` + Raw string `json:"raw"` + Title string `json:"title,omitempty"` + Category int `json:"category,omitempty"` +} + +// CreatePostResult is the POST /posts.json response. +type CreatePostResult struct { + PostID int `json:"id"` + TopicID int `json:"topic_id"` + TopicSlug string `json:"topic_slug"` +} + +// CreatePost posts markdown into a topic and returns the ids. +func (c *Client) CreatePost(ctx context.Context, req CreatePostRequest) (*CreatePostResult, error) { + if req.TopicID <= 0 { + return nil, fmt.Errorf("%w: topic_id is required (use CreateTopic for new topics)", ErrInvalidRequest) + } + if strings.TrimSpace(req.Raw) == "" { + return nil, fmt.Errorf("%w: post raw body is required", ErrInvalidRequest) + } + var out CreatePostResult + if err := c.Post(ctx, "/posts.json", req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// UpdatePost replaces a post's markdown body (PUT /posts/.json) with +// an optional edit reason. +func (c *Client) UpdatePost(ctx context.Context, postID int, raw, editReason string) (*Post, error) { + if postID <= 0 { + return nil, fmt.Errorf("%w: post id must be > 0", ErrInvalidRequest) + } + if strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("%w: post raw body is required", ErrInvalidRequest) + } + body := map[string]any{ + "post": map[string]any{ + "raw": raw, + }, + } + if editReason != "" { + body["post"].(map[string]any)["edit_reason"] = editReason + } + var post struct { + Post Post `json:"post"` + } + if err := c.Put(ctx, "/posts/"+fmt.Sprint(postID)+".json", body, &post); err != nil { + return nil, err + } + if post.Post.ID == 0 { + return nil, fmt.Errorf("%w: post updated but response carries no id", ErrMalformedResponse) + } + return &post.Post, nil +} + +// GetPost fetches one post by id (GET /posts/.json). +func (c *Client) GetPost(ctx context.Context, postID int) (*Post, error) { + if postID <= 0 { + return nil, fmt.Errorf("%w: post id must be > 0", ErrInvalidRequest) + } + var payload struct { + Post Post `json:"post"` + } + if err := c.Get(ctx, "/posts/"+fmt.Sprint(postID)+".json", &payload); err != nil { + return nil, err + } + if payload.Post.ID == 0 { + return nil, fmt.Errorf("%w: post response carries no id", ErrMalformedResponse) + } + return &payload.Post, nil +} + +// CurrentUser is the identity the key acts as โ€” the cheapest live +// verification of url + key + username (any 2xx means the trio works). +type CurrentUser struct { + ID int `json:"id"` + Username string `json:"username"` + Admin bool `json:"admin"` +} + +// CurrentUser runs GET /session/current.json. +func (c *Client) CurrentUser(ctx context.Context) (*CurrentUser, error) { + var payload struct { + CurrentUser CurrentUser `json:"current_user"` + } + if err := c.Get(ctx, "/session/current.json", &payload); err != nil { + return nil, err + } + if payload.CurrentUser.Username == "" { + return nil, fmt.Errorf("%w: no current_user in response", ErrMalformedResponse) + } + return &payload.CurrentUser, nil +} diff --git a/smoke/fakediscourse/main.go b/smoke/fakediscourse/main.go new file mode 100644 index 0000000..320cb48 --- /dev/null +++ b/smoke/fakediscourse/main.go @@ -0,0 +1,149 @@ +// Command fakediscourse is the smoke-test Discourse: a tiny in-memory +// instance enforcing Api-Key auth, serving exactly what the client covers +// (current user, categories, topics, posts). It exists so the smoke test +// can drive the REAL CLI binary end to end without ever touching a live +// forum. Not built into the library, never shipped. +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "sync" +) + +const smokeKey = "smoke-key-do-not-use" + +type server struct { + mu sync.Mutex + cats []map[string]any + posts map[int]map[string]any + topics []map[string]any + nextCat int + nextPost int + nextT int +} + +func main() { + addr := ":8610" + if len(os.Args) == 3 && os.Args[1] == "-addr" { + addr = os.Args[2] + } + s := &server{ + cats: []map[string]any{ + {"id": 1, "name": "General", "slug": "general", "color": "0088CC", "text_color": "FFFFFF", "topic_count": 0}, + {"id": 2, "name": "MOPAC Briefings", "slug": "mopac-briefings", "color": "3AB54A", "text_color": "FFFFFF", "topic_count": 0}, + }, + posts: map[int]map[string]any{}, + nextCat: 3, + nextPost: 100, + nextT: 10, + } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Api-Key") != smokeKey || r.Header.Get("Api-Username") != "smoker" { + reply(w, 403, map[string]any{"errors": []string{"Bad or missing API key"}}) + return + } + s.mu.Lock() + defer s.mu.Unlock() + path := r.URL.Path + switch { + case path == "/session/current.json" && r.Method == "GET": + reply(w, 200, map[string]any{"current_user": map[string]any{"id": 7, "username": "smoker", "admin": true}}) + + case path == "/categories.json" && r.Method == "GET": + reply(w, 200, map[string]any{"category_list": map[string]any{"categories": s.cats}}) + + case path == "/categories.json" && r.Method == "POST": + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + reply(w, 400, map[string]any{"errors": []string{"bad json"}}) + return + } + name, _ := req["name"].(string) + cat := map[string]any{ + "id": s.nextCat, "name": name, + "slug": strings.ToLower(strings.ReplaceAll(name, " ", "-")), + "color": orDefault(req["color"], "3AB54A"), "text_color": orDefault(req["text_color"], "FFFFFF"), + "topic_count": 0, + } + s.nextCat++ + s.cats = append(s.cats, cat) + reply(w, 200, cat) + + case path == "/posts.json" && r.Method == "POST": + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + reply(w, 400, nil) + return + } + topicID, _ := req["topic_id"].(float64) + pid := s.nextPost + s.nextPost++ + if topicID == 0 { + title, _ := req["title"].(string) + catID, _ := req["category"].(float64) + tid := s.nextT + s.nextT++ + slug := strings.ToLower(strings.ReplaceAll(title, " ", "-")) + s.topics = append(s.topics, map[string]any{"id": tid, "title": title, "slug": slug, "category_id": int(catID)}) + s.posts[pid] = map[string]any{"id": pid, "topic_id": tid, "raw": req["raw"], "post_number": 1} + reply(w, 200, map[string]any{"id": pid, "topic_id": tid, "topic_slug": slug}) + return + } + s.posts[pid] = map[string]any{"id": pid, "topic_id": int(topicID), "raw": req["raw"], "post_number": 2} + reply(w, 200, map[string]any{"id": pid, "topic_id": int(topicID), "topic_slug": "existing"}) + + case strings.HasPrefix(path, "/posts/") && r.Method == "PUT": + id, _ := strconv.Atoi(strings.SplitN(strings.TrimPrefix(path, "/posts/"), ".", 2)[0]) + var body struct { + Post struct { + Raw string `json:"raw"` + EditReason string `json:"edit_reason"` + } `json:"post"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + reply(w, 400, nil) + return + } + p, ok := s.posts[id] + if !ok { + reply(w, 404, nil) + return + } + p["raw"] = body.Post.Raw + s.posts[id] = p + reply(w, 200, map[string]any{"post": p}) + + case strings.HasPrefix(path, "/c/") && r.Method == "GET": + reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}}) + + case path == "/latest.json" && r.Method == "GET": + reply(w, 200, map[string]any{"topic_list": map[string]any{"topics": s.topics}}) + + default: + reply(w, 404, map[string]any{"errors": []string{"not found on the smoke instance"}}) + } + }) + fmt.Printf("fakediscourse listening on %s (api-key enforced)\n", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func reply(w http.ResponseWriter, code int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(body) +} + +func orDefault(v any, def string) any { + if s, ok := v.(string); ok && s != "" { + return s + } + return def +} diff --git a/smoke/smoke.sh b/smoke/smoke.sh new file mode 100755 index 0000000..5cc7da2 --- /dev/null +++ b/smoke/smoke.sh @@ -0,0 +1,113 @@ +# End-to-end smoke for discourse-go: builds the CLI in the Docker +# builder, boots the FAKE Discourse in a container on 127.0.0.1:8610, +# drives the real binary from the host through env vars (0600 temp env +# file), asserts the happy paths + failure classes + redaction, and tears +# everything down. No live forum is ever contacted. Only exact container +# IDs spawned here are killed. +set -e + +cd "$(dirname "$0")/.." + +IMAGE="golang@sha256:e8c859f5632dcfde7b32d2012b4351728f6437930887c2f6a91ea242459e5514" +PORT=8610 +SMOKE_KEY="smoke-key-do-not-use" +CID="" + +cleanup() { + if [ -n "$CID" ]; then + docker rm -f "$CID" >/dev/null 2>&1 || true + fi + rm -rf .smoke +} +trap cleanup EXIT INT TERM + +mkdir -p .smoke +umask 077 + +echo "--- build CLI (docker builder)" +docker run --rm -v "$PWD:/h" -w /h \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \ + "$IMAGE" go build -o bin/discourse-go ./cmd/discourse-go + +echo "--- boot fake Discourse (container, port $PORT)" +CID=$(docker run -d --rm \ + -v "$PWD:/h" -w /h \ + -u "$(id -u):$(id -g)" -e HOME=/tmp -e GOFLAGS=-buildvcs=false \ + -p 127.0.0.1:$PORT:8610 \ + "$IMAGE" go run ./smoke/fakediscourse -addr :8610) + +# wait for the fake to answer (any HTTP response proves it is up) +wait_up() { + python3 - "$PORT" <<'PYEOF' +import sys, urllib.request, urllib.error +port = sys.argv[1] +req = urllib.request.Request('http://127.0.0.1:%s/session/current.json' % port, + headers={'Api-Key': 'probe', 'Api-Username': 'probe'}) +try: + urllib.request.urlopen(req, timeout=2) +except urllib.error.HTTPError: + sys.exit(0) # got an HTTP answer: server is up +except Exception: + sys.exit(1) # not yet +sys.exit(0) +PYEOF +} +i=0 +until [ -n "$CID" ] && [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null)" = "true" ] && wait_up; do + i=$((i+1)) + if [ "$i" -ge 60 ]; then + echo "smoke: fake server did not come up; logs:" >&2 + docker logs "$CID" >&2 || true + exit 1 + fi + sleep 1 +done + +# 0600 env file = the credential path the docs prescribe +ENVF=".smoke/env" +printf 'DISCOURSE_URL=http://127.0.0.1:%s\nDISCOURSE_API_KEY=%s\nDISCOURSE_API_USERNAME=smoker\n' \ + "$PORT" "$SMOKE_KEY" > "$ENVF" + +CLI() { + ( set -a; . "$ENVF"; set +a; exec ./bin/discourse-go "$@" ) +} + +capture="$PWD/.smoke/out" +touch "$capture" + +echo "--- whoami" +CLI whoami | tee -a "$capture" | grep -q '"username": "smoker"' || { echo "smoke: whoami failed" >&2; exit 1; } + +echo "--- categories list" +CLI categories list | tee -a "$capture" | grep -q '"mopac-briefings"' || { echo "smoke: categories list failed" >&2; exit 1; } + +echo "--- categories create + typed 404 check" +CLI categories create "Smoke Cat" -color 25AAE2 | tee -a "$capture" | grep -q '"slug": "smoke-cat"' || { echo "smoke: category create failed" >&2; exit 1; } +CLI raw POST /definitely/not/there -data '{}' 2>>"$capture" >>"$capture" || true +grep -q "not found" "$capture" || { echo "smoke: 404 class missing" >&2; exit 1; } + +echo "--- topics create / list / post reply / update" +CLI topics create -title "Smoke Topic" -category 2 -raw "first body" | tee -a "$capture" | grep -q '"topic_id"' || { echo "smoke: topic create failed" >&2; exit 1; } +CLI topics list -category 2 | tee -a "$capture" | grep -q '"Smoke Topic"' || { echo "smoke: topics list failed" >&2; exit 1; } +CLI topics list -slug mopac-briefings | tee -a "$capture" | grep -q '"Smoke Topic"' || { echo "smoke: topics list by slug failed" >&2; exit 1; } +POST_ID=$(CLI posts create -topic 10 -raw "reply body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') +CLI posts update "$POST_ID" -raw "edited body" -reason smoke | tee -a "$capture" | grep -q '"raw": "edited body"' || { echo "smoke: post update failed" >&2; exit 1; } + +echo "--- raw passthrough" +CLI raw GET /latest.json | tee -a "$capture" | grep -q '"topic_list"' || { echo "smoke: raw GET failed" >&2; exit 1; } + +echo "--- redaction: no key material in any captured output" +if grep -q "$SMOKE_KEY" "$capture"; then + echo "smoke: KEY LEAKED in output" >&2 + exit 1 +fi + +echo "--- bad key: exit code 2 + typed error" +set +e +( set -a; . "$ENVF"; set +a; DISCOURSE_API_KEY=wrong-key exec ./bin/discourse-go whoami ) >"$PWD/.smoke/bad" 2>&1 +code=$? +set -e +if [ "$code" -ne 2 ]; then echo "smoke: bad key exit=$code want 2" >&2; exit 1; fi +grep -q "HTTP 403" "$PWD/.smoke/bad" || { echo "smoke: typed error missing" >&2; exit 1; } + +echo "smoke: OK" diff --git a/topics.go b/topics.go new file mode 100644 index 0000000..f2f26f5 --- /dev/null +++ b/topics.go @@ -0,0 +1,137 @@ +// Topics: create (POST /posts.json โ€” Discourse creates topics by posting +// the first post with a title), list per category (GET /c/<...>.json) and +// latest (GET /latest.json), fetch one (GET /t/.json). +package discourse + +import ( + "context" + "fmt" + "strings" +) + +// TopicSummary is one row of a topic list. +type TopicSummary struct { + ID int `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + CategoryID int `json:"category_id"` + PostsCount int `json:"posts_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Closed bool `json:"closed"` + Archived bool `json:"archived"` +} + +// Topic is the full topic payload (GET /t/.json); posts live under +// PostStream.Posts. +type Topic struct { + ID int `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + CategoryID int `json:"category_id"` + CreatedAt string `json:"created_at"` + Closed bool `json:"closed"` + Archived bool `json:"archived"` + PostStream struct { + Posts []Post `json:"posts"` + } `json:"post_stream"` +} + +// CreateTopicRequest creates a topic: title + markdown body + category. +type CreateTopicRequest struct { + Title string `json:"title"` + Raw string `json:"raw"` + Category int `json:"category"` + Tags []string `json:"tags,omitempty"` +} + +// CreateTopicResult is the POST /posts.json response for a topic +// creation: the first post's id plus the new topic's id and slug. +type CreateTopicResult struct { + PostID int `json:"id"` + TopicID int `json:"topic_id"` + TopicSlug string `json:"topic_slug"` +} + +// URL renders the topic's canonical URL on this instance. +func (r CreateTopicResult) URL(baseURL string) string { + if r.TopicID == 0 { + return "" + } + slug := r.TopicSlug + if slug == "" { + slug = "topic" + } + return strings.TrimSuffix(baseURL, "/") + "/t/" + slug + "/" + fmt.Sprint(r.TopicID) +} + +// CreateTopic posts a new topic into the category and returns its ids. +func (c *Client) CreateTopic(ctx context.Context, req CreateTopicRequest) (*CreateTopicResult, error) { + if strings.TrimSpace(req.Title) == "" { + return nil, fmt.Errorf("%w: topic title is required", ErrInvalidRequest) + } + if req.Category == 0 { + return nil, fmt.Errorf("%w: topic category id is required", ErrInvalidRequest) + } + var out CreateTopicResult + if err := c.Post(ctx, "/posts.json", req, &out); err != nil { + return nil, err + } + if out.TopicID == 0 { + return nil, fmt.Errorf("%w: topic created but response carries no topic_id", ErrMalformedResponse) + } + return &out, nil +} + +// ListTopics returns the topic list of a category, addressed by id +// (GET /c/.json) or slug (GET /c/.json). Pass slug="" with id>0 +// or id=0 with a slug. +func (c *Client) ListTopics(ctx context.Context, id int, slug string) ([]TopicSummary, error) { + if id == 0 && slug == "" { + return nil, fmt.Errorf("%w: category id or slug is required", ErrInvalidRequest) + } + var path string + if id > 0 { + if slug != "" { + path = "/c/" + quotePathSegment(slug) + "/" + fmt.Sprint(id) + ".json" + } else { + path = "/c/" + fmt.Sprint(id) + ".json" + } + } else { + path = "/c/" + quotePathSegment(slug) + ".json" + } + var payload struct { + TopicList struct { + Topics []TopicSummary `json:"topics"` + } `json:"topic_list"` + } + if err := c.Get(ctx, path, &payload); err != nil { + return nil, err + } + return payload.TopicList.Topics, nil +} + +// LatestTopics returns the instance's latest topic list (GET /latest.json). +func (c *Client) LatestTopics(ctx context.Context) ([]TopicSummary, error) { + var payload struct { + TopicList struct { + Topics []TopicSummary `json:"topics"` + } `json:"topic_list"` + } + if err := c.Get(ctx, "/latest.json", &payload); err != nil { + return nil, err + } + return payload.TopicList.Topics, nil +} + +// GetTopic fetches one topic by id, posts included. +func (c *Client) GetTopic(ctx context.Context, id int) (*Topic, error) { + if id <= 0 { + return nil, fmt.Errorf("%w: topic id must be > 0", ErrInvalidRequest) + } + var topic Topic + if err := c.Get(ctx, "/t/"+fmt.Sprint(id)+".json", &topic); err != nil { + return nil, err + } + return &topic, nil +}