Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

About:Pharmacopedia.ext: Difference between revisions

From Pharmacopedia
[checked revision][checked revision]
Version bump to 0.9.8.5
0.9.8.7 close-out: star hold-to-expand model, editor enhancements module, vote removal (boss-claude)
 
(6 intermediate revisions by the same user not shown)
Line 1: Line 1:
= Pharmacopedia extension specification =
'''Version:''' 0.9.8.7 · '''Requires:''' MediaWiki >= 1.46.0 · PHP >= 8.5
 
'''Version:''' 0.9.8.5 · '''Requires:''' MediaWiki >= 1.46.0 · PHP >= 8.5
'''Author:''' MDElliottMD · '''License:''' GPL-2.0-or-later
'''Author:''' MDElliottMD · '''License:''' GPL-2.0-or-later
'''Source:''' <code>/var/www/mediawiki/extensions/Pharmacopedia/</code>
'''Source:''' <code>/var/www/mediawiki/extensions/Pharmacopedia/</code>
Line 8: Line 6:


* Structured medicine pages via the <code><nowiki>{{MedTemplate}}</nowiki></code> template
* Structured medicine pages via the <code><nowiki>{{MedTemplate}}</nowiki></code> template
* Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere)
* Per-user rating on effects, problems, titration strategies, anecdotes, and drug-drug interactions (continuous 0–100 sliders, ±100 valence; no 0–5 likert anywhere); ratings use a hold-to-expand star widget (300 ms press, spring animation, drag-commit with pixel-travel + value-delta guards); voters can remove their own committed rating
* Binary AND choice/multi voting on arbitrary content (<code>type="single"</code> / <code>type="multi"</code> with 2-5 options, results-visibility policy per element)
* Binary AND choice/multi voting on arbitrary content (<code>type="single"</code> / <code>type="multi"</code> with 2-5 options, results-visibility policy per element)
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful
Line 41: Line 39:
* '''Backend (PHP):''' <code>includes/</code>, one class per parser tag, store, special page, or API module. Auto-loaded under <code>MediaWiki\Extension\Pharmacopedia\</code>. Assessment classes under <code>includes/Assessments/</code>. API modules under <code>includes/Api/</code>.
* '''Backend (PHP):''' <code>includes/</code>, one class per parser tag, store, special page, or API module. Auto-loaded under <code>MediaWiki\Extension\Pharmacopedia\</code>. Assessment classes under <code>includes/Assessments/</code>. API modules under <code>includes/Api/</code>.
* '''Frontend (JS / CSS):''' multiple ResourceModules per surface area:
* '''Frontend (JS / CSS):''' multiple ResourceModules per surface area:
** <code>ext.pharmacopedia</code>: main IIFE (chip-picker, dx autocomplete, BFI-10 compute, vote logic for both binary and choice/multi)
** <code>ext.pharmacopedia</code>: main IIFE (chip-picker, dx autocomplete, BFI-10 compute, vote logic for both binary and choice/multi, hold-to-expand star rating model with spring animation and drag-commit, vote removal)
** <code>ext.pharmacopedia.styles</code>: base extension stylesheet (self-hosted Geist / Newsreader / Source Serif fonts, core component styling)
** <code>ext.pharmacopedia.styles</code>: base extension stylesheet (self-hosted Geist / Newsreader / Source Serif fonts, core component styling)
** <code>ext.pharmacopedia.blocksave</code>: debounced autosave per block (race-safe)
** <code>ext.pharmacopedia.blocksave</code>: debounced autosave per block (race-safe)
Line 51: Line 49:
** <code>ext.pharmacopedia.perspective</code>: observer-perspective form enhancement (slider readout, progress, consent/delete confirm)
** <code>ext.pharmacopedia.perspective</code>: observer-perspective form enhancement (slider readout, progress, consent/delete confirm)
** <code>ext.pharmacopedia.administer</code>: the administer-to-others surfaces (take-flow slider readout + "Not sure" toggling, owner-hub styling)
** <code>ext.pharmacopedia.administer</code>: the administer-to-others surfaces (take-flow slider readout + "Not sure" toggling, owner-hub styling)
** <code>ext.pharmacopedia.editor</code>: editor enhancements loaded on <code>action=edit/submit</code>; smart paste converts bare PMID or DOI from the clipboard into a formatted <code>&lt;ref&gt;</code> tag (PubMed eutils / CrossRef); house-rules linter flags banned terms and em-dashes on submit with a dismissable warning; quick-ref stub (Ctrl+Alt+R) inserts a journal-article <code>&lt;ref&gt;</code> skeleton
** <code>ext.pharmacopedia.observation</code>: quick-add observation textarea + live preview
** <code>ext.pharmacopedia.observation</code>: quick-add observation textarea + live preview
** <code>ext.pharmacopedia.refupgrade</code>: bulk linker for free-text → structured refs
** <code>ext.pharmacopedia.refupgrade</code>: bulk linker for free-text → structured refs
Line 61: Line 60:
** <code>ext.pharmacopedia.skin.plants</code> / <code>ext.pharmacopedia.skin.fungi</code>: the earth-toned plants-skin overlay and the fungi sub-skin override layer
** <code>ext.pharmacopedia.skin.plants</code> / <code>ext.pharmacopedia.skin.fungi</code>: the earth-toned plants-skin overlay and the fungi sub-skin override layer
* '''Schema:''' <code>sql/</code>, roughly three dozen core tables plus migration patches. Picked up via the <code>LoadExtensionSchemaUpdates</code> hook.
* '''Schema:''' <code>sql/</code>, roughly three dozen core tables plus migration patches. Picked up via the <code>LoadExtensionSchemaUpdates</code> hook.
== Security & encryption ==
Pharmacopedia stores deliberately personal data, including self-reports across mood, addiction, sexuality, and clinical history. The cryptographic + operational posture below is documented in detail so that a security researcher can read it in one pass and know exactly what is on the ground. Values are quoted verbatim where they are already observable from the public surface (HTTP headers, TLS handshake, public APIs); secrets and rotation policy are described without disclosing their values.
=== Transport ===
TLS terminates at Apache 2 (mod_ssl) on the same host as the application. No CDN, no reverse proxy, no load balancer is in front. Certificate is a Let's Encrypt ECDSA leaf, renewed by <code>certbot.timer</code> (fires twice daily, renews when within 30 days of expiry). Private key on disk is mode 600 root:root in <code>/etc/letsencrypt/archive/pharmacopedia.wiki/</code>.
Apache TLS config (live from <code>/etc/letsencrypt/options-ssl-apache.conf</code> + <code>/etc/apache2/mods-enabled/ssl.conf</code>):
SSLProtocol        all -SSLv3
SSLCipherSuite      HIGH:!aNULL
SSLSessionTickets  off
Effective TLS range: TLS 1.0 through 1.3 (SSLv3 explicitly refused). The cipher suite shorthand <code>HIGH:!aNULL</code> covers all ECDHE + DHE forward-secrecy ciphers but does not exclude TLSv1.0 or TLSv1.1 at the protocol layer. Hardening to an explicit modern list and <code>SSLProtocol -TLSv1 -TLSv1.1</code> is queued; see ''Honest limitations'' below. <code>SSLSessionTickets off</code> preserves forward secrecy across server restarts.
HSTS:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Two years, subdomains included, preload-ready. The <code>preload</code> directive is present in the header; submission to the Chromium HSTS preload list is pending.
=== HTTP security headers ===
Set at the Apache layer (<code>/etc/apache2/conf-enabled/security-headers.conf</code>), not relying on PHP:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob:; font-src 'self' data:; object-src 'none'; frame-src 'self' https://challenges.cloudflare.com; worker-src blob:; base-uri 'self'; form-action 'self';
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN
Permissions-Policy: geolocation=(), camera=(self), microphone=(), payment=()
X-Content-Type-Options: nosniff      (set by MediaWiki)
<code>'unsafe-inline'</code> + <code>'unsafe-eval'</code> are required by MediaWiki's JS/CSS pipeline; <code>challenges.cloudflare.com</code> is whitelisted only for the Cloudflare Turnstile widget. <code>object-src 'none'</code> blocks Flash/applet vectors; <code>base-uri 'self'</code> blocks base-tag hijack; <code>form-action 'self'</code> blocks off-origin form POST. No COOP / COEP / CORP set: MW does not need cross-origin isolation.
=== Apache file filters + URL redaction ===
A backup-pattern denylist applies under the document root:
<FilesMatch "\.pre-|\.bak|\.orig|\.php\.|~$">
    Require all denied
</FilesMatch>
This closes editor swap files, ad-hoc <code>.pre-&lt;feature&gt;</code> snapshots, <code>.orig</code> merge debris, and emacs / vim trailing-tilde backups (a source-disclosure path closed 2026-05-20).
Skin asset directories run a positive allowlist (default-deny, only the whitelisted suffixes are served):
<FilesMatch "(?i)^(?!.*\.(php|js|mjs|css|json|png|gif|jpe?g|svg|ico|webp|woff2?|ttf|eot|otf|html?|map|pdf)$)">
    Require all denied
</FilesMatch>
The web installer at <code>/mw-config/</code> is 403'd at the vhost layer regardless of any <code>$wgUpgradeKey</code> value.
Token-bearing URLs are redacted from access logs by <code>/etc/apache2/conf-enabled/pcp-log-redaction.conf</code>. Pharmacopedia issues two URL families that carry per-request secrets in the path: <code>Special:RespondToAssessment/&lt;token&gt;</code> (the invite token derives the AES key for the respondent-readable AdminCrypto copy) and <code>Special:Perspective/&lt;token&gt;</code>. The redaction rule rewrites the request URI in the access log to a literal "[pcp: token-bearing URL redacted]" while preserving IP, time, method, status, byte count, and User-Agent. Three match-paths (request URL, Referer, query string) cover navigation, subresource, and <code>?title=</code> invocations.
=== PHP-FPM hardening ===
Production pool <code>/etc/php/8.5/fpm/pool.d/mediawiki-prod.conf</code> runs as <code>www-data</code> on an UDS socket (<code>0660 www-data:www-data</code>), <code>pm = ondemand</code>, <code>pm.max_children = 16</code>, <code>pm.max_requests = 500</code> (workers cycle every 500 requests to recover memory). Per-pool <code>open_basedir</code> restricts filesystem access to the small set of directories the wiki actually needs:
/var/www/mediawiki, /tmp, /var/log/mediawiki, /var/lib/php,
/var/cache/mediawiki, /var/lib/pharmacopedia-verification,
/var/lib/pharmacopedia-literature, /var/lib/pharmacopedia-life,
/usr/bin, /dev/null, /dev/urandom,
/var/lib/pharmacopedia-feature-requests,
/var/lib/pharmacopedia-adminkeys, /var/lib/mwoauth2
A workspace outside this list is unreadable from PHP regardless of file mode.
ini hardening: <code>expose_php = Off</code>, <code>display_errors = Off</code> (errors go to log only), <code>allow_url_include = Off</code> (remote-PHP include attack vector closed; <code>allow_url_fopen = On</code> stays because MW needs upload-from-URL). Session cookies: <code>secure = 1</code>, <code>httponly = 1</code>, <code>samesite = "Lax"</code>, <code>use_strict_mode = 1</code>, <code>use_only_cookies = 1</code>, session-end lifetime, 24-minute idle gc. opcache enabled with <code>validate_timestamps = 1</code> + 2-second revalidate (no stale-code-after-deploy risk).
=== Database ===
MariaDB 10.11.14, <code>bind-address = 127.0.0.1</code> only (the loopback). DB ports are not exposed to the network; UFW does not need a rule because the bind never reaches the wire. <code>sql_mode</code> includes <code>STRICT_TRANS_TABLES</code> + <code>ERROR_FOR_DIVISION_BY_ZERO</code> (strict type + math behavior). <code>have_ssl = DISABLED</code> is deliberate (loopback-only connections do not benefit from TLS overhead). The wiki's DB account is scoped to the two MW schemas (<code>mediawiki</code>, <code>mediawiki_staging</code>); no <code>FILE</code>, no <code>SUPER</code>, no <code>GRANT OPTION</code>. A DB compromise via injection is bounded to those two schemas.
=== SSH + host ===
<code>sshd</code> is key-only (<code>PasswordAuthentication no</code>, <code>KbdInteractiveAuthentication no</code>, <code>PermitEmptyPasswords no</code>), <code>PermitRootLogin prohibit-password</code> (root accessible only with an authorized key), <code>MaxAuthTries 6</code>, X11 forwarding off. Modern key exchange algorithms preferred (sntrup761x25519, curve25519); legacy SHA1 MACs left in the list for client compatibility.
UFW is deny-by-default for incoming traffic, allow-all outgoing. The only open ingress ports are 22/tcp, 80/tcp, and 443/tcp. The database, the SMTP relay, and the application cache are all loopback-only.
fail2ban runs five jails: <code>sshd</code>, <code>apache-auth</code>, <code>apache-badbots</code>, <code>mediawiki-auth</code> (matches failed wiki logins by spotting <code>200</code> responses on POSTs to <code>Special:UserLogin</code> / <code>Special:CreateAccount</code> — successful logins redirect <code>302</code>), and <code>web-scanners</code> (matches the usual probe patterns).
=== Secrets and keys on disk ===
The high-trust paths and their modes (no values published):
/var/www/mediawiki/LocalSettings.php        640 root:www-data
  contains: $wgSecretKey, $wgUpgradeKey, $wgDBpassword,
            $wgTurnstileSecretKey, $wgSMTP['password'],
            $wgPharmacopediaVoteHashSecret,
            $wgOAuth2{Private,Public}Key paths
/root/.backup-passphrase                    600 root:root  (64 bytes random)
/var/lib/mwoauth2/oauth-private.key        600 www-data:www-data  (RSA-4096)
/var/lib/mwoauth2/oauth-public.key          644 www-data:www-data
/var/lib/pharmacopedia-adminkeys/          700 www-data:www-data
  master.key (lazy-provisioned on first Mode B owner)  600 www-data:www-data
/etc/exim4/passwd.client                    640 root:Debian-exim
/etc/letsencrypt/archive/.../privkey1.pem  600 root:root
/root/.config/rclone/rclone.conf            600 root:root
Public values that are safe to read:
* Cloudflare Turnstile site key: <code>0x4AAAAAADMu_bvOguDp0U52</code>
* Backup target: <code>dropbox:pharmacopedia-backups</code> (Dropbox holds only AES-256-encrypted bundles; the passphrase never leaves the host)
Rotation policy:
* <code>$wgSecretKey</code> is NOT rotated (would invalidate every session and signed-state cookie).
* <code>$wgUpgradeKey</code> rotated 2026-05-22 to 256 bits.
* <code>$wgPharmacopediaVoteHashSecret</code> is intentionally never rotated (rotation invalidates every voter-state mapping, retroactively breaking voter anonymity for existing votes).
* TLS private key rotates on Let's Encrypt renewal (automatic, twice-daily check).
* AdminCrypto Mode B master key + OAuth2 RSA keypair are NOT rotated today; rotation would invalidate existing wrappings + outstanding tokens. Future rotation is overlap-aware (re-wrap under new key, accept either during the cutover window).
=== Application-layer cryptography ===
==== Passwords and second factor ====
MediaWiki passwords are stored as PBKDF2 hashes (MW core default). Verified across every <code>user_password</code> row: HMAC-SHA-512 inner hash, 30,000 iterations, 64-byte derived key, 16-byte random salt per user. The <code>$wgPasswordConfig</code> default is unmodified; bcrypt is available in MW core but not enabled.
Two-factor authentication via the OATHAuth extension. Default module is TOTP per RFC 6238: HMAC-SHA-1 inner, 6 digits, 30-second time-step, 80-bit shared secret. Five recovery codes per user, each a random string, hashed at rest, consumed on use. WebAuthn / FIDO2 (passkey) is available in the same extension and is the operator's first-choice path.
==== AdminCrypto ====
The "Administer to others" subsystem uses a per-owner asymmetric envelope so that respondents can submit results without an account and the owner can decrypt while absent. Implementation (<code>extensions/Pharmacopedia/includes/Assessments/AdminCrypto.php</code>):
Per-owner X25519 keypair generated via <code>sodium_crypto_box_keypair()</code> (libsodium, kernel CSPRNG). The public key is stored in the clear; the secret key is wrapped at rest in <code>pcp_administer_userkey.uk_wrapped_seckey</code> with AES-256-GCM (12-byte fresh IV per call, 16-byte authentication tag, empty AAD; wrapped layout: <code>IV || ciphertext || tag</code>).
Respondent submissions are sealed to the owner's public key via <code>crypto_box_seal</code> (libsodium, anonymous X25519 sender): anyone can encrypt with the public key; only the owner can decrypt with the secret key.
Two key-custody modes:
* '''Mode A (passphrase, zero-knowledge).''' The wrap key for the owner's X25519 secret key derives from a passphrase the server never stores, via Argon2id (libsodium <code>sodium_crypto_pwhash</code> with <code>ARGON2ID13</code>). Two version tracks: v1 uses INTERACTIVE limits (2 ops, 64 MiB memory); v2 uses MODERATE limits (3 ops, 256 MiB memory). The 16-byte salt is stored in <code>uk_kdf_salt</code>; the derivation produces a 32-byte wrap key and a 32-byte verifier (domain-separated SHA-256 of the wrap key). Older-version owners are transparently re-wrapped to the current KDF version on their next successful unlock. ''In Mode A, a database leak alone yields nothing the attacker can decrypt without the owner's passphrase, by design.''
* '''Mode B (managed key).''' The wrap key is a 32-byte random AES-256-GCM key in <code>/var/lib/pharmacopedia-adminkeys/master.key</code> (mode 600 <code>www-data:www-data</code>), lazy-provisioned on first Mode B owner setup. The directory is excluded from the backup tar by path; a database-plus-backup leak does not yield decryption power, because the master key never enters the backup pipeline.
A separate respondent-readable copy of each submission is encrypted with a key derived from the invite token: <code>respondentKey = SHA-256("pcp-administer-respondent-v1:" || rawToken)</code>. The server never stores this key; the rawToken lives only in the URL handed to the respondent. The token-bearing URL is redacted from access logs (see ''Apache file filters'' above).
==== OAuth 2.0 (iOS app) ====
The iOS app authenticates against the wiki via the MWOAuth extension. RSA-4096 signing keypair generated 2026-05-22 with <code>openssl genrsa</code>; JWT signing algorithm RS256. Access tokens live 1 hour; refresh tokens 1 month (MWOAuth defaults). PKCE with S256 challenge is REQUIRED for public clients (<code>$wgOAuth2RequireCodeChallengeForPublicClients = true</code>); the iOS bundle never holds a client secret. Tokens are stored at rest in the wiki's session cache keyed by hashed token (the tokens themselves are opaque to the cache row). The browser-to-app handoff goes via a small HTML+JS bridge at <code>https://pharmacopedia.wiki/app/oauth-callback</code> that forwards the authorization code + state to the <code>pharmacopedia://oauth</code> custom URL scheme. Special:UserLogin and Special:CreateAccount also carry <code>autocomplete</code> field attributes (<code>username</code>, <code>current-password</code> / <code>new-password</code>, <code>email</code>) injected by the <code>AuthChangeFormFields</code> hook so iOS Safari + system password managers offer the right credentials on the right field.
==== Voter anonymity ====
Every vote stores a HMAC-SHA-256 of the voter's user id, salted with <code>$wgPharmacopediaVoteHashSecret</code> (256 bits, never rotated by policy). The hash is a 64-character hex string in <code>pcp_votes</code> in place of the user id, so an administrator reading the votes table cannot map a vote back to an identity without the secret. The secret lives only in LocalSettings.
==== Perspective-invite tokens ====
The perspective subsystem's invite tokens are 24-byte URL-safe random strings handed to invitees in <code>Special:Perspective/&lt;token&gt;</code> links. As of 0.9.8.7, every new invite stores BOTH the cleartext token in <code>pcp_perspective_invite.pvi_token</code> AND a SHA-256 hash of the same token in a new <code>pvi_token_hash</code> column (<code>BINARY(32)</code>, <code>UNIQUE</code>). Lookup at <code>PerspectiveStore::resolveToken</code> hashes the inbound URL token and searches by hash first; a cleartext-column fallback covers any row not yet backfilled at deploy edge. Hashing reuses the canonical <code>AdminCrypto::hashInviteToken</code> helper (raw 32-byte SHA-256), the same shape as the existing <code>pcp_administer_invites.inv_token_hash</code> pattern.
Honest current status: this is half-shipped on purpose. The cleartext <code>pvi_token</code> column is still present so the lookup-fallback works during the dual-write cycle; 0.9.8.8 drops the cleartext column and the fallback branch, at which point a database-read attacker can no longer extract usable perspective-invite tokens.
=== Backups ===
<code>/usr/local/bin/pharmacopedia-backup.sh</code> runs daily at 03:15 local. Every artifact is GPG-symmetric encrypted with AES-256 before it touches the off-host stage:
gpg --batch --yes --symmetric --cipher-algo AES256
    --passphrase-file /root/.backup-passphrase
GPG packet inspection on a current bundle confirms cipher 9 (AES256), S2K mode 3 (iterated and salted), S2K count 65,011,712 iterations, MDC method 2 (modification-detection code present). Local retention is 7 days; off-host (Dropbox via rclone) is 14 days as of 2026-05-23 (was 60). The passphrase file is 64 bytes random, mode 600 root:root, never transmitted off-host.
The 14-day window describes ACTIVE off-host storage: after 14 days the encrypted bundle is removed from the active view of the off-host provider by the nightly rotation. The current off-host provider (Dropbox Pro) additionally retains deleted files in a deletion-recovery layer for up to 180 days, during which the encrypted bundle may remain recoverable by the account operator; after that window the bundle is permanently and irrecoverably deleted. The bundle is GPG-AES256 encrypted at all times; the off-host provider cannot read it under any circumstance. This active-vs-recovery distinction is queued for removal once a B-side backend migration (Hetzner Storage Box BX11, SFTP, POSIX unlink, no recovery layer) ships; tracked in ''Honest limitations'' below.
The backup tar covers <code>/var/www/mediawiki/images</code>, <code>LocalSettings.php</code>, the Pharmacopedia extension, and local skin assets. The full <code>mediawiki</code> schema is dumped separately to a sibling SQL file (also encrypted). Deliberately excluded by path: the AdminCrypto master key, the OAuth2 RSA private key, the backup passphrase itself, the Gmail SMTP credential, and the Let's Encrypt private key. ''A leak of any single backup bundle does not yield Mode B decryption power, JWT signing power, or further off-host backup decryption — those keys live elsewhere on the host and are not in the bundle.''
=== Logging + audit ===
Apache access/error logs and the MediaWiki exception / error / dberror / fatal logs all rotate daily and retain 14 days, then delete. Log files are <code>640 root:adm</code> (Apache) and <code>640 www-data:adm</code> (MW); the world-readable mode that existed prior to 2026-05-22 was tightened in the same audit pass that hardened the rest of the host.
MW exception logs live at <code>/var/log/mediawiki/{exception,error,dberror,fatal}.log</code>. The <code>pcp_visibility_view_log</code> table records every permitted view through a share rule (rule type, viewer, timestamp); the table stores raw IP for anonymous viewers (no /24 mask today — a feature gap, not a privacy claim).
=== Abuse protection ===
* Cloudflare Turnstile gates account creation, repeated failed logins, URL-bearing edits, and email-sending. Editors are not challenged on normal edits (a deliberate friction trade-off).
* The AbuseFilter extension is loaded with 2 rules, both enabled as of 0.9.8.7. Rule 1: spam-keyword block (block + disallow). Rule 2: new-account edit throttle (throttle). The active rule set is small; the lane will grow as live-fire patterns emerge.
* Every file or image upload is scanned by ClamAV before the file moves into the persistent store. The scanner is run via <code>clamdscan</code> (persistent daemon, fast) with a fallback to <code>clamscan</code>. The gate is fail-closed: exit 0 = clean (proceed), exit 1 = infected (reject + unlink), any other exit = error (reject + unlink). A scanner crash never becomes a pass.
* fail2ban (see ''SSH + host'' above) bans abusive IPs at the network layer.
* No CDN or DDoS mitigation layer sits in front; a determined volumetric attacker can degrade availability. We do not claim 24/7 uptime.
=== Honest limitations ===
The posture above is reasonable for a single-operator project that handles personal data; it is not pretending to be anything else.
* '''Single point of failure.''' Host-root compromise yields AdminCrypto Mode B decryption, OAuth2 JWT signing, backup decryption (via the passphrase file), and outbound email impersonation. Defense-in-depth lives in per-key file separation + filesystem perms + open_basedir, but a root-on-host attacker who clears each gate gets everything.
* '''Backup-lag on deletion.''' When a user requests deletion, the live row is purged immediately; the encrypted bundle is removed from active off-host storage after 14 days; the off-host provider's deletion-recovery layer may keep the (still-encrypted) bundle recoverable by the account operator for up to 180 days after that, until it is permanently deleted. Disclosed in About:Privacy on the wiki and (with identical wording) in Oyami's PRIVACY.md. Queued: a backend migration to Hetzner Storage Box (POSIX unlink, no recovery layer) collapses the window to a clean 14 days.
* '''No CDN, no DDoS layer.''' One VM, three open ports, UFW + fail2ban. Hostile traffic can take the site down; it cannot exfiltrate.
* '''Some application-layer key rotation is "never" by design.''' The voter-hash secret and the AdminCrypto Mode B master key cannot rotate without breaking either anonymity or existing wrappings respectively. Loss of either key has the obvious one-time consequence; trading that off against the alternative (re-encrypt every historical record) was the deliberate choice.
* '''TLS allows TLSv1.0 and TLSv1.1.''' The live Apache literal is <code>SSLProtocol all -SSLv3</code>, which does not exclude older TLS protocol versions at the protocol layer. Hardening to an explicit modern cipher list with <code>SSLProtocol -TLSv1 -TLSv1.1</code> is queued.
* '''Perspective-invite tokens are half-migrated to hashed storage.''' 0.9.8.7 added the <code>pvi_token_hash</code> column, backfilled it, and switched the lookup to hash-first; the cleartext <code>pvi_token</code> column is still present so the dual-write fallback works during the deploy edge. 0.9.8.8 drops the cleartext column and the fallback branch, at which point a database-read attacker can no longer extract usable invite tokens. Severity in the interim: an attacker with DB read can still submit a perspective under a planted invite identity; not access to medical data.
=== Security researchers welcome ===
I'm a long-term privacy hobbyist, but new at building real infrastructure. I'm trying my best and honestly it seems world-class good to me (and claude), but if it's not. I need to know ASAP.
If you find anything worth flagging — vulnerability, weakness, design concern, or just an observation — email [mailto:info@pharmacopedia.wiki me] directly. No bug-bounty program; just genuine appreciation for the time and the love of [https://markelliottmd.com/pubkey.asc pretty darn good privacy]. No NDA, no scope restriction, no preferred-disclosure-window. Reach out for any reason.


== Skins, layout, and the Appearance rail ==
== Skins, layout, and the Appearance rail ==
Line 81: Line 272:


<code>DiptychChrome</code> supplies the shared topbar and footer for both. The modules are <code>ext.pharmacopedia.frontpage</code> and <code>ext.pharmacopedia.categoryindex</code>; each carries its own palette tokens so the splash renders correctly with no skin stylesheet loaded.
<code>DiptychChrome</code> supplies the shared topbar and footer for both. The modules are <code>ext.pharmacopedia.frontpage</code> and <code>ext.pharmacopedia.categoryindex</code>; each carries its own palette tokens so the splash renders correctly with no skin stylesheet loaded.
The topbar carries a '''live typeahead search''' as the leftmost item of the right-side topnav cluster. It debounces 180 ms, fetches up to 8 results per keystroke (min 2 chars), and renders an ARIA-combobox dropdown with arrow / Enter / Escape keyboard navigation. The fetch path is hybrid: <code>action=opensearch</code> first (fast prefix index across the eight content namespaces: main, Category, Enzyme, Receptor, Phenotype, USLegal, Problem, Effect), with a <code>action=query&list=search</code> fallback fired on zero hits so all-caps titles like LSD (which opensearch cannot case-insensitively prefix-match against a lowercase query) still resolve.


== Parser tags ==
== Parser tags ==
Line 116: Line 309:
|-
|-
| <code>&lt;categoryindex/&gt;</code> || The two-origin diptych Category index || <code>CategoryIndexTag</code>
| <code>&lt;categoryindex/&gt;</code> || The two-origin diptych Category index || <code>CategoryIndexTag</code>
|-
| <code>&lt;pharmaCommonUses&gt;...&lt;/pharmaCommonUses&gt;</code> || Medicine-page sidebar "Common uses": top-5 problems by rater count, ranked desc; falls back to the legacy hand-entered <code>uses</code> wikitext when zero problems are linked || <code>CommonUsesTag</code>
|-
| <code>&lt;problemMedicines slug="X"/&gt;</code> || Auto-generated list of medicines that carry a <code>&lt;problem ref="X"&gt;</code>. Used on every Problem:&lt;Name&gt; namespace page so the canonical "Medicines used for X" section maintains itself || <code>ProblemMedicinesTag</code>
|-
| <code>&lt;effectMedicines slug="X"/&gt;</code> || Auto-generated list of medicines that carry an <code>&lt;effect ref="X"&gt;</code>. Used on every Effect:&lt;Name&gt; namespace page so the canonical "Medicines that may cause X" section maintains itself || <code>EffectMedicinesTag</code>
|}
|}


Line 537: Line 736:


== Autosave infrastructure ==
== Autosave infrastructure ==
The save-status indicator is a single colored dot (amber = saving, green = saved, red = error) reparented to <code>document.body</code> and pinned to the top-right corner of whichever form control was last manipulated. Position recomputes on scroll + resize so the dot stays on its anchor through the full pending → saving → ✓ saved cycle.


Every block on <code>Special:MyProfile</code> is wrapped in <code>&lt;div data-pcp-save-block="block-name"&gt;</code>. The blocksave.js library:
Every block on <code>Special:MyProfile</code> is wrapped in <code>&lt;div data-pcp-save-block="block-name"&gt;</code>. The blocksave.js library:
Line 824: Line 1,027:
* <code>UserGetRights</code> + <code>UserEffectiveGroups</code>: verified-provider role wiring
* <code>UserGetRights</code> + <code>UserEffectiveGroups</code>: verified-provider role wiring
* Various special-page registrations via <code>SpecialPage_initList</code>
* Various special-page registrations via <code>SpecialPage_initList</code>
== Custom content namespaces ==
Six dedicated content namespaces sit above NS_MAIN for entities that have their own canonical wiki page beyond the main encyclopedic article surface. All are registered with talk pages (id +1), counted as content, included in default search, and tracked by FlaggedRevs (the FlaggedRevs registration is deferred via <code>$wgExtensionFunctions</code> so it runs after the extension's defaults merge, the same timing fix as the original NS_TEMPLATE block).
{| class="wikitable"
! ID !! Namespace !! Purpose
|-
| 3000 / 3001 || Enzyme: / Enzyme talk: || Drug-metabolizing enzymes (CYPs, UGTs, etc.) with locked-template substrate tables
|-
| 3002 / 3003 || Receptor: / Receptor talk: || Receptor entity pages
|-
| 3004 / 3005 || Phenotype: / Phenotype talk: || PGx phenotype reference pages
|-
| 3006 / 3007 || USLegal: / USLegal talk: || US legal / regulatory status reference pages (Prescription only, OTC, DEA Schedule I-V, plus terse redirects)
|-
| 3008 / 3009 || Problem: / Problem talk: || Per-Problem wiki pages; one per <code>pcp_problem</code> row; <code>p_page_id</code> column links the canonical DB row to its page id; <code>&lt;problemMedicines slug="X"/&gt;</code> auto-emits the medicines list inside each page
|-
| 3010 / 3011 || Effect: / Effect talk: || Per-Effect wiki pages; one per <code>pcp_effects</code> row; <code>e_page_id</code> column links the canonical DB row to its page id; <code>&lt;effectMedicines slug="X"/&gt;</code> auto-emits the medicines list inside each page
|}
The Problem: and Effect: namespaces ship with 170 + 288 auto-created stub pages (one per non-retired row in the canonical tables), produced by <code>maintenance/migrateProblemEffectStubs.php</code>. Each stub carries a one-line "Stub" header, the canonical description if any, the auto-generated medicines section, and the sentinel <code>Category:Problem stubs</code> / <code>Category:Effect stubs</code> for the buildout queue. The migration script is CLI-only, idempotent on re-run, and credits MDElliottMD via <code>EDIT_INTERNAL</code> (skips AbuseFilter + captcha + rate limits per MW convention for ops migrations).
Inbound linkage: every <code>&lt;problem ref="X"&gt;</code> on a medicine page links its problem-card title to <code>Problem:&lt;Name&gt;</code>; every <code>&lt;effect ref="X"&gt;</code> links its label to <code>Effect:&lt;Name&gt;</code>; the sidebar Common-uses list links the same way. <code>Special:Problem/&lt;slug&gt;</code> auto-redirects to the matching NS page when <code>p_page_id</code> is set (the legacy aggregate render stays as a fallback for any unmigrated row).
== Wiki-content pages we maintain ==
Alongside the content articles themselves, the project maintains a small set of canonical wiki-content pages whose state is recorded in this spec doc:
* <code>About:Pharmacopedia.ext</code>: this spec, kept lockstep with extension version (interface-claude updates body + version line on every close-out).
* <code>About:Privacy</code>: site privacy policy, plain-language, covering data collection, third parties (Cloudflare Turnstile + Gmail SMTP + Dropbox-as-encrypted-backup-sub-processor), cookies, retention windows, encryption (Let's Encrypt TLS, PBKDF2-SHA512 passwords, OATHAuth 2FA, AdminCrypto X25519 sealed-box + AES-256-GCM, OAuth 2.0 + PKCE for the iOS app, GPG-AES256 backups), and the manual-today deletion path with the up-to-60-day backup-lag disclosure.
* <code>Category:Pharmaceutical</code> + <code>Category:Plants</code>: the two origin categories; every medicine page belongs to exactly one. Each category page is a descriptive history-first article per the canonical category-page spec.
* The eleven Pendell-class category pages (<code>Category:Euphorica</code>, <code>Category:Evaesthetica</code>, etc.): per-class wiki articles with an opening English-gloss clause sourced from Pendell's trilogy.
* The seven USLegal status pages (<code>USLegal:Prescription only</code>, <code>USLegal:Over-the-counter</code>, <code>USLegal:DEA Schedule I</code>...<code>V</code>) plus 26 terse redirects; medicine pages link these via the MedTemplate <code>legal=</code> field.
* <code>MediaWiki:Sidebar</code>: standard MW sidebar with the local additions (My profile, My assessments, etc.).


== Configuration globals ==
== Configuration globals ==