About:Pharmacopedia.ext: Difference between revisions
| [checked revision] | [checked revision] |
MDElliottMD (talk | contribs) Bump to 0.9.1.1: WHO ICD-API integration + ICD-10 chapter Z ingest |
MDElliottMD (talk | contribs) 0.9.8.7 close-out: star hold-to-expand model, editor enhancements module, vote removal (boss-claude) |
||
| (22 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
'''Version:''' 0.9.8.7 · '''Requires:''' MediaWiki >= 1.46.0 · PHP >= 8.5 | |||
'''Version:''' 0.9. | |||
'''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> | ||
The Pharmacopedia extension turns a MediaWiki install into a structured, community-edited medicine reference with rich user-profile and | The Pharmacopedia extension turns a MediaWiki install into a structured, community-edited medicine reference with rich user-profile, assessment, life-story, and visibility-sharing infrastructure. It adds parser tags, special pages, API modules, two dark skins, a chip-picker / autosave UI framework, a vis-timeline-based visual life timeline, a granular per-record sharing subsystem, an observer-perspective subsystem, a token-gated subsystem for administering assessments to people outside the wiki, a two-origin diptych front-of-house, and a database schema that together support: | ||
* 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) | |||
* Two-perspective data capture (personal vs. provider) wherever clinically meaningful | * Two-perspective data capture (personal vs. provider) wherever clinically meaningful | ||
* User profile with dimensional personality / autism assessments (CATI, CAT-Q, MBTI, Enneagram, PID-5-BF, OCEAN/BFI-10) and rich auto-generated reports | * User profile with dimensional personality / autism assessments (CATI, CAT-Q, MBTI, Enneagram, PID-5-BF, OCEAN/BFI-10) and rich auto-generated reports | ||
* Diagnosis autocomplete backed by ~ | * Further self-report instruments (BPNS, NFCS, OCI-PCP, WHOQOL-BREF) and the HYD-PCP wellbeing check-in, on the same auto-scored, report-bearing pattern | ||
* ADHD screening (ASRS and AMAAS-SR) plus a Formal testing log of standardized-test scores, every score field carrying its own visibility | |||
* Diagnosis autocomplete backed by ~41,500 ICD-10-CM, ICD-11, and DSM-5 codes | |||
* '''Life-story timeline''': visual swimlanes (vis-timeline 7.7.3, vendored Apache-2.0) plus a synchronized trait-trajectory overlay (vis.Graph2d); card-list view alongside; quick-add free-text observation parser; range-date episode form with severity slider | |||
* '''Per-record sharing subsystem''': rule types include public, private, users, cohort, link_token, reciprocal; time-bounded; audit log of who-viewed-what; bulk free-text → structured ref upgrader; privacy mode that disables legacy fallback | |||
* '''Observer perspectives''': token-gated, no-account second-party input on something a user owns, under a two-gate consent model | |||
* '''Administer to others''': send any registered assessment scale to people outside the wiki by one-time link, collect their results, and follow them over time, under per-owner public-key encryption with an optional zero-knowledge passphrase mode | |||
* '''Two-origin diptych''': the Main Page and Category index render as chromeless full-viewport splashes giving the pharmaceutical and plant origins equal face | |||
* Chip-picker / autosave / slider-precise-input UI framework shared across editor surfaces | * Chip-picker / autosave / slider-precise-input UI framework shared across editor surfaces | ||
* Date + time + range kit: PCPDatePicker (point/range/possibility), PCPTimePicker (fuzzy time-only: "4p", "noon", "quarter past 6") | |||
* Verified-provider role with document-based verification | * Verified-provider role with document-based verification | ||
* Fail-closed ClamAV scan on every image / file upload (hard project rule) | |||
* Per-user '''research_id''' (stable 10-char hex, opaque, never reassigned) for de-identified research participation | |||
== Precision doctrine == | == Precision doctrine == | ||
| Line 29: | Line 37: | ||
== High-level architecture == | == High-level architecture == | ||
* '''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>. | * '''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):''' <code> | * '''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, 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.blocksave</code>: debounced autosave per block (race-safe) | ||
** <code>ext.pharmacopedia.bounceback</code>: preserves the reader's scroll position across POST-then-reload actions via sessionStorage | |||
** <code>ext.pharmacopedia.confirmdelete</code>: styled red warning prompt replacing <code>window.confirm()</code> on destructive actions | |||
** <code>ext.pharmacopedia.datepicker</code> + <code>.datepicker.styles</code>: range / possibility-mix date widget | |||
** <code>ext.pharmacopedia.timepicker</code> + <code>.timepicker.styles</code>: time-only widget (fuzzy parsing, extracted from DatePicker) | |||
** <code>ext.pharmacopedia.share</code>: per-record share dialog (People / Link / Cohorts tabs) | |||
** <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.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><ref></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><ref></code> skeleton | |||
** <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.vis-timeline-vendor</code>: vis-timeline 7.7.3 (vendored) | |||
** <code>ext.pharmacopedia.lifetimeline</code>: visual life-story timeline + swimlanes + toolbar | |||
** <code>ext.pharmacopedia.lifegraph</code>: trait-trajectory overlay (vis.Graph2d) synced to timeline | |||
** <code>ext.pharmacopedia.kitsync</code>: glue that propagates kit-widget changes into legacy hidden inputs + drives privacy-mode toggle | |||
** <code>ext.pharmacopedia.frontpage</code> / <code>ext.pharmacopedia.categoryindex</code>: the two diptych splash modules, each self-contained so it renders with no skin loaded | |||
** <code>ext.pharmacopedia.appearance</code>: the collapsible Appearance rail (reader text-size control) | |||
** <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. | |||
== 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-<feature></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/<token></code> (the invite token derives the AES key for the respondent-readable AdminCrypto copy) and <code>Special:Perspective/<token></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/<token></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 == | |||
'''Two skins.''' Every page renders in one of two dark skins. The default '''pharmaceutical skin''' is the violet-on-near-black clinical identity. The '''plants skin''' is an earth-toned treatment (<code>ext.pharmacopedia.skin.plants</code>) applied to plant-origin medicine pages. Both skins are dark; there is no light mode. The '''fungi sub-skin''' (<code>ext.pharmacopedia.skin.fungi</code>) is a specialization of the plants skin for fungal medicine pages: a fungus page carries both the <code>pcp-skin-plants</code> and <code>pcp-skin-fungi</code> body classes, loading the plants base plus a fungi override layer (a damp cool-dark palette, a spore-dust grain, the mushroom mark, fungi section-marker hues); everything else inherits from the plants skin. | |||
<code>Hooks::resolvePcpSkin</code> picks the skin. A content (medicine) page is read by its OWN DIRECT origin category: a direct <code>Category:Fungi</code> tag gives the fungi sub-skin (checked first), a direct <code>Category:Plants</code> tag gives the plants skin, anything else pharma. There is no recursive category walk, so a page sitting inside a dual-parented class category (Category:Psychedelics parents under both origins) is skinned by its own origin tag, not by its class. A category page has no single direct origin, so it is resolved by walking its category chain (plants only when the chain is purely plant). | |||
'''Full-width layout.''' The content frame runs edge to edge rather than in a fixed-width column; prose is held to a readable measure by its own constraint, and the layout gutters are set by layout tokens. | |||
'''The Appearance rail.''' A collapsible rail (<code>ext.pharmacopedia.appearance</code>) gives the reader appearance controls, currently a text-size control. The chromeless diptych splashes suppress the rail. | |||
== Front-of-house: the diptych == | |||
The Main Page and the Category index are built as a '''two-origin diptych''': a pharmaceutical column and a plant column side by side, the two origins of the materia medica given equal face. Both render as '''chromeless full-viewport splashes'''. A <code>body.pcp-diptych-page</code> class, added by the <code>BeforePageDisplay</code> hook on those two pages, hides all Vector chrome, and the module supplies its own topbar and footer. A full-height two-origin split gradient runs the page edge to edge, the pharma dark on the left half and the plant dark on the right, a 1px seam down the middle, so the diptych reads top to bottom with no separate bands. | |||
Two parser tags generate the diptych from live wiki data: | |||
* <code><frontpage></code> (<code>FrontPageTag</code>), the Main Page: a featured medicine and featured plant medicine, class / Pharmako-volume browse lists, recently-updated lists, portal links, a status strip. | |||
* <code><categoryindex></code> (<code>CategoryIndexTag</code>), the Category index: the pharmacological classes and the plant lineages as two side-by-side trees. | |||
<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 42: | Line 282: | ||
! Tag !! Purpose !! Class | ! Tag !! Purpose !! Class | ||
|- | |- | ||
| <code><vote></code> || | | <code><vote></code> || Binary up/down (default) OR choice/multi when <code>type="single"</code> or <code>type="multi"</code> + <code>options="A; B; C"</code> (2-5 options, semicolon-separated). Optional <code>results="live\|after-vote\|hidden"</code> for tally-visibility policy. || <code>VoteTag</code> | ||
|- | |- | ||
| <code><effect></code> || Therapeutic or adverse effect; patient + provider perspectives; provider freq slider 0–100; shared valence slider ±100 || <code>EffectTag</code> | | <code><effect></code> || Therapeutic or adverse effect; patient + provider perspectives; provider freq slider 0–100; shared valence slider ±100 || <code>EffectTag</code> | ||
| Line 54: | Line 294: | ||
| <code><anecdote></code> || Personal or provider story with up/down vote || <code>AnecdoteTag</code> | | <code><anecdote></code> || Personal or provider story with up/down vote || <code>AnecdoteTag</code> | ||
|- | |- | ||
| <code><problem></code> || A problem | | <code><problem></code> || A problem the medicine addresses; 0–100 efficacy likert slider + "don't know" toggle || <code>ProblemTag</code> | ||
|- | |- | ||
| <code><pharmaInteractions/></code> || Self-closing; renders the Interactions section for the current page || <code>InteractionTag</code> | | <code><pharmaInteractions/></code> || Self-closing; renders the Interactions section for the current page || <code>InteractionTag</code> | ||
|- | |- | ||
| <code><pharmaExperience/></code> || Self-closing; renders the Experience report form (efficacy, burden, dose, route, schedule, stop-reasons) || <code>ExperienceTag</code> | | <code><pharmaExperience/></code> || Self-closing; renders the Experience report form (efficacy, burden, dose, route, schedule, stop-reasons) || <code>ExperienceTag</code> | ||
|- | |||
| <code><pharmaLiterature/></code> || Self-closing; per-medicine "Relevant literature" section: approved <code>pcp_literature</code> entries plus a collapsed submission form || <code>LiteratureTag</code> | |||
|- | |||
| <code><classGrid/></code> || A grid of medicine-class categories (those tagged Category:MedCategory); <code>count</code> + <code>exclude</code> attributes; 5-minute cache || <code>ClassGridTag</code> | |||
|- | |||
| <code><classTree/></code> || The MedCategory classes as a hierarchy with member counts; <code>exclude</code> attribute; 5-minute cache || <code>ClassTreeTag</code> | |||
|- | |||
| <code><frontpage/></code> || The two-origin diptych Main Page (see [[#Front-of-house: the diptych|Front-of-house]]) || <code>FrontPageTag</code> | |||
|- | |||
| <code><categoryindex/></code> || The two-origin diptych Category index || <code>CategoryIndexTag</code> | |||
|- | |||
| <code><pharmaCommonUses>...</pharmaCommonUses></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><problemMedicines slug="X"/></code> || Auto-generated list of medicines that carry a <code><problem ref="X"></code>. Used on every Problem:<Name> namespace page so the canonical "Medicines used for X" section maintains itself || <code>ProblemMedicinesTag</code> | |||
|- | |||
| <code><effectMedicines slug="X"/></code> || Auto-generated list of medicines that carry an <code><effect ref="X"></code>. Used on every Effect:<Name> namespace page so the canonical "Medicines that may cause X" section maintains itself || <code>EffectMedicinesTag</code> | |||
|} | |} | ||
All non-self-closing tags take a <code>slug</code> argument and (where relevant) a <code>title</code>, <code>label</code>, <code>author</code>, <code>ref</code>, or <code>perspective</code>. | All non-self-closing rating tags take a <code>slug</code> argument and (where relevant) a <code>title</code>, <code>label</code>, <code>author</code>, <code>ref</code>, or <code>perspective</code>. | ||
=== Tag wikitext examples === | === Tag wikitext examples === | ||
| Line 81: | Line 337: | ||
<pharmaInteractions/> | <pharmaInteractions/> | ||
<pharmaExperience/> | <pharmaExperience/> | ||
<vote slug="fav-color" type="single" options="Red; Blue; Green"> | |||
What's your favorite color?</vote> | |||
<vote slug="side-effects" type="multi" results="after-vote" | |||
options="Dry mouth; Insomnia; Anxiety; Headache; None"> | |||
Which side effects did you experience?</vote> | |||
</pre> | </pre> | ||
| Line 88: | Line 351: | ||
! Element !! Scale !! Perspectives !! Storage | ! Element !! Scale !! Perspectives !! Storage | ||
|- | |- | ||
| Vote tag || +1 / −1 binary || single || <code>pcp_votes</code> | | Vote tag (binary) || +1 / −1 binary || single || <code>pcp_votes.v_value</code> | ||
|- | |||
| Vote tag (single-choice) || one of 2-5 options || single || <code>pcp_votes.v_choices</code> (CSV index) | |||
|- | |||
| Vote tag (multi-choice) || any subset of 2-5 options || single || <code>pcp_votes.v_choices</code> (CSV indices) | |||
|- | |- | ||
| Titration || +1 / −1 binary || single || <code>pcp_votes</code> | | Titration || +1 / −1 binary || single || <code>pcp_votes</code> | ||
| Line 103: | Line 370: | ||
|} | |} | ||
Choice / multi vote elements expose per-option tallies via <code>tallyChoices()</code> on demand. Per-option bars render inline in the picker. The <code>results</code> attribute gates tally visibility: | |||
* <code>live</code> (default), tally always visible | |||
* <code>after-vote</code>, tally hidden until viewer has voted | |||
* <code>hidden</code>, tally never shown (only options + "thanks" on submit) | |||
Server-side options-hash (<code>ve_options_h</code>) detects post-vote option-list edits; the API rejects new votes whose submitted hash doesn't match the live one. Voter identities are stored as HMAC-SHA256 (<code>v_voter_hash</code>) so admins reading the DB cannot map votes to user accounts without the HMAC secret. | |||
Aggregates are recomputed and returned by every report-submit API call so the row re-renders in place without a page reload. | Server-side aggregates: <code>n</code>, mean of the rating field, and (for interactions) <code>severe = (vmean ≤ −83.0)</code> (rescaled from the original ±3-scale −2.5). Aggregates are recomputed and returned by every report-submit API call so the row re-renders in place without a page reload. | ||
== Effect bucketing == | == Effect bucketing == | ||
| Line 130: | Line 402: | ||
<code>Special:MyProfile</code> is the user-facing editor for everything personal. Every block on it autosaves on a 800 ms debounce (see [[#Autosave infrastructure|Autosave infrastructure]]). | <code>Special:MyProfile</code> is the user-facing editor for everything personal. Every block on it autosaves on a 800 ms debounce (see [[#Autosave infrastructure|Autosave infrastructure]]). | ||
Visible at top: a Privacy-mode panel toggling whether legacy public fallback applies (privacy-mode ON = only explicit share rules grant access; OFF = the field-level <code>pf_visibility</code> enum applies). | |||
🔗 Share chips appear in each fieldset legend (Demographics, Diagnoses, Medicines) so the owner can scope a share-rule to that namespace via the modal Share dialog. | |||
=== Block list === | === Block list === | ||
* '''Identity''' (display alias, default attribution, experience-report visibility) | * '''Identity''' (display alias, default attribution, experience-report visibility, read-only Research ID badge) | ||
* '''Demographics''' (full chip-picker rebuild, see below) | * '''Demographics''' (full chip-picker rebuild, see below) | ||
* '''Personality''' (Big Five OCEAN sliders + collapsible assessments) | * '''Personality''' (Big Five OCEAN sliders + collapsible assessments) | ||
* '''Enneagram''' (9 type sliders + 45-item screening test) | * '''Enneagram''' (9 type sliders + 45-item screening test) | ||
* '''MBTI''' (4 dichotomy sliders + 32-item OEJTS test) | * '''MBTI''' (4 dichotomy sliders + 32-item OEJTS test) | ||
* ''' | * '''Self-report assessments''' (PID-5-BF, CATI, CAT-Q, BPNS, NFCS, OCI-PCP, WHOQOL-BREF, HYD-PCP, each a collapsible inline test) | ||
* '''ADHD screening''' (ASRS adult-ADHD screener and AMAAS-SR attention self-report, each a collapsible inline test) | |||
* '''Formal testing''' (a log of standardized-test scores; raw score, percentile and pass/fail each carry their own privacy setting) | |||
* '''Diagnoses''' (multi-row with ICD-10-CM + ICD-11 autocomplete, severity slider 0–100, disability slider 0–100, status, origin, dates, notes) | * '''Diagnoses''' (multi-row with ICD-10-CM + ICD-11 autocomplete, severity slider 0–100, disability slider 0–100, status, origin, dates, notes) | ||
* '''Medicines I have tried''' (multi-row with med-name autocomplete, dose, route 16-option dropdown, schedule with datalist suggestions, efficacy + burden sliders 0–100, periods via date-picker) | * '''Medicines I have tried''' (multi-row with med-name autocomplete, dose, route 16-option dropdown, schedule with datalist suggestions, efficacy + burden sliders 0–100, periods via date-picker) | ||
| Line 146: | Line 424: | ||
All categorical demographics use the chip-picker widget (single or multi, with optional primary marker, optional custom free-text). All quantitative demographics use numeric inputs or structured composite widgets. | All categorical demographics use the chip-picker widget (single or multi, with optional primary marker, optional custom free-text). All quantitative demographics use numeric inputs or structured composite widgets. | ||
* '''Birthday''' DatePicker (single / range / possibility-mix) | * '''Birthday''' DatePicker (single / range / possibility-mix). Setting (or changing) the birthday auto-syncs a TYPE_STORY life event tagged <code>auto-birth</code> titled 'Born!' on the life-story timeline. Subsequent birthday edits move ONLY the event's date; user edits to title, body, tags, images are preserved. | ||
* '''Sex assigned at birth''' single-select (clinical category) | * '''Sex assigned at birth''' single-select (clinical category) | ||
* '''Gender identity''' multi-select chip-picker, 27 common terms + custom | * '''Gender identity''' multi-select chip-picker, 27 common terms + custom | ||
| Line 155: | Line 433: | ||
* '''Height / weight''' unit toggle (Metric cm/kg or US ft+in/lb); stored canonically as cm/kg regardless | * '''Height / weight''' unit toggle (Metric cm/kg or US ft+in/lb); stored canonically as cm/kg regardless | ||
* '''Handedness''' single-select (3 options) | * '''Handedness''' single-select (3 options) | ||
* '''Smoking''' structured widget: status + cigs/day + years smoked + quit date; auto-computes pack-years | * '''Smoking''' structured widget: status + cigs/day + years smoked + quit date (PCPDatePicker); auto-computes pack-years | ||
* '''Alcohol''' structured widget: drinks/week + typical drink type + max one occasion | * '''Alcohol''' structured widget: drinks/week + typical drink type + max one occasion | ||
* '''Education''' bucketed highest-level + numeric years + free-text field of study | * '''Education''' bucketed highest-level + numeric years + free-text field of study | ||
| Line 165: | Line 443: | ||
* '''Number of children''' numeric | * '''Number of children''' numeric | ||
* '''Time zone''' free text, auto-detects IANA TZ on first load if empty | * '''Time zone''' free text, auto-detects IANA TZ on first load if empty | ||
* '''Chronotype / sleep schedule''' two | * '''Chronotype / sleep schedule''' two PCPTimePicker widgets (typical bedtime, typical wake; fuzzy parsing accepts "10p", "bedtime", "quarter past 6") | ||
* '''Political orientation''' two-axis compass sliders (economic ±100, social ±100) | * '''Political orientation''' two-axis compass sliders (economic ±100, social ±100) | ||
| Line 188: | Line 466: | ||
Autocomplete via <code>action=pharmacopediadxsearch</code>, multi-token AND search (e.g. "ADHD inattentive" matches the F90.0 row that contains both substrings); ORDER BY <code>FIELD(da_system, 'ICD-10-CM', 'ICD-11', 'DSM-5', ...)</code> so ICD-10-CM leads, then ICD-11, then everything else. | Autocomplete via <code>action=pharmacopediadxsearch</code>, multi-token AND search (e.g. "ADHD inattentive" matches the F90.0 row that contains both substrings); ORDER BY <code>FIELD(da_system, 'ICD-10-CM', 'ICD-11', 'DSM-5', ...)</code> so ICD-10-CM leads, then ICD-11, then everything else. | ||
=== | === Self-report assessments === | ||
The six dimensional assessments all have continuous-slider items, a "Not sure" toggle per item, auto-computed subscale + total scores, and a rich auto-generated report at <code>Special:MyAssessment/{key}</code>: | |||
{| class="wikitable" | {| class="wikitable" | ||
| Line 208: | Line 486: | ||
|} | |} | ||
All assessment items submit raw responses to <code> | Five further self-report instruments are available on the same collapsible-inline-test, auto-scored, report-bearing pattern: | ||
{| class="wikitable" | |||
! Instrument !! Items !! Structure !! Measures | |||
|- | |||
| '''BPNS-PCP''' || 21 || 3 subscales (Autonomy, Competence, Relatedness), 7-point Likert || basic psychological need satisfaction, per Self-Determination Theory | |||
|- | |||
| '''NFCS-PCP''' || 15 || 5 facets (Order, Predictability, Decisiveness, Ambiguity intolerance, Closed-mindedness), 6-point Likert || need for cognitive closure (brief 15-item Roets & Van Hiel form) | |||
|- | |||
| '''OCI-PCP''' || 18 || 6 subscales (Washing, Obsessing, Hoarding, Ordering, Checking, Neutralizing), 0–4 per item; screening cutoff total ≥ 21 || obsessive-compulsive symptoms, adapted from the OCI-R | |||
|- | |||
| '''WHOQOL-BREF-PCP''' || 26 || 4 domains (Physical, Psychological, Social, Environment) + 2 overall items, 5-point Likert || quality of life | |||
|- | |||
| '''HYD-PCP''' || 8 || 8 single-item domains, each one bipolar slider (−100 really poorly to +100 really well); no subscales, no cutoffs || everyday wellbeing, re-taken and watched over time; a locally authored check-in, not validated | |||
|} | |||
All assessment items submit raw responses to <code>pcp_profile_fields</code> under namespace <code>{key}_raw</code> (e.g. <code>cati_raw</code>); the computed scores live under namespace <code>{key}</code> with keys like <code>subscale_SOC</code>, <code>total</code>, plus a <code>taken_at</code> timestamp. Re-scoring happens automatically on every save (autosave fires <code>scoreResponses()</code> on the full raw set, skipping "unsure" rows). Each completed assessment also auto-upserts a Life-story keyframe row (see [[#Life-story timeline|Life-story timeline]]) so trajectories plot over time. | |||
All thirteen assessments (the six dimensional, these five, and the two ADHD screeners below) are registered in <code>AssessmentRegistry</code> and so can also be administered to outside respondents (see [[#Administer assessments to others|Administer assessments to others]]). | |||
=== ADHD screening === | |||
Two attention / ADHD instruments sit alongside the dimensional assessments, each a collapsible inline test on <code>Special:MyProfile</code> and a card render on the public profile: | |||
* '''ASRS''' (Adult ADHD Self-Report Scale, Part A): a 6-item binary screener. The card counts how many of the 6 cardinal items fall in the screening range; 4 or more is a positive screen. The public profile renders it as a '''verdict card''', a screen-positive or screen-negative result word with a 6-cell cardinal-item strip and a screening-result detail line. | |||
* '''AMAAS-SR''' (a 30-item experimental attention self-report): three symptom subscales, inattention, hyperactivity and impulsivity, each scored as a percentage of the subscale maximum. The public profile renders it as a '''featured radar card''', a 3-axis radar carrying a deliberately arbitrary 66.66% threshold triangle that is labelled experimental and not a validated cutoff. AMAAS has no validated norms; the card discloses this in plain sight rather than presenting a clinical cutoff. | |||
Both instruments store responses in <code>pcp_profile_fields</code> under the <code>asrs</code> and <code>amaas</code> namespaces, the same pattern as the dimensional assessments. | |||
=== Formal testing === | |||
The '''Formal testing''' block on <code>Special:MyProfile</code> is a log of standardized tests the user has taken (entrance exams, AP exams, IQ tests, and the like; retakes are welcome, and the year disambiguates them). Each entry resolves against a catalog (<code>pcp_formal_tests</code>) or is a custom free-text test, and records up to three score fields, raw score, percentile and pass/fail, each with an optional estimate flag. | |||
Every score field has its own '''per-field visibility'''. Raw score, percentile and pass/fail each carry a separate privacy setting (<code>uts_vis_raw</code>, <code>uts_vis_pct</code>, <code>uts_vis_passfail</code>), so a user can publish a percentile while keeping the raw score private. The <code>Special:MyProfile</code> editor shows three privacy toggles per entry; the public <code>Special:UserProfile</code> gates each score line independently by its own field visibility. Scores are stored in <code>pcp_user_test_scores</code>, managed through the <code>pharmacopediaformaltest</code> API. | |||
== Life-story timeline == | |||
<code>Special:MyLifeStory</code> is the owner-facing editor + viewer for everything time-anchored about the user. Backed by <code>pcp_life_events</code> with extension columns for episode / observation / keyframe metadata. | |||
=== Event types === | |||
{| class="wikitable" | |||
! le_type !! Meaning !! Notes | |||
|- | |||
| 0 (TYPE_STORY) || Plain timeline entry || title, body, optional image | |||
|- | |||
| 1 (TYPE_IMAGE) || Image-primary entry || same shape, image is the main payload | |||
|- | |||
| 2 (TYPE_KEYFRAME) || Auto-created assessment snapshot || populated by <code>upsertAssessmentKeyframe</code> on every assessment save | |||
|- | |||
| 3 (TYPE_OBSERVATION) || Plain-text observation || created via the quick-add textarea; parser extracts date, polarity, refs | |||
|- | |||
| 4 (TYPE_EPISODE) || Time-bounded period || start + end (PCPDatePicker range mode); type / subtype / severity 0-100 | |||
|} | |||
=== Quick-add observation === | |||
A textarea at the top of <code>Special:MyLifeStory</code> (and a 📝 modal trigger on <code>Special:MyProfile</code>) accepts plain text like: | |||
<pre> | |||
anxiety from bupropion in jan 2020 | |||
did not experience anxiety from bupropion in feb 2018 | |||
panic attack 2 months ago | |||
depressed as a freshman | |||
i had a manic episode sep 1 2020 till nov 15 2020 | |||
no insomnia while on melatonin in summer 2023 | |||
felt great on christmas 2020 | |||
happiest on my 30th birthday | |||
</pre> | |||
<code>ObservationParser::parse()</code> extracts: | |||
* '''Date''' (point or range): ISO, MM/DD/YYYY, "Month D YYYY", "Month YYYY", "Season YYYY", "early/mid/late YYYY", bare year, decades ("2010s"); date RANGES via "X to Y", "X till Y", "from X to Y", "X - Y"; relative-to-now ("yesterday", "last week/month/year", "N months ago", "a few weeks ago"); holidays (christmas, halloween, new year's, valentine's, july 4th, thanksgiving with computed 4th-Thursday-of-Nov, MLK / Memorial / Labor day); age-relative ("7y8mo", "51.2yo", "at age 14", "ages 2-10"); life stages ("in childhood", "as a teen", "as a freshman", "junior year"); Nth birthday ("my 30th birthday") | |||
* '''Polarity''' (negation detection): "not", "didn't", "did not experience", "never", "without", "denied" → polarity=0 (negative); else 1 (positive) | |||
* '''Leading verbs stripped''': "I was diagnosed with", "I took", "started taking", "tried", "was on", "experienced", "felt", so the subject is the noun, not the verb | |||
* '''Adverbs stripped''': "briefly", "occasionally", "frequently", "sometimes", "always", so the subject is the noun, not the modifier | |||
* '''Role splitting''': "from / caused by / due to / while on" → role='cause'; "with / during / while" → role='context' | |||
* '''Ref resolution''', in priority order: | |||
*# User's meds (<code>pcp_user_meds</code>) | |||
*# Wiki pages in Category:Medicines | |||
*# Effects catalog (<code>pcp_effects</code>) | |||
*# Problems catalog (<code>pcp_problem</code> + <code>pcp_problem_alias</code>) | |||
*# User's diagnoses (<code>pcp_profile_diagnoses</code>) | |||
*# Global ICD diagnosis abbreviations (<code>pcp_diagnosis_abbreviations</code>) | |||
*# Free-text fallback (stored as type='free' for later upgrade) | |||
* '''Auto-promote unrecognized subjects to custom-trait keyframes''': when the subject doesn't match an existing entity but the input has BOTH a numeric value AND a date (e.g. <code>my neuroticism was 5 at 10yo</code>), the subject is treated as a NEW custom trait (<code>namespace='custom'</code>, key derived from subject text). Creates a TYPE_KEYFRAME event with the value + a trajectory point. Future inputs with the same subject auto-append to that series. | |||
* '''Flexible range separators''': <code>X to Y</code>, <code>X through Y</code>, <code>X thru Y</code>, <code>X until Y</code>, <code>X till Y</code>, em-dash, en-dash, flexible-whitespace hyphens (digit-aware so <code>2026-05-31</code> ISO dates aren't split), <code>..</code> / <code>...</code> ellipsis. | |||
* '''Age-range phrases without literal "ages"''': <code>when I was 11-13</code>, <code>from 11 to 13</code>, <code>between ages 5 and 12</code>, <code>aged 10 to 14</code>. Negative lookahead prevents false matches on dosages (<code>10-20 mg</code>), durations (<code>5-10 years ago</code>), measurements (<code>cm/mm/ft/lb/etc</code>). | |||
* '''Episode-shape detection''': "manic episode" → type=mood, subtype=manic; "psychotic break", "panic attack", "anxiety attack", "trauma response" all route to <code>addEpisode</code> instead of <code>addObservation</code>. A date RANGE alone is enough to force <code>is_episode=true</code>. | |||
Live preview chips appear under the textarea as you type. Submit routes to <code>pharmacopediaobservation</code> API which writes the row + refs via <code>setEventRefs()</code>. | |||
=== Episode form === | |||
Click the "🌀 Episode" button (or the quick-add detects an episode shape) for the structured form: | |||
* Type selector: mood / psychotic / anxiety / panic / trauma response / dissociative / substance use / eating / sleep disturbance / pain flare / migraine / medication adjustment / hospitalization / creative surge / spiritual / transcendent / relationship crisis / grief / somatic / other | |||
* Subtype (text + datalist), for mood: depressive / manic / hypomanic / mixed / dysphoric / euthymic | |||
* Severity slider 0-100 (per precision doctrine) | |||
* Date range via PCPDatePicker locked to range mode | |||
* Title, body, single virus-scanned image, visibility (4-state) | |||
=== Visual timeline === | |||
Top of <code>Special:MyLifeStory</code> has a tabbed view: '''Visual timeline''' (default) / '''Card list'''. | |||
The visual timeline uses '''vis-timeline 7.7.3''' (vendored Apache-2.0 at <code>resources/vendor/vis-timeline/</code>) with these features: | |||
* Swimlanes (toggleable chips): Episodes (range bars), Events, Observations, Keyframes (off by default), Derived | |||
* '''Trait-trajectory overlay''': a synchronized <code>vis.Graph2d</code> draws smooth (centripetal Catmull-Rom interpolated) lines for every keyframe trait series (CATI subscales, PID-5-BF domains, CAT-Q, custom traits like "shyness") DIRECTLY on top of the timeline plot area. Same X-axis; the graph's data + time axes are CSS-hidden so only the lines + points show. Values are normalized to 0-100% within each series so different scales coexist. | |||
* Toolbar: Visual/Card tabs, group toggle chips, magnifier + zoom +/- buttons, Fit visible / Fit everything | |||
* Plain wheel = vertical scroll inside the timeline (520px fixed height); Ctrl+wheel = zoom; Shift+wheel = horizontal pan | |||
* Click empty timeline area: prefills the quick-add textarea with "on YYYY-MM-DD" at that date + scrolls + triggers live preview | |||
* Click an item: routes to its edit form (event / episode / observation, each has its own route) | |||
* Collapsible "Trait series (N)" legend with chip toggles to show/hide individual series | |||
=== Edit / delete / duplicate flows === | |||
Each event type has its own edit route under <code>Special:MyLifeStory</code>: | |||
* <code>Special:MyLifeStory/edit-observation/<id></code>, re-parses raw text on save; supports polarity override + date override | |||
* <code>Special:MyLifeStory/edit-episode/<id></code>, full episode form | |||
* <code>Special:MyLifeStory?edit_event=<id></code>, existing event / image / keyframe form | |||
All three forms have side-by-side "Delete X" + "Duplicate X" buttons. Duplicate copies fields + refs + keyframe traits (NOT images) and redirects to the new row's edit form. | |||
=== Upgrade-link UI === | |||
When the parser stored a free-text ref (because the entity wasn't in the user's data at the time), <code>Special:MyRefLinks</code> finds all such refs and offers one-click "link to {match}" buttons. Matches come from the same catalogs the parser checks at write time. A banner on <code>Special:MyLifeStory</code> ("📎 N free-text references could be linked → Review & link") appears when unmatched refs exist. | |||
== Per-record sharing subsystem == | |||
A granular per-record sharing system layered on top of the legacy <code>pf_visibility</code> enum without removing it. Resolved by <code>VisibilityResolver</code>; the new model is additive (legacy fallback preserved unless Privacy mode is on). | |||
=== Rule types (vr_rule_type) === | |||
* '''<code>private</code>''', explicit deny (only owner) | |||
* '''<code>public</code>''', explicit allow (anyone) | |||
* '''<code>users</code>''', payload <code>{user_ids: [...]}</code>; allow if viewer in list | |||
* '''<code>cohort</code>''', payload <code>{cohort_id: N}</code>; allow if viewer in <code>pcp_cohort_members</code> for that cohort | |||
* '''<code>link_token</code>''', payload <code>{token: '...', uses_remaining: null|N}</code>; allow if URL <code>?pcpshare=TOKEN</code> matches | |||
* '''<code>reciprocal</code>''', allow if viewer has a matching <code>users</code> rule sharing the same shape back to owner | |||
Rules scope at three levels: <code>(profile, namespace, key)</code>, <code>(profile, namespace, NULL)</code>, or <code>(profile, '*', NULL)</code>. Most-specific-first matching. Rules can have <code>vr_expires</code> (time-bounded) and <code>vr_revoked</code> (preserves audit trail). | |||
=== Privacy mode === | |||
When Privacy mode is ON for a profile, a *-wide <code>private</code> rule is created. <code>VisibilityResolver::canView()</code> short-circuits to false instead of falling through to the legacy <code>pf_visibility</code> check, so only explicit share rules grant access. Default: OFF (back-compat). | |||
=== Share dialog === | |||
Triggered by 🔗 chips on assessment reports, profile sections, life-story timeline. Modal with three tabs: | |||
* '''People''': username autocomplete (↓/↑/Enter/Esc keyboard nav), per-shared-user pill with × to remove just that user, optional expires DatePicker, "Auto-share back" reciprocal toggle | |||
* '''Link''': "Generate link" creates a <code>link_token</code> rule; copies the URL to clipboard with toast; optional max-uses + expires | |||
* '''Cohorts''': dropdown of the owner's cohorts (managed at <code>Special:MyCohorts</code>); optional expires | |||
=== Audit log === | |||
Every permitted view through a rule (not legacy) writes a row to <code>pcp_visibility_view_log</code>. <code>Special:MyShareLog</code> shows the last 200 views with timestamp, viewer (or anonymous IP masked to /24), namespace, key, rule id. | |||
== Observer perspectives == | |||
A structured way to collect second-party (observer) input on something a user owns, without the observer needing an account. The v1 use case is an observer-rated attention report: a registered user invites someone who knows them well to rate them. A two-gate consent model governs the whole flow. | |||
* '''Gate 1, contribution.''' An owner-issued, token-bearing invite link is the only way to submit a perspective. The invitee URL carries only an opaque random token (<code>VisibilityResolver::generateLinkToken()</code>); the owner's identity is never in the URL. The invitee sees only the owner's chosen display name. | |||
* '''Gate 2, publication.''' Every submission is born <code>psp_consent = 0</code>, private to the owner. Only the owner, re-checked for ownership, can flip it to published. A perspective is never publicly visible while unconsented. | |||
Flow: the owner opens <code>Special:MyPerspectives</code>, picks a display name, mints an invite link, and sends it. The invitee opens <code>Special:Perspective/<token></code>, reads a generic intro and a non-anonymity notice, optionally states their relationship to the owner, fills the type-specific slider form (with per-item "not sure" markers), clears a Turnstile challenge, and submits. The perspective lands in the owner's consent inbox, where the owner can publish it, return it to private, or delete it. | |||
<code>Special:Perspective</code> is unlisted and unauthenticated; its POST path is defended by pingLimiter, a per-token APCu velocity cap, and fail-closed Turnstile. <code>pcp_perspective_invite</code> holds the token-bearing invitations (<code>pvi_max_uses</code> NULL means an unlimited reusable link); <code>pcp_perspective</code> holds the submissions, each with a <code>psp_validity</code> response-quality flag and the <code>psp_consent</code> gate. The client module <code>ext.pharmacopedia.perspective</code> adds the slider readout and progress bar on the invitee form and the confirm steps on the owner inbox. | |||
== Administer assessments to others == | |||
A registered user (the '''owner''') can send any of the thirteen registered assessment scales to people outside the wiki (the '''respondents'''), collect their results, and follow them over time. Respondents need no account; a one-time invite link is their only credential. The subsystem is built so that, by default, a respondent's results are readable only by the owner, and the owner can additionally choose a zero-knowledge mode in which not even a site administrator can read them. | |||
=== Assessment registry === | |||
<code>AssessmentRegistry</code> (<code>includes/Assessments/AssessmentRegistry.php</code>) is the single source of truth: an assessment registered there once autopopulates the owner's scale-picker, the respondent take-flow, and the dashboards. Each entry names a scorer class and a '''response model''': | |||
* <code>radio</code>, discrete labelled radio buttons (CATI, CAT-Q, PID-5-BF, NFCS, BPNS, OCI-PCP, WHOQOL-BREF, ASRS) | |||
* <code>slider</code>, one continuous slider per item with uniform end anchors (OCEAN, Enneagram, AMAAS, HYD-PCP) | |||
* <code>bipolar</code>, one slider per item with the item's own two opposing phrases as the anchors (MBTI) | |||
Every take-flow item also carries a "Not sure" control that disables the item. | |||
=== The four surfaces === | |||
* '''Entry point''', a control that opens the feature. | |||
* '''Owner hub''' (<code>Special:AdministerAssessments</code>): the owner manages respondents (a named contact list), composes a send (a respondent plus one or more scales), generates a one-time link, and reviews each respondent's results over time. The owner can delete an invite. | |||
* '''Respondent take-flow''' (<code>Special:RespondToAssessment/<token></code>), reached only by the invite link: a consent screen, then the model-aware item forms; once every scale is done the same URL becomes a revisitable results dashboard. Either party can delete. | |||
* '''Key setup''', where the owner chooses how their results are protected (see below). | |||
=== Cryptography === | |||
Each owner has an '''X25519 keypair'''. The public key is stored in the clear; the secret key is wrapped at rest. A respondent submits while the owner is absent, so the result is sealed to the owner's public key with <code>crypto_box_seal</code>, which needs only the public key; only the owner's secret key can open it. Two protection modes: | |||
* '''Managed''' (default, seamless): the secret key is wrapped with a server master key held in a file outside the web root, never in the database. No passphrase to remember, and a forgotten site password never costs the owner their data. A site administrator could technically read results. | |||
* '''Passphrase''' (opt-in, zero-knowledge): the secret key is wrapped with an Argon2id-derived key from a passphrase only the owner knows. Not even the site can read the results. '''The passphrase cannot be recovered or reset'''; losing it makes every collected result permanently unreadable. | |||
AES-256-GCM is used for key wrapping in both modes. <code>AdminCrypto</code> (<code>includes/Assessments/AdminCrypto.php</code>) is the helper: <code>setupOwnerKey</code>, <code>verifyPassphrase</code>, <code>unlockSecretKey</code>, <code>encryptForOwner</code> / <code>decryptForOwner</code>, <code>encryptForRespondent</code> / <code>decryptForRespondent</code>, <code>mintInviteToken</code> / <code>hashInviteToken</code>. Invite tokens are 256-bit; only their SHA-256 hash is stored, so a stolen database yields no usable tokens. The respondent's own copy of each result is sealed to a key derived from the raw invite token, so the link doubles as the respondent's read credential. A scheme-version column on the key and result rows lets the cipher or KDF be revised over time; the Mode A passphrase KDF currently runs at scheme v2 (Argon2id at MODERATE limits), and an owner created under an earlier scheme is transparently re-wrapped on their next unlock. | |||
=== De-identified research pool === | |||
Each submission also writes one row to <code>pcp_administer_research</code>, decoupled from the result write, with no foreign key to the invite, respondent, or owner. <code>res_id</code> is a random 128-bit value (not sequential) and the only time field is <code>res_month</code> ('YYYY-MM', no day), so a research row cannot be time-correlated or rank-correlated back to the owner-side tables. The payload is the plaintext item responses plus the computed score. Deleting an invite removes the owner-sealed and respondent-sealed result copies but intentionally leaves the de-identified research row, as the consent screen states. | |||
=== Tables === | |||
* <code>pcp_administer_respondents</code>, an owner's named contacts (the label is sealed to the owner's public key, so the roster is readable only by the owner) | |||
* <code>pcp_administer_invites</code>, one token-bearing link per send (stores only the SHA-256 of the token) | |||
* <code>pcp_administer_assessments</code>, the scale(s) inside an invite, each with the owner-sealed (<code>aa_payload_enc</code>) and respondent-sealed (<code>aa_respondent_enc</code>) result copies | |||
* <code>pcp_administer_userkey</code>, per-owner key material (public key, wrapped secret key, Mode-A salt and verifier) | |||
* <code>pcp_administer_research</code>, the de-identified research pool | |||
The 13-instrument submission handler reuses the existing assessment scorers in <code>includes/Assessments/</code> rather than reimplementing scoring. | |||
== Choice / multi voting == | |||
The <code><vote></code> tag's binary up/down mode is unchanged. With <code>type="single"</code> or <code>type="multi"</code> + <code>options="A; B; C"</code> (2-5 entries, semicolon separator), it renders a compact chart-icon chip that expands inline to a radio (single) or checkbox (multi) picker with per-option count bars. | |||
Server-side storage: | |||
* <code>pcp_votable_elements.ve_options</code> (JSON array of labels) | |||
* <code>pcp_votable_elements.ve_options_h</code> (first 8 hex of sha256; drift hash) | |||
* <code>pcp_votable_elements.ve_results_policy</code> (live / after-vote / hidden) | |||
* <code>pcp_votes.v_choices</code> (CSV indices) | |||
* <code>pcp_votes.v_options_h</code> (drift hash at vote time) | |||
API route: same <code>pharmacopediavote</code> endpoint; presence of <code>choices</code> or <code>options_h</code> params routes to <code>castChoice()</code>. Response includes <code>tally</code> + <code>user_choices</code> (null for binary). Tally hidden per <code>results</code> policy. | |||
Drift behavior: if the page editor changes the options list after votes exist, <code>ve_options_h</code> updates and new votes' <code>v_options_h</code> reflects the new value. Existing votes stay but their hash no longer matches (marked stale). New votes whose submitted hash mismatches the live one are rejected, protects against browser cache races. Tallies still aggregate by raw index, so RENAMING an option in place silently turns old votes into new-label votes; reordering is the dangerous case. Appending new options is safe. | |||
== Research ID == | |||
Every user profile carries a '''research_id''': a 10-character hex string (<code>bin2hex(random_bytes(5))</code> = 40 bits), generated once at profile create, stored UNIQUE in <code>pcp_user_profiles.prof_research_id</code>, and never reassigned. | |||
Purpose: provides a stable opaque identifier for de-identified research participation. It does not reveal the user's wiki username, user_id, or HMAC voter_hash; it survives username changes; and it stays constant across the user's lifetime on the wiki. Users can find theirs in the '''Public identity''' fieldset on <code>Special:MyProfile</code> (single-click to select-and-copy). | |||
Backfilled retroactively for all pre-existing profiles on 2026-05-18 (v0.9.4). | |||
== ClamAV scan rule (project standard) == | |||
Hard rule, set 2026-05-17: every server-accepted file upload (image, PDF, document, anything) MUST go through <code>VirusScanner::scanFile($path)</code> (<code>includes/VirusScanner.php</code>) BEFORE being moved to permanent storage. Fail-closed: if <code>/usr/bin/clamdscan</code> is unavailable or returns error, the upload is rejected. | |||
Wired into: | |||
* <code>LifeStoryStore::addImage()</code>, life events / episodes / observations | |||
* <code>LiteratureStore::storeUploadedPdf()</code>, literature PDFs | |||
* <code>ProviderAppStore::saveUploadedFile()</code>, provider verification documents | |||
<code>AttachmentScanner</code> (used by feature-request attachments) is left alone, its status-return model is intentional for the queued-moderation flow there. <code>AntivirusHelper</code> (the old silent-no-op variant) was deleted; all callers consolidated onto <code>VirusScanner</code>. | |||
== 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><div data-pcp-save-block="block-name"></code>. The blocksave.js library: | Every block on <code>Special:MyProfile</code> is wrapped in <code><div data-pcp-save-block="block-name"></code>. The blocksave.js library: | ||
| Line 223: | Line 752: | ||
Slider numbers are also clickable: a single delegated handler on every <code><output></code> next to a range slider swaps it for a number input on click, accepts a precise typed value (clamps to the slider's <code>min</code>/<code>max</code>), commits with Enter, cancels with Escape. | Slider numbers are also clickable: a single delegated handler on every <code><output></code> next to a range slider swaps it for a number input on click, accepts a precise typed value (clamps to the slider's <code>min</code>/<code>max</code>), commits with Enter, cancels with Escape. | ||
Scroll position is preserved across the rare reloads (delete operations on diagnoses / medicines / experience reports, and the auto-reload after a new diagnosis or medicine is added) via <code>sessionStorage</code>. | Scroll position is preserved across the rare reloads (delete operations on diagnoses / medicines / experience reports, and the auto-reload after a new diagnosis or medicine is added) via <code>sessionStorage</code> (the <code>ext.pharmacopedia.bounceback</code> module). | ||
== Special pages == | == Special pages == | ||
| Line 230: | Line 759: | ||
! Page !! Purpose | ! Page !! Purpose | ||
|- | |- | ||
| <code>Special:MyProfile</code> || Edit your full profile (identity, demographics, personality, dx, meds). Autosave throughout. | | <code>Special:MyProfile</code> || Edit your full profile (identity, demographics, personality, dx, meds). Autosave throughout. Privacy mode toggle + share chips per fieldset. | ||
|- | |- | ||
| <code>Special:UserProfile/<name></code> || Public profile view (filtered by per-field visibility) | | <code>Special:UserProfile/<name></code> || Public profile view (filtered by per-field visibility + rule-based access). 🔗 Share chip in subtitle for self-view. | ||
|- | |- | ||
| <code>Special:MyAssessment</code> || Index of rich assessment reports | | <code>Special:MyAssessment</code> || Index of rich assessment reports | ||
|- | |- | ||
| <code>Special:MyAssessment/cati</code>, <code>/catq</code>, <code>/pid5bf</code>, <code>/mbti</code>, <code>/enneagram</code>, <code>/ocean</code> || Rich report per assessment | | <code>Special:MyAssessment/cati</code>, <code>/catq</code>, <code>/pid5bf</code>, <code>/mbti</code>, <code>/enneagram</code>, <code>/ocean</code> || Rich report per assessment; 🔗 Share chip in subtitle for owner | ||
|- | |||
| <code>Special:TakeAssessment/<key></code> || Generic paginated self-assessment runner; stores raw items and computes scores | |||
|- | |||
| <code>Special:MyLifeStory</code> || Owner editor + visual timeline + card list. Quick-add observation textarea + Event/Episode buttons. 🔗 Share chip in subtitle. Privacy-mode + free-text-refs banners. | |||
|- | |||
| <code>Special:MyLifeStory/add-episode</code>, <code>/edit-episode/<id></code> || Episode form (create / edit) | |||
|- | |||
| <code>Special:MyLifeStory/edit-observation/<id></code> || Observation edit form (re-parses raw text) | |||
|- | |||
| <code>Special:LifeStory/<name></code> || Public life-story view (read-only, visibility-filtered) | |||
|- | |||
| <code>Special:LifeImage</code> || Visibility-gated image streamer for life-story event images | |||
|- | |||
| <code>Special:MyCohorts</code> || Owner-managed groups for share-with-cohort flows; create / rename / delete / add+remove members | |||
|- | |||
| <code>Special:MyShareLog</code> || Who has viewed your shared content (timestamp, viewer, namespace, rule id; anonymous IPs masked) | |||
|- | |||
| <code>Special:MyRefLinks</code> || Bulk linker: find free-text refs in observations that now match a structured entity; one-click upgrade or dismiss | |||
|- | |||
| <code>Special:MyPerspectives</code> || Owner: mint observer-perspective invite links and review the consent inbox | |||
|- | |||
| <code>Special:Perspective/<token></code> || Public, token-gated observer-perspective form (no account; unlisted, anti-abuse defended) | |||
|- | |||
| <code>Special:AdministerAssessments</code> || Owner hub: send assessment scales to outside respondents and follow their results | |||
|- | |||
| <code>Special:RespondToAssessment/<token></code> || Token-gated respondent take-flow and revisitable results dashboard | |||
|- | |- | ||
| <code>Special:SaveProfileBlock</code> || AJAX endpoint for autosave (POST-only, JSON response) | | <code>Special:SaveProfileBlock</code> || AJAX endpoint for autosave (POST-only, JSON response) | ||
| Line 244: | Line 799: | ||
| <code>Special:Problem/<slug></code> || Individual problem page | | <code>Special:Problem/<slug></code> || Individual problem page | ||
|- | |- | ||
| <code>Special:SuggestProblem</code> || User-facing form to suggest a new problem | | <code>Special:SuggestProblem</code> || User-facing form to suggest a new problem (page-tied or standalone) | ||
|- | |||
| <code>Special:SuggestAnecdote</code> || Logged-in users propose an anecdote for a medicine page | |||
|- | |||
| <code>Special:SuggestEffect</code> || Logged-in users propose an effect for a medicine page | |||
|- | |||
| <code>Special:SuggestTitration</code> || Logged-in users propose a titration schedule for a medicine page | |||
|- | |- | ||
| <code>Special:ManageProblems</code> || Sysop tool for problem-repository moderation | | <code>Special:ManageProblems</code> || Sysop tool for problem-repository moderation | ||
|- | |- | ||
| <code>Special: | | <code>Special:ManageEffects</code> || Sysop CRUD for the global effects vocabulary | ||
|- | |- | ||
| <code>Special:ManageInteractions</code> || Sysop bulk-edit interaction reports | | <code>Special:ManageInteractions</code> || Sysop bulk-edit interaction reports | ||
|- | |||
| <code>Special:ReviewExperience</code> || Sysop queue for pending experience reports | |||
|- | |- | ||
| <code>Special:DeletePharmaElement</code> || Sysop delete tool for any votable element | | <code>Special:DeletePharmaElement</code> || Sysop delete tool for any votable element | ||
|- | |||
| <code>Special:VerifyProvider</code> || User-facing form to apply for provider verification and check status | |||
|- | |||
| <code>Special:ProviderApplications</code> || Sysop queue to approve / reject provider-verification applications | |||
|- | |||
| <code>Special:VerificationDoc</code> || Permission-gated streamer for provider-verification document files | |||
|- | |||
| <code>Special:FeatureRequests</code> || User-facing feature-request board (submit and browse) | |||
|- | |||
| <code>Special:RequestReview</code> || Sysop feature-request review console (status counters, prioritized queue, triage) | |||
|- | |||
| <code>Special:LiteratureDoc</code> || Download proxy for approved literature PDFs (reviewers may preview pending) | |||
|- | |||
| <code>Special:LiteratureQueue</code> || Sysop literature review queue (approve / reject / delete) | |||
|- | |||
| <code>Special:PharmacopediaActivity</code> || Recent-activity feed: last 30 votes, effect reports, comments, literature submissions | |||
|- | |||
| <code>Special:NewUsers</code> || The 20 most recently registered accounts | |||
|- | |||
| <code>Special:ProfileAnalysis</code> || Sysop dashboard of cross-table profile aggregates; per-section CSV export | |||
|- | |||
| <code>Special:ProfileFilter</code> || Sysop cross-filter UI over user profiles (demographics, OCEAN ranges, dx, med); CSV export | |||
|- | |||
| <code>Special:PCPCtrls</code> || Sysop controls hub (gated by <code>$wgSpecialPageLockdown</code>) | |||
|- | |||
| <code>Special:AdminCtrls</code> || Admin-controls landing page (renders sysop-editable <code>MediaWiki:Adminctrls-body</code>) | |||
|- | |||
| <code>Special:DatePickerTest</code> || Developer sandbox exercising the date-input widget (point / range / possibility) | |||
|} | |} | ||
| Line 260: | Line 851: | ||
! Action !! Purpose | ! Action !! Purpose | ||
|- | |- | ||
| <code>pharmacopediavote</code> || | | <code>pharmacopediavote</code> || Binary OR choice/multi vote (routes by presence of <code>choices</code> param). Returns tally + user_choices for choice modes; gated by results-policy. | ||
|- | |- | ||
| <code>pharmacopedialikert</code> || Submit problem-efficacy likert (0–100 + −1 DK) | | <code>pharmacopedialikert</code> || Submit problem-efficacy likert (0–100 + −1 DK) | ||
| Line 280: | Line 871: | ||
| <code>pharmacopediaproblemsearch</code> || Problem-repository autocomplete | | <code>pharmacopediaproblemsearch</code> || Problem-repository autocomplete | ||
|- | |- | ||
| <code>pharmacopediaeffectslookup | | <code>pharmacopediaeffectslookup</code> || Picker used by the experience-submit form | ||
|- | |- | ||
| <code>pharmacopedialiteratureadd</code>, <code>literaturedelete</code> || Literature attachment ops | | <code>pharmacopedialiteratureadd</code>, <code>literaturedelete</code> || Literature attachment ops | ||
|- | |||
| <code>pharmacopediaobservation</code> || op=preview / op=submit for plain-text observations (routes to addObservation OR addEpisode) | |||
|- | |||
| <code>pharmacopediavisrules</code> || Visibility rule CRUD (list / create / update / revoke / newtoken) | |||
|- | |||
| <code>pharmacopediausersearch</code> || Username autocomplete for share-with-people picker | |||
|- | |||
| <code>pharmacopediacohorts</code> || Cohort CRUD + membership | |||
|- | |||
| <code>pharmacopediarefupgrade</code> || Free-text ref linker (op=candidates / apply / dismiss) | |||
|- | |||
| <code>pharmacopediaformaltest</code> || Formal-testing score operations (list / add / update / delete), with per-field visibility | |||
|} | |} | ||
| Line 321: | Line 924: | ||
* Efficacy (0–100 slider) | * Efficacy (0–100 slider) | ||
* Side-effect burden (0–100 slider) | * Side-effect burden (0–100 slider) | ||
* Stop reasons (personal + stopped only): JSON multi-select with optional severity slider per reason | * Stop reasons (personal + stopped only): JSON multi-select with optional severity slider per reason, codes: side_effects / ineffective / cost / no_longer_needed / clinician_advised / other | ||
* Free-text anecdote | * Free-text anecdote | ||
* Problems addressed (multi-pick with per-problem efficacy) | * Problems addressed (multi-pick with per-problem efficacy) | ||
| Line 331: | Line 934: | ||
! Table !! Purpose | ! Table !! Purpose | ||
|- | |- | ||
| <code>pcp_votable_elements</code> || Stable per-(page, slug) handle reused by votes / likert / comments | | <code>pcp_votable_elements</code> || Stable per-(page, slug) handle reused by votes / likert / comments. Also <code>ve_options</code> + <code>ve_options_h</code> + <code>ve_results_policy</code> for choice votes. | ||
|- | |- | ||
| <code>pcp_votes</code> || Binary +1/−1 votes | | <code>pcp_votes</code> || Binary +1/−1 votes (v_value) AND choice/multi votes (v_choices CSV + v_options_h drift hash) | ||
|- | |- | ||
| <code>pcp_likert_reports</code> || Problem efficacy (0–100 + −1 DK), TINYINT signed | | <code>pcp_likert_reports</code> || Problem efficacy (0–100 + −1 DK), TINYINT signed | ||
| Line 343: | Line 946: | ||
| <code>pcp_interactions</code> || Interaction edges (canonical-ordered pair) | | <code>pcp_interactions</code> || Interaction edges (canonical-ordered pair) | ||
|- | |- | ||
| <code>pcp_comments</code> || Threaded discussions | | <code>pcp_comments</code> || Threaded <code><discuss></code>-tag discussions (soft-delete, optional display name) | ||
|- | |- | ||
| <code>pcp_user_profiles</code> || Per-user profile meta (alias, attribution, voter hash) | | <code>pcp_user_profiles</code> || Per-user profile meta (alias, attribution, voter hash, prof_research_id) | ||
|- | |- | ||
| <code> | | <code>pcp_profile_fields</code> || Generic key-value field store: (namespace, key, num, text, visibility) | ||
|- | |- | ||
| <code>pcp_profile_diagnoses</code> || Per-user diagnoses (system, code, description, status, origin, severity 0–100, disability 0–100, dates, notes, visibility) | | <code>pcp_profile_diagnoses</code> || Per-user diagnoses (system, code, description, status, origin, severity 0–100, disability 0–100, dates, notes, visibility) | ||
| Line 359: | Line 962: | ||
| <code>pcp_experience_reports</code> || Experience reports (pending → approved); efficacy + burden 0–100; route + schedule; stop-reasons JSON; patient-count min + max | | <code>pcp_experience_reports</code> || Experience reports (pending → approved); efficacy + burden 0–100; route + schedule; stop-reasons JSON; patient-count min + max | ||
|- | |- | ||
| <code>pcp_life_events</code>, | | <code>pcp_life_events</code> || Timeline events. Columns: le_type (0=story, 1=image, 2=keyframe, 3=observation, 4=episode), le_polarity, le_raw_text (parser input), le_episode_type, le_episode_subtype, le_severity, le_date_struct (PCPDatePicker JSON; supports range) | ||
|- | |- | ||
| <code>pcp_literature</code> || | | <code>pcp_life_event_refs</code> || Join table linking events to entities (med / effect / problem / diagnosis / med_page / diagnosis_code / free); role (subject / cause / context / symptom / trigger / treatment) | ||
|- | |||
| <code>pcp_life_traits</code> || Keyframe trait values (assessment subscale snapshots → trajectory graph) | |||
|- | |||
| <code>pcp_life_images</code> || Image attachments (ClamAV-scanned) | |||
|- | |||
| <code>pcp_visibility_rules</code> || Per-(profile, namespace, key) sharing rules. Types: public / private / users / cohort / link_token / reciprocal. Optional vr_expires, vr_revoked, vr_attribution, vr_label. | |||
|- | |||
| <code>pcp_cohorts</code>, <code>pcp_cohort_members</code> || Owner-managed user groups for share-with-cohort flows | |||
|- | |||
| <code>pcp_visibility_view_log</code> || Audit trail of rule-permitted views (Special:MyShareLog) | |||
|- | |||
| <code>pcp_perspective_invite</code> || Token-bearing observer-perspective invitations (display name, object, type, max-uses) | |||
|- | |||
| <code>pcp_perspective</code> || Submitted observer perspectives (payload JSON, validity flag, consent gate) | |||
|- | |||
| <code>pcp_administer_respondents</code> || An owner's named contacts; the label is sealed to the owner's public key | |||
|- | |||
| <code>pcp_administer_invites</code> || One token-bearing administer link per send; stores only SHA-256 of the token | |||
|- | |||
| <code>pcp_administer_assessments</code> || The scale(s) in an invite; owner-sealed and respondent-sealed result copies | |||
|- | |||
| <code>pcp_administer_userkey</code> || Per-owner key material (X25519 public key, wrapped secret key, Mode-A salt + verifier) | |||
|- | |||
| <code>pcp_administer_research</code> || De-identified research pool: random id, coarsened month, no link back to the owner | |||
|- | |||
| <code>pcp_provider_apps</code> || Provider-verification applications (profession, specialty, jurisdiction, license, status, doc paths) | |||
|- | |||
| <code>pcp_literature</code> || Per-page literature submissions (citation metadata, optional PDF, status, reviewer) | |||
|- | |||
| <code>pcp_feature_request</code>, <code>pcp_feature_request_attachment</code>, <code>pcp_feature_request_comment</code> || Feature-request board: requests, ClamAV-scanned attachments, threaded comments | |||
|- | |||
| <code>pcp_formal_tests</code> || Catalog of standardized tests (abbrev, full name, category, score format) | |||
|- | |||
| <code>pcp_user_test_scores</code> || Per-user formal-test scores; raw score / percentile / pass-fail, each with its own visibility (uts_vis_raw / uts_vis_pct / uts_vis_passfail) and an estimate flag | |||
|} | |} | ||
== Notable lessons learned == | == Notable lessons learned == | ||
* '''Cargo string fields cap at ~300 chars.''' <code>structure</code> and <code>mechanism</code> on MedTemplate are short VARCHARs; long prose goes in MEDIUMBLOB sections | * '''Synthetic Event needs bubbles:true to trigger delegated listeners.''' <code>new Event('input')</code> defaults to <code>bubbles:false</code>, so listeners on parent wrappers never see programmatic dispatches. Native input/change events bubble by default; only JS-fired ones don't. Pass <code>{ bubbles: true }</code> explicitly. Bit DatePicker calendar-cell clicks 2026-05-18, typing in the text field autosaved fine, but picking a date from the calendar didn't, because blocksave's wrapper listener never received the event. | ||
* '''VARBINARY + LOWER() is a no-op.''' MariaDB's LOWER() returns binary types unchanged. <code>pcp_diagnosis_abbreviations</code> was migrated VARBINARY → VARCHAR/utf8mb4 | * '''MediaWiki form-field names collide with reserved URL params.''' A form <code><input name="title"></code> or <code>name="action"></code> with user-controlled value silently HIJACKS MW's dispatch when POSTed (body param overrides URL param). Symptom: form submits but lands on a wiki article named whatever the user typed, with URL bar still showing the special page. Bit the episode form 2026-05-18 (user typed 'Fake one' as title → got a 404 'create article: Fake one' page). Fix: prefix ALL custom form inputs with <code>pcp_</code> (both the input <code>name="..."</code> AND the matching <code>$request->getVal('...', ...)</code> read). Self-referential hidden inputs (<code>name="title" value="<getPageTitle()>"</code>) are safe. | ||
* '''FlaggedRevs locks template inclusions by default.''' Config fix: <code>$wgFlaggedRevsHandleIncludes=0</code> + remove NS_TEMPLATE from <code>$wgFlaggedRevsNamespaces</code> via an extension-function callback | * '''Cargo string fields cap at ~300 chars.''' <code>structure</code> and <code>mechanism</code> on MedTemplate are short VARCHARs; long prose goes in MEDIUMBLOB sections. Overruns silently lose Cargo data (MySQL Error 1406). | ||
* '''CSP must allow Cloudflare Turnstile and any other 3rd-party widget script source.''' | * '''VARBINARY + LOWER() is a no-op.''' MariaDB's LOWER() returns binary types unchanged. <code>pcp_diagnosis_abbreviations</code> was migrated VARBINARY → VARCHAR/utf8mb4 so case-insensitive LIKE works natively without CONVERT() wrappers. | ||
* '''Sidebar cache must be purged after CLI <code>maintenance/edit.php</code> writes''' to <code>MediaWiki:Sidebar</code> or other chrome pages: <code> | * '''FlaggedRevs locks template inclusions by default.''' Config fix: <code>$wgFlaggedRevsHandleIncludes=0</code> + remove NS_TEMPLATE from <code>$wgFlaggedRevsNamespaces</code> via an extension-function callback. | ||
* ''' | * '''CSP must allow Cloudflare Turnstile and any other 3rd-party widget script source.''' Audit script-src / frame-src / connect-src / style-src whenever adding any 3rd-party JS widget. | ||
* '''Sidebar cache must be purged after CLI <code>maintenance/edit.php</code> writes''' to <code>MediaWiki:Sidebar</code> or other chrome pages. | |||
* '''CLI E_USER_DEPRECATED suppressed in LocalSettings.php''' (EmbedVideo / FlaggedRevs spam on MW 1.46). Web behavior unaffected. | |||
* '''MW ApiResult drops keys starting with <code>_</code>.''' Any field whose key starts with underscore in an ApiResult payload is treated as internal metadata and stripped. Rename to plain identifier. Bit Phase 2-4 of visibility-rules subsystem. | |||
* '''MW API serializes int-keyed assoc arrays unreliably.''' <code>[23 => 'Alice']</code> may arrive as <code>{"23": "Alice"}</code> or worse. Always use list-of-objects (<code>[{"id": 23, "name": "Alice"}]</code>) for id-to-value maps across the API boundary. | |||
* '''PHP single-quoted strings don't interpret <code>\xNN</code> byte escapes.''' <code>'\xF0\x9F\x94\x97'</code> is 16 literal chars, NOT the 4-byte UTF-8 for 🔗. Use literal Unicode char or double-quoted <code>\u{1F517}</code>. Bit three times in one session. | |||
* '''PHP "0" is FALSY.''' <code>!"0"</code> evaluates to TRUE. So <code>if ( !$x )</code> silently treats <code>"0"</code> (string zero) as missing. Bit choice-vote tallying when voter picked option index 0; vote was IN the DB but invisible to readers. Use <code>=== null || === ''</code> for "missing or blank" intent. <code>empty()</code> has the same problem. | |||
* '''<code><span></code> can't contain <code><p></code>.''' MediaWiki auto-wraps tag content in <code><p></code> when there are newlines; browsers auto-close any <code><span></code> before the <code><p></code>, scattering child elements into wrong DOM positions. Use <code><div></code> for parser-tag wrappers whose content can span paragraphs (choice votes, life-story cards). | |||
* '''vis.Graph2d group <code>style</code> belongs at TOP LEVEL''' not nested in <code>options</code>. Nesting silently fails (no error, just invisible lines). Bit the trait-trajectory graph on first build. | |||
* '''A symmetric data-key cannot serve an asynchronous flow.''' The administer-to-others result must be encryptable while the owner is absent. A purely symmetric per-owner key has no holder at submission time; the model must be an asymmetric keypair, with the public key stored openly so a sealed box can always be written and only the owner's wrapped secret key can open it. | |||
* '''A de-identified row must carry no order and no link.''' An auto-increment id rank-correlates with the source table and an insert timestamp time-correlates with it. The research pool uses a random 128-bit id and a month-only date so a row genuinely cannot be traced back. | |||
== Hooks == | == Hooks == | ||
| Line 377: | Line 1,024: | ||
* <code>ParserFirstCallInit</code>: register all parser tags | * <code>ParserFirstCallInit</code>: register all parser tags | ||
* <code>LoadExtensionSchemaUpdates</code>: install / migrate schema (the sql/ directory + patches) | * <code>LoadExtensionSchemaUpdates</code>: install / migrate schema (the sql/ directory + patches) | ||
* <code>BeforePageDisplay</code>: inject the ext.pharmacopedia.* ResourceLoader modules | * <code>BeforePageDisplay</code>: inject the ext.pharmacopedia.* ResourceLoader modules; resolve and apply the page skin (the <code>pcp-skin-*</code> body class) and, on the Main Page / Category index, the <code>pcp-diptych-page</code> chromeless class | ||
* <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><problemMedicines slug="X"/></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><effectMedicines slug="X"/></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><problem ref="X"></code> on a medicine page links its problem-card title to <code>Problem:<Name></code>; every <code><effect ref="X"></code> links its label to <code>Effect:<Name></code>; the sidebar Common-uses list links the same way. <code>Special:Problem/<slug></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 == | ||
* <code>$wgPharmacopediaInteractionCategoryMarker</code> (default <code>Category:MedCategory</code>): only categories tagged with this marker appear in the add-interaction modal | * <code>$wgPharmacopediaInteractionCategoryMarker</code> (default <code>Category:MedCategory</code>): only categories tagged with this marker appear in the add-interaction modal | ||
* <code>$wgPharmacopediaVoteHashSecret</code> (required): HMAC secret for <code>v_voter_hash</code> so vote rows can't be mapped back to user accounts by anyone reading the DB without the secret | |||
* <code>$wgPharmacopediaAdminKeyDir</code>: filesystem directory, outside the web root, holding the administer-to-others server master key (managed mode); never in the database, never in the DB backup set | |||
* (Various permission-grant arrays via standard MW <code>$wgGroupPermissions</code>) | * (Various permission-grant arrays via standard MW <code>$wgGroupPermissions</code>) | ||
| Line 392: | Line 1,076: | ||
|-- includes/ | |-- includes/ | ||
| |-- Hooks.php | | |-- Hooks.php | ||
| |-- *Tag.php (parser tags: VoteTag, EffectTag, ProblemTag, ...) | | |-- *Tag.php (parser tags: VoteTag, EffectTag, ProblemTag, | ||
| |-- *Store.php (data access: EffectStore, ProblemStore, ...) | | | ClassGridTag, ClassTreeTag, LiteratureTag, | ||
| | FrontPageTag, CategoryIndexTag, ...) | |||
| |-- DiptychChrome.php (shared topbar + footer for the diptych splashes) | |||
| |-- *Store.php (data access: EffectStore, ProblemStore, ElementStore, LifeStoryStore, ...) | |||
| |-- UserProfileStore.php (the big one; profile / dx / meds / abbreviations) | | |-- UserProfileStore.php (the big one; profile / dx / meds / abbreviations) | ||
| |-- VisibilityResolver.php (per-record sharing decision engine) | |||
| |-- VirusScanner.php (ClamAV gate; fail-closed) | |||
| |-- ObservationParser.php (plain-text -> structured observation) | |||
| |-- SpecialMyProfile.php (the user profile editor; large) | | |-- SpecialMyProfile.php (the user profile editor; large) | ||
| |-- SpecialMyAssessment.php (rich reports for | | |-- SpecialMyAssessment.php (rich reports for the dimensional assessments; large) | ||
| |-- | | |-- SpecialMyLifeStory.php (life-story editor + visual timeline; large) | ||
| |-- SpecialMyPerspectives.php / SpecialPerspective.php (observer perspectives) | |||
| |-- SpecialAdministerAssessments.php / SpecialRespondToAssessment.php (administer to others) | |||
| |-- SpecialPCPCtrls.php (sysop controls hub) | |||
| |-- Special*.php (other special pages) | | |-- Special*.php (other special pages) | ||
| |-- ProfileDatasets.php (countries, languages, genders, religions, etc.) | | |-- ProfileDatasets.php (countries, languages, genders, religions, etc.) | ||
| |-- DatePicker.php (range / possibility-mix date widget backend) | | |-- DatePicker.php (range / possibility-mix date widget backend + injectBirthdayContextOnce) | ||
| |-- Assessments/ | | |-- Assessments/ | ||
| | |-- AssessmentRegistry.php (single source of truth for the 13 instruments) | |||
| | |-- AdminCrypto.php (X25519 / Argon2id / AES-256-GCM helper) | |||
| | |-- Cati.php, CatiNorms.php | | | |-- Cati.php, CatiNorms.php | ||
| | |-- Catq.php | | | |-- Catq.php, CatqNorms.php | ||
| | |-- Pid5bf.php | | | |-- Pid5bf.php, Pid5bfNorms.php | ||
| | |-- Mbti.php | | | |-- Mbti.php | ||
| | |-- Enneagram.php | | | |-- Enneagram.php | ||
| | |-- Ocean.php, OceanNorms.php | |||
| | |-- Asrs.php, Amaas.php | |||
| | |-- Bpns.php, BpnsNorms.php | |||
| | |-- Nfcs.php, NfcsNorms.php | |||
| | |-- Ocipcp.php | |||
| | |-- WhoqolBref.php, WhoqolBrefNorms.php | |||
| | `-- Raadsr.php (deprecated 2026-05-17 in favor of CATI, kept for archival reads) | | | `-- Raadsr.php (deprecated 2026-05-17 in favor of CATI, kept for archival reads) | ||
| `-- Api/ | | `-- Api/ | ||
| `-- | | |-- VoteApi.php, EffectApi.php, ... | ||
| |-- ObservationApi.php | |||
| |-- VisibilityRulesApi.php, UserSearchApi.php, CohortsApi.php | |||
| `-- RefUpgradeApi.php | |||
|-- resources/ | |-- resources/ | ||
| |-- ext.pharmacopedia.js | | |-- ext.pharmacopedia.js (single IIFE: chip-picker, dx autocomplete, vote logic, ...) | ||
| |-- ext.pharmacopedia.blocksave.js (debounced autosave per block) | | |-- ext.pharmacopedia.styles.css (base stylesheet, self-hosted fonts) | ||
| |-- ext.pharmacopedia.css | | |-- ext.pharmacopedia.blocksave.js (debounced autosave per block) | ||
| |-- ext.pharmacopedia. | | |-- ext.pharmacopedia.bounceback.js (scroll-position preservation) | ||
| |-- ext.pharmacopedia.confirmdelete.* (styled destructive-action prompt) | |||
| |-- ext.pharmacopedia.datepicker.js + .datepicker.styles.css | |||
| |-- ext.pharmacopedia.timepicker.js + .timepicker.styles.css | |||
| |-- ext.pharmacopedia.share.js + .css | |||
| |-- ext.pharmacopedia.perspective.* (observer-perspective form enhancement) | |||
| |-- ext.pharmacopedia.administer.* (administer-to-others surfaces) | |||
| |-- ext.pharmacopedia.observation.js + .css | |||
| |-- ext.pharmacopedia.refupgrade.js + .css | |||
| |-- ext.pharmacopedia.lifetimeline.js + .css | |||
| |-- ext.pharmacopedia.lifegraph.js + .css | |||
| |-- ext.pharmacopedia.kitsync.js | |||
| |-- ext.pharmacopedia.frontpage.* / ext.pharmacopedia.categoryindex.* (diptych splashes) | |||
| |-- ext.pharmacopedia.appearance.* (the Appearance rail) | |||
| |-- ext.pharmacopedia.skin.plants.css (plants-skin overlay) | |||
| `-- vendor/vis-timeline/ (vis-timeline 7.7.3, Apache-2.0 + MIT) | |||
| |-- vis-timeline-graph2d.min.js | |||
| |-- vis-timeline-graph2d.min.css | |||
| `-- LICENSE | |||
|-- sql/ | |-- sql/ | ||
| |-- (one .sql per table; patches as patch-*.sql) | | |-- (one .sql per table; patches as patch-*.sql) | ||
Latest revision as of 02:45, 28 May 2026
Version: 0.9.8.7 · Requires: MediaWiki >= 1.46.0 · PHP >= 8.5
Author: MDElliottMD · License: GPL-2.0-or-later
Source: /var/www/mediawiki/extensions/Pharmacopedia/
The Pharmacopedia extension turns a MediaWiki install into a structured, community-edited medicine reference with rich user-profile, assessment, life-story, and visibility-sharing infrastructure. It adds parser tags, special pages, API modules, two dark skins, a chip-picker / autosave UI framework, a vis-timeline-based visual life timeline, a granular per-record sharing subsystem, an observer-perspective subsystem, a token-gated subsystem for administering assessments to people outside the wiki, a two-origin diptych front-of-house, and a database schema that together support:
- Structured medicine pages via the
{{MedTemplate}}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); 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 (
type="single"/type="multi"with 2-5 options, results-visibility policy per element) - Two-perspective data capture (personal vs. provider) wherever clinically meaningful
- User profile with dimensional personality / autism assessments (CATI, CAT-Q, MBTI, Enneagram, PID-5-BF, OCEAN/BFI-10) and rich auto-generated reports
- Further self-report instruments (BPNS, NFCS, OCI-PCP, WHOQOL-BREF) and the HYD-PCP wellbeing check-in, on the same auto-scored, report-bearing pattern
- ADHD screening (ASRS and AMAAS-SR) plus a Formal testing log of standardized-test scores, every score field carrying its own visibility
- Diagnosis autocomplete backed by ~41,500 ICD-10-CM, ICD-11, and DSM-5 codes
- Life-story timeline: visual swimlanes (vis-timeline 7.7.3, vendored Apache-2.0) plus a synchronized trait-trajectory overlay (vis.Graph2d); card-list view alongside; quick-add free-text observation parser; range-date episode form with severity slider
- Per-record sharing subsystem: rule types include public, private, users, cohort, link_token, reciprocal; time-bounded; audit log of who-viewed-what; bulk free-text → structured ref upgrader; privacy mode that disables legacy fallback
- Observer perspectives: token-gated, no-account second-party input on something a user owns, under a two-gate consent model
- Administer to others: send any registered assessment scale to people outside the wiki by one-time link, collect their results, and follow them over time, under per-owner public-key encryption with an optional zero-knowledge passphrase mode
- Two-origin diptych: the Main Page and Category index render as chromeless full-viewport splashes giving the pharmaceutical and plant origins equal face
- Chip-picker / autosave / slider-precise-input UI framework shared across editor surfaces
- Date + time + range kit: PCPDatePicker (point/range/possibility), PCPTimePicker (fuzzy time-only: "4p", "noon", "quarter past 6")
- Verified-provider role with document-based verification
- Fail-closed ClamAV scan on every image / file upload (hard project rule)
- Per-user research_id (stable 10-char hex, opaque, never reassigned) for de-identified research participation
Precision doctrine
A standing design rule (memorialised 2026-05-17) that shapes every storage / UI decision in the extension:
- No bucketing where a number or free-text will do. Income is numeric + currency, not a 5-band dropdown. Education keeps the bucketed dropdown and adds numeric years of schooling + free-text field of study.
- No single-select where multi-select reflects reality. Languages, gender identities, ethnicities, pronouns, religion, marital status, stop-reasons, all use chip-pickers (with optional severity per chip where relevant).
- No forced category where a continuous score works. All assessments (Enneagram 9 type sliders, MBTI 4 dichotomy sliders, OCEAN 5 trait sliders, CATI/CAT-Q/PID-5-BF items) use continuous 0–100 sliders, never radio buttons or button rows. Valence is ±100, not ±3.
- Storage in canonical form, UI converts at display. Heights stored cm regardless of user's preferred unit (cm or ft+in); ICD codes stored as ISO; date capture as range / possibility-mix JSON.
- Browser auto-fill as suggestion only. Country chip pre-fills from
navigator.language; languages fromnavigator.languages; time zone fromIntl.DateTimeFormat().resolvedOptions().timeZone. User can always edit or remove. - Always allow custom free-text where the curated list might miss someone. Chip-pickers accept Enter-to-add custom chips for all picklists except ISO-coded ones (country, language).
High-level architecture
- Backend (PHP):
includes/, one class per parser tag, store, special page, or API module. Auto-loaded underMediaWiki\Extension\Pharmacopedia\. Assessment classes underincludes/Assessments/. API modules underincludes/Api/. - Frontend (JS / CSS): multiple ResourceModules per surface area:
ext.pharmacopedia: 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)ext.pharmacopedia.styles: base extension stylesheet (self-hosted Geist / Newsreader / Source Serif fonts, core component styling)ext.pharmacopedia.blocksave: debounced autosave per block (race-safe)ext.pharmacopedia.bounceback: preserves the reader's scroll position across POST-then-reload actions via sessionStorageext.pharmacopedia.confirmdelete: styled red warning prompt replacingwindow.confirm()on destructive actionsext.pharmacopedia.datepicker+.datepicker.styles: range / possibility-mix date widgetext.pharmacopedia.timepicker+.timepicker.styles: time-only widget (fuzzy parsing, extracted from DatePicker)ext.pharmacopedia.share: per-record share dialog (People / Link / Cohorts tabs)ext.pharmacopedia.perspective: observer-perspective form enhancement (slider readout, progress, consent/delete confirm)ext.pharmacopedia.administer: the administer-to-others surfaces (take-flow slider readout + "Not sure" toggling, owner-hub styling)ext.pharmacopedia.editor: editor enhancements loaded onaction=edit/submit; smart paste converts bare PMID or DOI from the clipboard into a formatted<ref>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<ref>skeletonext.pharmacopedia.observation: quick-add observation textarea + live previewext.pharmacopedia.refupgrade: bulk linker for free-text → structured refsext.pharmacopedia.vis-timeline-vendor: vis-timeline 7.7.3 (vendored)ext.pharmacopedia.lifetimeline: visual life-story timeline + swimlanes + toolbarext.pharmacopedia.lifegraph: trait-trajectory overlay (vis.Graph2d) synced to timelineext.pharmacopedia.kitsync: glue that propagates kit-widget changes into legacy hidden inputs + drives privacy-mode toggleext.pharmacopedia.frontpage/ext.pharmacopedia.categoryindex: the two diptych splash modules, each self-contained so it renders with no skin loadedext.pharmacopedia.appearance: the collapsible Appearance rail (reader text-size control)ext.pharmacopedia.skin.plants/ext.pharmacopedia.skin.fungi: the earth-toned plants-skin overlay and the fungi sub-skin override layer
- Schema:
sql/, roughly three dozen core tables plus migration patches. Picked up via theLoadExtensionSchemaUpdateshook.
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 certbot.timer (fires twice daily, renews when within 30 days of expiry). Private key on disk is mode 600 root:root in /etc/letsencrypt/archive/pharmacopedia.wiki/.
Apache TLS config (live from /etc/letsencrypt/options-ssl-apache.conf + /etc/apache2/mods-enabled/ssl.conf):
SSLProtocol all -SSLv3 SSLCipherSuite HIGH:!aNULL SSLSessionTickets off
Effective TLS range: TLS 1.0 through 1.3 (SSLv3 explicitly refused). The cipher suite shorthand HIGH:!aNULL 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 SSLProtocol -TLSv1 -TLSv1.1 is queued; see Honest limitations below. SSLSessionTickets off preserves forward secrecy across server restarts.
HSTS:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Two years, subdomains included, preload-ready. The preload directive is present in the header; submission to the Chromium HSTS preload list is pending.
HTTP security headers
Set at the Apache layer (/etc/apache2/conf-enabled/security-headers.conf), 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)
'unsafe-inline' + 'unsafe-eval' are required by MediaWiki's JS/CSS pipeline; challenges.cloudflare.com is whitelisted only for the Cloudflare Turnstile widget. object-src 'none' blocks Flash/applet vectors; base-uri 'self' blocks base-tag hijack; form-action 'self' 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 .pre-<feature> snapshots, .orig 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 /mw-config/ is 403'd at the vhost layer regardless of any $wgUpgradeKey value.
Token-bearing URLs are redacted from access logs by /etc/apache2/conf-enabled/pcp-log-redaction.conf. Pharmacopedia issues two URL families that carry per-request secrets in the path: Special:RespondToAssessment/<token> (the invite token derives the AES key for the respondent-readable AdminCrypto copy) and Special:Perspective/<token>. 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 ?title= invocations.
PHP-FPM hardening
Production pool /etc/php/8.5/fpm/pool.d/mediawiki-prod.conf runs as www-data on an UDS socket (0660 www-data:www-data), pm = ondemand, pm.max_children = 16, pm.max_requests = 500 (workers cycle every 500 requests to recover memory). Per-pool open_basedir 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: expose_php = Off, display_errors = Off (errors go to log only), allow_url_include = Off (remote-PHP include attack vector closed; allow_url_fopen = On stays because MW needs upload-from-URL). Session cookies: secure = 1, httponly = 1, samesite = "Lax", use_strict_mode = 1, use_only_cookies = 1, session-end lifetime, 24-minute idle gc. opcache enabled with validate_timestamps = 1 + 2-second revalidate (no stale-code-after-deploy risk).
Database
MariaDB 10.11.14, bind-address = 127.0.0.1 only (the loopback). DB ports are not exposed to the network; UFW does not need a rule because the bind never reaches the wire. sql_mode includes STRICT_TRANS_TABLES + ERROR_FOR_DIVISION_BY_ZERO (strict type + math behavior). have_ssl = DISABLED is deliberate (loopback-only connections do not benefit from TLS overhead). The wiki's DB account is scoped to the two MW schemas (mediawiki, mediawiki_staging); no FILE, no SUPER, no GRANT OPTION. A DB compromise via injection is bounded to those two schemas.
SSH + host
sshd is key-only (PasswordAuthentication no, KbdInteractiveAuthentication no, PermitEmptyPasswords no), PermitRootLogin prohibit-password (root accessible only with an authorized key), MaxAuthTries 6, 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: sshd, apache-auth, apache-badbots, mediawiki-auth (matches failed wiki logins by spotting 200 responses on POSTs to Special:UserLogin / Special:CreateAccount — successful logins redirect 302), and web-scanners (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:
0x4AAAAAADMu_bvOguDp0U52 - Backup target:
dropbox:pharmacopedia-backups(Dropbox holds only AES-256-encrypted bundles; the passphrase never leaves the host)
Rotation policy:
$wgSecretKeyis NOT rotated (would invalidate every session and signed-state cookie).$wgUpgradeKeyrotated 2026-05-22 to 256 bits.$wgPharmacopediaVoteHashSecretis 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 user_password row: HMAC-SHA-512 inner hash, 30,000 iterations, 64-byte derived key, 16-byte random salt per user. The $wgPasswordConfig 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 (extensions/Pharmacopedia/includes/Assessments/AdminCrypto.php):
Per-owner X25519 keypair generated via sodium_crypto_box_keypair() (libsodium, kernel CSPRNG). The public key is stored in the clear; the secret key is wrapped at rest in pcp_administer_userkey.uk_wrapped_seckey with AES-256-GCM (12-byte fresh IV per call, 16-byte authentication tag, empty AAD; wrapped layout: IV || ciphertext || tag).
Respondent submissions are sealed to the owner's public key via crypto_box_seal (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
sodium_crypto_pwhashwithARGON2ID13). 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 inuk_kdf_salt; 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
/var/lib/pharmacopedia-adminkeys/master.key(mode 600www-data:www-data), 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: respondentKey = SHA-256("pcp-administer-respondent-v1:" || rawToken). 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 openssl genrsa; JWT signing algorithm RS256. Access tokens live 1 hour; refresh tokens 1 month (MWOAuth defaults). PKCE with S256 challenge is REQUIRED for public clients ($wgOAuth2RequireCodeChallengeForPublicClients = true); 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 https://pharmacopedia.wiki/app/oauth-callback that forwards the authorization code + state to the pharmacopedia://oauth custom URL scheme. Special:UserLogin and Special:CreateAccount also carry autocomplete field attributes (username, current-password / new-password, email) injected by the AuthChangeFormFields 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 $wgPharmacopediaVoteHashSecret (256 bits, never rotated by policy). The hash is a 64-character hex string in pcp_votes 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 Special:Perspective/<token> links. As of 0.9.8.7, every new invite stores BOTH the cleartext token in pcp_perspective_invite.pvi_token AND a SHA-256 hash of the same token in a new pvi_token_hash column (BINARY(32), UNIQUE). Lookup at PerspectiveStore::resolveToken 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 AdminCrypto::hashInviteToken helper (raw 32-byte SHA-256), the same shape as the existing pcp_administer_invites.inv_token_hash pattern.
Honest current status: this is half-shipped on purpose. The cleartext pvi_token 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
/usr/local/bin/pharmacopedia-backup.sh 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 /var/www/mediawiki/images, LocalSettings.php, the Pharmacopedia extension, and local skin assets. The full mediawiki 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 640 root:adm (Apache) and 640 www-data:adm (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 /var/log/mediawiki/{exception,error,dberror,fatal}.log. The pcp_visibility_view_log 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
clamdscan(persistent daemon, fast) with a fallback toclamscan. 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
SSLProtocol all -SSLv3, which does not exclude older TLS protocol versions at the protocol layer. Hardening to an explicit modern cipher list withSSLProtocol -TLSv1 -TLSv1.1is queued. - Perspective-invite tokens are half-migrated to hashed storage. 0.9.8.7 added the
pvi_token_hashcolumn, backfilled it, and switched the lookup to hash-first; the cleartextpvi_tokencolumn 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 me directly. No bug-bounty program; just genuine appreciation for the time and the love of pretty darn good privacy. No NDA, no scope restriction, no preferred-disclosure-window. Reach out for any reason.
Skins, layout, and the Appearance rail
Two skins. Every page renders in one of two dark skins. The default pharmaceutical skin is the violet-on-near-black clinical identity. The plants skin is an earth-toned treatment (ext.pharmacopedia.skin.plants) applied to plant-origin medicine pages. Both skins are dark; there is no light mode. The fungi sub-skin (ext.pharmacopedia.skin.fungi) is a specialization of the plants skin for fungal medicine pages: a fungus page carries both the pcp-skin-plants and pcp-skin-fungi body classes, loading the plants base plus a fungi override layer (a damp cool-dark palette, a spore-dust grain, the mushroom mark, fungi section-marker hues); everything else inherits from the plants skin.
Hooks::resolvePcpSkin picks the skin. A content (medicine) page is read by its OWN DIRECT origin category: a direct Category:Fungi tag gives the fungi sub-skin (checked first), a direct Category:Plants tag gives the plants skin, anything else pharma. There is no recursive category walk, so a page sitting inside a dual-parented class category (Category:Psychedelics parents under both origins) is skinned by its own origin tag, not by its class. A category page has no single direct origin, so it is resolved by walking its category chain (plants only when the chain is purely plant).
Full-width layout. The content frame runs edge to edge rather than in a fixed-width column; prose is held to a readable measure by its own constraint, and the layout gutters are set by layout tokens.
The Appearance rail. A collapsible rail (ext.pharmacopedia.appearance) gives the reader appearance controls, currently a text-size control. The chromeless diptych splashes suppress the rail.
Front-of-house: the diptych
The Main Page and the Category index are built as a two-origin diptych: a pharmaceutical column and a plant column side by side, the two origins of the materia medica given equal face. Both render as chromeless full-viewport splashes. A body.pcp-diptych-page class, added by the BeforePageDisplay hook on those two pages, hides all Vector chrome, and the module supplies its own topbar and footer. A full-height two-origin split gradient runs the page edge to edge, the pharma dark on the left half and the plant dark on the right, a 1px seam down the middle, so the diptych reads top to bottom with no separate bands.
Two parser tags generate the diptych from live wiki data:
<frontpage>(FrontPageTag), the Main Page: a featured medicine and featured plant medicine, class / Pharmako-volume browse lists, recently-updated lists, portal links, a status strip.<categoryindex>(CategoryIndexTag), the Category index: the pharmacological classes and the plant lineages as two side-by-side trees.
DiptychChrome supplies the shared topbar and footer for both. The modules are ext.pharmacopedia.frontpage and ext.pharmacopedia.categoryindex; 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: action=opensearch first (fast prefix index across the eight content namespaces: main, Category, Enzyme, Receptor, Phenotype, USLegal, Problem, Effect), with a action=query&list=search 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
Registered via Hooks::onParserFirstCallInit:
| Tag | Purpose | Class |
|---|---|---|
<vote> |
after-vote\|hidden" for tally-visibility policy. | VoteTag
|
<effect> |
Therapeutic or adverse effect; patient + provider perspectives; provider freq slider 0–100; shared valence slider ±100 | EffectTag
|
<discuss> |
Threaded comment widget | CommentTag
|
<effectsummary> |
Roll-up aggregate header | EffectSummaryTag
|
<titration> |
Titration strategy card with up/down vote | TitrationTag
|
<anecdote> |
Personal or provider story with up/down vote | AnecdoteTag
|
<problem> |
A problem the medicine addresses; 0–100 efficacy likert slider + "don't know" toggle | ProblemTag
|
<pharmaInteractions/> |
Self-closing; renders the Interactions section for the current page | InteractionTag
|
<pharmaExperience/> |
Self-closing; renders the Experience report form (efficacy, burden, dose, route, schedule, stop-reasons) | ExperienceTag
|
<pharmaLiterature/> |
Self-closing; per-medicine "Relevant literature" section: approved pcp_literature entries plus a collapsed submission form |
LiteratureTag
|
<classGrid/> |
A grid of medicine-class categories (those tagged Category:MedCategory); count + exclude attributes; 5-minute cache |
ClassGridTag
|
<classTree/> |
The MedCategory classes as a hierarchy with member counts; exclude attribute; 5-minute cache |
ClassTreeTag
|
<frontpage/> |
The two-origin diptych Main Page (see Front-of-house) | FrontPageTag
|
<categoryindex/> |
The two-origin diptych Category index | CategoryIndexTag
|
<pharmaCommonUses>...</pharmaCommonUses> |
Medicine-page sidebar "Common uses": top-5 problems by rater count, ranked desc; falls back to the legacy hand-entered uses wikitext when zero problems are linked |
CommonUsesTag
|
<problemMedicines slug="X"/> |
Auto-generated list of medicines that carry a <problem ref="X">. Used on every Problem:<Name> namespace page so the canonical "Medicines used for X" section maintains itself |
ProblemMedicinesTag
|
<effectMedicines slug="X"/> |
Auto-generated list of medicines that carry an <effect ref="X">. Used on every Effect:<Name> namespace page so the canonical "Medicines that may cause X" section maintains itself |
EffectMedicinesTag
|
All non-self-closing rating tags take a slug argument and (where relevant) a title, label, author, ref, or perspective.
Tag wikitext examples
<problem slug="depression" title="Major depressive disorder" author="MDElliottMD">First-line for moderate to severe MDD.</problem> <effect slug="nausea" label="Nausea"/> <effect ref="hyperkalemia"/> <!-- ref to global effect library --> <titration slug="slow-start-elderly" title="Slow start (elderly)" author="MDElliottMD">Begin at 10 mg q AM; titrate by 10 mg every 14 days.</titration> <anecdote slug="qi8sg2" perspective="provider" author="MDElliottMD">One patient developed serotonin syndrome at week 3...</anecdote> <pharmaInteractions/> <pharmaExperience/> <vote slug="fav-color" type="single" options="Red; Blue; Green"> What's your favorite color?</vote> <vote slug="side-effects" type="multi" results="after-vote" options="Dry mouth; Insomnia; Anxiety; Headache; None"> Which side effects did you experience?</vote>
Voting / rating semantics
| Element | Scale | Perspectives | Storage |
|---|---|---|---|
| Vote tag (binary) | +1 / −1 binary | single | pcp_votes.v_value
|
| Vote tag (single-choice) | one of 2-5 options | single | pcp_votes.v_choices (CSV index)
|
| Vote tag (multi-choice) | any subset of 2-5 options | single | pcp_votes.v_choices (CSV indices)
|
| Titration | +1 / −1 binary | single | pcp_votes
|
| Anecdote | +1 / −1 binary | single (perspective is metadata) | pcp_votes
|
| Problem (efficacy likert) | 0–100 continuous slider, optional "Don't know" (-1) | single | pcp_likert_reports
|
| Effect (patient) | experienced ∈ {yes, no, unsure} + valence ±100 slider | patient | pcp_effect_reports (perspective=1)
|
| Effect (provider) | frequency 0–100 continuous slider + "Don't know" (-1) + valence ±100 slider | provider | pcp_effect_reports (perspective=2)
|
| Interaction | experience 1–5 + valence ±100 slider + optional note | user + provider, separate aggregates | pcp_interaction_reports
|
Choice / multi vote elements expose per-option tallies via tallyChoices() on demand. Per-option bars render inline in the picker. The results attribute gates tally visibility:
live(default), tally always visibleafter-vote, tally hidden until viewer has votedhidden, tally never shown (only options + "thanks" on submit)
Server-side options-hash (ve_options_h) detects post-vote option-list edits; the API rejects new votes whose submitted hash doesn't match the live one. Voter identities are stored as HMAC-SHA256 (v_voter_hash) so admins reading the DB cannot map votes to user accounts without the HMAC secret.
Server-side aggregates: n, mean of the rating field, and (for interactions) severe = (vmean ≤ −83.0) (rescaled from the original ±3-scale −2.5). Aggregates are recomputed and returned by every report-submit API call so the row re-renders in place without a page reload.
Effect bucketing
When a wiki <ul> contains only <effect> cards, JavaScript groups them into buckets by the provider frequency mean (data-fmean):
| Bucket | fmean band | Default state |
|---|---|---|
| Common | > 20 | expanded, always visible |
| Uncommon | > 5 and ≤ 20 | collapsed |
| Rare | ≤ 5, provider vmean > −83 | collapsed |
| Rare but Severe | ≤ 5 and vmean ≤ −83 | expanded by default, red highlight |
| Not yet rated | no provider data (n=0) | collapsed, only renders if non-empty |
The vmean ≤ −83 threshold is also the trip-wire for the "severe" red treatment on interaction rows.
User profile
Special:MyProfile is the user-facing editor for everything personal. Every block on it autosaves on a 800 ms debounce (see Autosave infrastructure).
Visible at top: a Privacy-mode panel toggling whether legacy public fallback applies (privacy-mode ON = only explicit share rules grant access; OFF = the field-level pf_visibility enum applies).
🔗 Share chips appear in each fieldset legend (Demographics, Diagnoses, Medicines) so the owner can scope a share-rule to that namespace via the modal Share dialog.
Block list
- Identity (display alias, default attribution, experience-report visibility, read-only Research ID badge)
- Demographics (full chip-picker rebuild, see below)
- Personality (Big Five OCEAN sliders + collapsible assessments)
- Enneagram (9 type sliders + 45-item screening test)
- MBTI (4 dichotomy sliders + 32-item OEJTS test)
- Self-report assessments (PID-5-BF, CATI, CAT-Q, BPNS, NFCS, OCI-PCP, WHOQOL-BREF, HYD-PCP, each a collapsible inline test)
- ADHD screening (ASRS adult-ADHD screener and AMAAS-SR attention self-report, each a collapsible inline test)
- Formal testing (a log of standardized-test scores; raw score, percentile and pass/fail each carry their own privacy setting)
- Diagnoses (multi-row with ICD-10-CM + ICD-11 autocomplete, severity slider 0–100, disability slider 0–100, status, origin, dates, notes)
- Medicines I have tried (multi-row with med-name autocomplete, dose, route 16-option dropdown, schedule with datalist suggestions, efficacy + burden sliders 0–100, periods via date-picker)
Demographics (chip-picker / structured-widget rebuild)
All categorical demographics use the chip-picker widget (single or multi, with optional primary marker, optional custom free-text). All quantitative demographics use numeric inputs or structured composite widgets.
- Birthday DatePicker (single / range / possibility-mix). Setting (or changing) the birthday auto-syncs a TYPE_STORY life event tagged
auto-birthtitled 'Born!' on the life-story timeline. Subsequent birthday edits move ONLY the event's date; user edits to title, body, tags, images are preserved. - Sex assigned at birth single-select (clinical category)
- Gender identity multi-select chip-picker, 27 common terms + custom
- Pronouns multi-select chip-picker, 17 common sets + custom
- Ethnicity / race multi-select chip-picker, 23 broad categories + custom
- Country of residence chip-picker single-value, ISO 3166 list (~100 entries), auto-suggested from
navigator.language - Languages chip-picker multi-select with ★ primary marker, ISO 639-1 list (~70 entries with endonyms), auto-suggested from
navigator.languages - Height / weight unit toggle (Metric cm/kg or US ft+in/lb); stored canonically as cm/kg regardless
- Handedness single-select (3 options)
- Smoking structured widget: status + cigs/day + years smoked + quit date (PCPDatePicker); auto-computes pack-years
- Alcohol structured widget: drinks/week + typical drink type + max one occasion
- Education bucketed highest-level + numeric years + free-text field of study
- Employment bucketed status + free-text occupation + numeric hours/week
- Income numeric amount + currency selector (20 options) + individual/household scope
- Marital / relationship status chip-picker single + custom
- Religion / spirituality chip-picker single + custom, 36 traditions + secular stances
- Housing chip-picker single + custom
- Number of children numeric
- Time zone free text, auto-detects IANA TZ on first load if empty
- Chronotype / sleep schedule two PCPTimePicker widgets (typical bedtime, typical wake; fuzzy parsing accepts "10p", "bedtime", "quarter past 6")
- Political orientation two-axis compass sliders (economic ±100, social ±100)
Diagnosis subsystem
Diagnoses are stored in pcp_profile_diagnoses with autocomplete backed by pcp_diagnosis_abbreviations (~41,500 rows):
| System | Rows | Notes |
|---|---|---|
| ICD-10-CM | 25,542 | CMS FY2026 valid-codes file, chapters A B C D E F G H I J K L M N O P Q R U + 553 friendly-alias rows (mdd, adhd, stroke, htn, etc.) |
| ICD-11 | 15,823 | WHO MMS Apr 2026 linearization, chapters 01–24 except billing (22) / external causes (23) / extension modifiers (X) / functioning assessment (V) + 361 friendly-alias rows |
| DSM-5 | 32 | Legacy hand-seed for codes without ICD equivalents |
| Other | 34 | somatic, unofficial, ICD-10 (WHO), instrument |
Skipped intentionally: ICD-10-CM S/T (injury body) ~41k codes, V/W/X/Y (external causes) ~7.5k codes; ICD-11 chapter 22 (injury), 23 (external causes), 25 (special purposes), V (functioning scales), X (17.7k extension modifiers). These are billing scaffolding, not diagnoses.
Autocomplete via action=pharmacopediadxsearch, multi-token AND search (e.g. "ADHD inattentive" matches the F90.0 row that contains both substrings); ORDER BY FIELD(da_system, 'ICD-10-CM', 'ICD-11', 'DSM-5', ...) so ICD-10-CM leads, then ICD-11, then everything else.
Self-report assessments
The six dimensional assessments all have continuous-slider items, a "Not sure" toggle per item, auto-computed subscale + total scores, and a rich auto-generated report at Special:MyAssessment/{key}:
| Assessment | Items | Score range | Cutoffs / threshold | Report |
|---|---|---|---|---|
| CATI | 42 (6 subscales) | 1–5 per item, sum per subscale | 148 / 139 / 141 / 156 (English 2025 gender-specific) | gender-specific scoring against English 2025 normative tables (12,253-row CatiNorms.php from OSF supplementary) |
| CAT-Q | 25 (3 subscales) | 1–7 per item, sum per subscale | Total ≥ 110 + per-subscale cutoffs (NeurodivUrgent recalibration) | subscale narratives, top-item analysis |
| PID-5-BF | 25 (5 domains) | 0–3 per item, mean per domain | mean ≥ 2.0 per domain | domain narratives, cross-system mapping (DSM-5 AMPD ↔ ICD-11 PD ↔ Big Five) |
| MBTI | 32 OEJTS items + 4 direct dichotomy sliders | ±2 per axis | none (dimensional treatment, no forced categorisation) | 4-axis Position column with letter + strength + bar, cognitive function stack, Big Five mapping, top-item analysis |
| Enneagram | 45 (5 per type × 9 types) | 0–100 per type | none (no clinical cutoffs for typology) | hero banner (primary + wing + tritype), 9-bar profile, primary deep-dive, wing analysis, centers, Hornevian + Harmonic groups, stress / growth lines, cross-system map (Big Five, MBTI) |
| OCEAN (Big Five) | 5 direct sliders + optional BFI-10 (10 items) | 0–100 per trait | none (personality, not pathology) | trait deep-dives (high / mid / low at your score), BFI-10 item table, cross-system mapping (MBTI ↔ Enneagram ↔ PID-5-BF) pulling live data from the profile |
Five further self-report instruments are available on the same collapsible-inline-test, auto-scored, report-bearing pattern:
| Instrument | Items | Structure | Measures |
|---|---|---|---|
| BPNS-PCP | 21 | 3 subscales (Autonomy, Competence, Relatedness), 7-point Likert | basic psychological need satisfaction, per Self-Determination Theory |
| NFCS-PCP | 15 | 5 facets (Order, Predictability, Decisiveness, Ambiguity intolerance, Closed-mindedness), 6-point Likert | need for cognitive closure (brief 15-item Roets & Van Hiel form) |
| OCI-PCP | 18 | 6 subscales (Washing, Obsessing, Hoarding, Ordering, Checking, Neutralizing), 0–4 per item; screening cutoff total ≥ 21 | obsessive-compulsive symptoms, adapted from the OCI-R |
| WHOQOL-BREF-PCP | 26 | 4 domains (Physical, Psychological, Social, Environment) + 2 overall items, 5-point Likert | quality of life |
| HYD-PCP | 8 | 8 single-item domains, each one bipolar slider (−100 really poorly to +100 really well); no subscales, no cutoffs | everyday wellbeing, re-taken and watched over time; a locally authored check-in, not validated |
All assessment items submit raw responses to pcp_profile_fields under namespace {key}_raw (e.g. cati_raw); the computed scores live under namespace {key} with keys like subscale_SOC, total, plus a taken_at timestamp. Re-scoring happens automatically on every save (autosave fires scoreResponses() on the full raw set, skipping "unsure" rows). Each completed assessment also auto-upserts a Life-story keyframe row (see Life-story timeline) so trajectories plot over time.
All thirteen assessments (the six dimensional, these five, and the two ADHD screeners below) are registered in AssessmentRegistry and so can also be administered to outside respondents (see Administer assessments to others).
ADHD screening
Two attention / ADHD instruments sit alongside the dimensional assessments, each a collapsible inline test on Special:MyProfile and a card render on the public profile:
- ASRS (Adult ADHD Self-Report Scale, Part A): a 6-item binary screener. The card counts how many of the 6 cardinal items fall in the screening range; 4 or more is a positive screen. The public profile renders it as a verdict card, a screen-positive or screen-negative result word with a 6-cell cardinal-item strip and a screening-result detail line.
- AMAAS-SR (a 30-item experimental attention self-report): three symptom subscales, inattention, hyperactivity and impulsivity, each scored as a percentage of the subscale maximum. The public profile renders it as a featured radar card, a 3-axis radar carrying a deliberately arbitrary 66.66% threshold triangle that is labelled experimental and not a validated cutoff. AMAAS has no validated norms; the card discloses this in plain sight rather than presenting a clinical cutoff.
Both instruments store responses in pcp_profile_fields under the asrs and amaas namespaces, the same pattern as the dimensional assessments.
Formal testing
The Formal testing block on Special:MyProfile is a log of standardized tests the user has taken (entrance exams, AP exams, IQ tests, and the like; retakes are welcome, and the year disambiguates them). Each entry resolves against a catalog (pcp_formal_tests) or is a custom free-text test, and records up to three score fields, raw score, percentile and pass/fail, each with an optional estimate flag.
Every score field has its own per-field visibility. Raw score, percentile and pass/fail each carry a separate privacy setting (uts_vis_raw, uts_vis_pct, uts_vis_passfail), so a user can publish a percentile while keeping the raw score private. The Special:MyProfile editor shows three privacy toggles per entry; the public Special:UserProfile gates each score line independently by its own field visibility. Scores are stored in pcp_user_test_scores, managed through the pharmacopediaformaltest API.
Life-story timeline
Special:MyLifeStory is the owner-facing editor + viewer for everything time-anchored about the user. Backed by pcp_life_events with extension columns for episode / observation / keyframe metadata.
Event types
| le_type | Meaning | Notes |
|---|---|---|
| 0 (TYPE_STORY) | Plain timeline entry | title, body, optional image |
| 1 (TYPE_IMAGE) | Image-primary entry | same shape, image is the main payload |
| 2 (TYPE_KEYFRAME) | Auto-created assessment snapshot | populated by upsertAssessmentKeyframe on every assessment save
|
| 3 (TYPE_OBSERVATION) | Plain-text observation | created via the quick-add textarea; parser extracts date, polarity, refs |
| 4 (TYPE_EPISODE) | Time-bounded period | start + end (PCPDatePicker range mode); type / subtype / severity 0-100 |
Quick-add observation
A textarea at the top of Special:MyLifeStory (and a 📝 modal trigger on Special:MyProfile) accepts plain text like:
anxiety from bupropion in jan 2020 did not experience anxiety from bupropion in feb 2018 panic attack 2 months ago depressed as a freshman i had a manic episode sep 1 2020 till nov 15 2020 no insomnia while on melatonin in summer 2023 felt great on christmas 2020 happiest on my 30th birthday
ObservationParser::parse() extracts:
- Date (point or range): ISO, MM/DD/YYYY, "Month D YYYY", "Month YYYY", "Season YYYY", "early/mid/late YYYY", bare year, decades ("2010s"); date RANGES via "X to Y", "X till Y", "from X to Y", "X - Y"; relative-to-now ("yesterday", "last week/month/year", "N months ago", "a few weeks ago"); holidays (christmas, halloween, new year's, valentine's, july 4th, thanksgiving with computed 4th-Thursday-of-Nov, MLK / Memorial / Labor day); age-relative ("7y8mo", "51.2yo", "at age 14", "ages 2-10"); life stages ("in childhood", "as a teen", "as a freshman", "junior year"); Nth birthday ("my 30th birthday")
- Polarity (negation detection): "not", "didn't", "did not experience", "never", "without", "denied" → polarity=0 (negative); else 1 (positive)
- Leading verbs stripped: "I was diagnosed with", "I took", "started taking", "tried", "was on", "experienced", "felt", so the subject is the noun, not the verb
- Adverbs stripped: "briefly", "occasionally", "frequently", "sometimes", "always", so the subject is the noun, not the modifier
- Role splitting: "from / caused by / due to / while on" → role='cause'; "with / during / while" → role='context'
- Ref resolution, in priority order:
- User's meds (
pcp_user_meds) - Wiki pages in Category:Medicines
- Effects catalog (
pcp_effects) - Problems catalog (
pcp_problem+pcp_problem_alias) - User's diagnoses (
pcp_profile_diagnoses) - Global ICD diagnosis abbreviations (
pcp_diagnosis_abbreviations) - Free-text fallback (stored as type='free' for later upgrade)
- User's meds (
- Auto-promote unrecognized subjects to custom-trait keyframes: when the subject doesn't match an existing entity but the input has BOTH a numeric value AND a date (e.g.
my neuroticism was 5 at 10yo), the subject is treated as a NEW custom trait (namespace='custom', key derived from subject text). Creates a TYPE_KEYFRAME event with the value + a trajectory point. Future inputs with the same subject auto-append to that series. - Flexible range separators:
X to Y,X through Y,X thru Y,X until Y,X till Y, em-dash, en-dash, flexible-whitespace hyphens (digit-aware so2026-05-31ISO dates aren't split),../...ellipsis. - Age-range phrases without literal "ages":
when I was 11-13,from 11 to 13,between ages 5 and 12,aged 10 to 14. Negative lookahead prevents false matches on dosages (10-20 mg), durations (5-10 years ago), measurements (cm/mm/ft/lb/etc). - Episode-shape detection: "manic episode" → type=mood, subtype=manic; "psychotic break", "panic attack", "anxiety attack", "trauma response" all route to
addEpisodeinstead ofaddObservation. A date RANGE alone is enough to forceis_episode=true.
Live preview chips appear under the textarea as you type. Submit routes to pharmacopediaobservation API which writes the row + refs via setEventRefs().
Episode form
Click the "🌀 Episode" button (or the quick-add detects an episode shape) for the structured form:
- Type selector: mood / psychotic / anxiety / panic / trauma response / dissociative / substance use / eating / sleep disturbance / pain flare / migraine / medication adjustment / hospitalization / creative surge / spiritual / transcendent / relationship crisis / grief / somatic / other
- Subtype (text + datalist), for mood: depressive / manic / hypomanic / mixed / dysphoric / euthymic
- Severity slider 0-100 (per precision doctrine)
- Date range via PCPDatePicker locked to range mode
- Title, body, single virus-scanned image, visibility (4-state)
Visual timeline
Top of Special:MyLifeStory has a tabbed view: Visual timeline (default) / Card list.
The visual timeline uses vis-timeline 7.7.3 (vendored Apache-2.0 at resources/vendor/vis-timeline/) with these features:
- Swimlanes (toggleable chips): Episodes (range bars), Events, Observations, Keyframes (off by default), Derived
- Trait-trajectory overlay: a synchronized
vis.Graph2ddraws smooth (centripetal Catmull-Rom interpolated) lines for every keyframe trait series (CATI subscales, PID-5-BF domains, CAT-Q, custom traits like "shyness") DIRECTLY on top of the timeline plot area. Same X-axis; the graph's data + time axes are CSS-hidden so only the lines + points show. Values are normalized to 0-100% within each series so different scales coexist. - Toolbar: Visual/Card tabs, group toggle chips, magnifier + zoom +/- buttons, Fit visible / Fit everything
- Plain wheel = vertical scroll inside the timeline (520px fixed height); Ctrl+wheel = zoom; Shift+wheel = horizontal pan
- Click empty timeline area: prefills the quick-add textarea with "on YYYY-MM-DD" at that date + scrolls + triggers live preview
- Click an item: routes to its edit form (event / episode / observation, each has its own route)
- Collapsible "Trait series (N)" legend with chip toggles to show/hide individual series
Edit / delete / duplicate flows
Each event type has its own edit route under Special:MyLifeStory:
Special:MyLifeStory/edit-observation/<id>, re-parses raw text on save; supports polarity override + date overrideSpecial:MyLifeStory/edit-episode/<id>, full episode formSpecial:MyLifeStory?edit_event=<id>, existing event / image / keyframe form
All three forms have side-by-side "Delete X" + "Duplicate X" buttons. Duplicate copies fields + refs + keyframe traits (NOT images) and redirects to the new row's edit form.
Upgrade-link UI
When the parser stored a free-text ref (because the entity wasn't in the user's data at the time), Special:MyRefLinks finds all such refs and offers one-click "link to {match}" buttons. Matches come from the same catalogs the parser checks at write time. A banner on Special:MyLifeStory ("📎 N free-text references could be linked → Review & link") appears when unmatched refs exist.
Per-record sharing subsystem
A granular per-record sharing system layered on top of the legacy pf_visibility enum without removing it. Resolved by VisibilityResolver; the new model is additive (legacy fallback preserved unless Privacy mode is on).
Rule types (vr_rule_type)
private, explicit deny (only owner)public, explicit allow (anyone)users, payload{user_ids: [...]}; allow if viewer in listcohort, payload{cohort_id: N}; allow if viewer inpcp_cohort_membersfor that cohortlink_token, payload{token: '...', uses_remaining: null|N}; allow if URL?pcpshare=TOKENmatchesreciprocal, allow if viewer has a matchingusersrule sharing the same shape back to owner
Rules scope at three levels: (profile, namespace, key), (profile, namespace, NULL), or (profile, '*', NULL). Most-specific-first matching. Rules can have vr_expires (time-bounded) and vr_revoked (preserves audit trail).
Privacy mode
When Privacy mode is ON for a profile, a *-wide private rule is created. VisibilityResolver::canView() short-circuits to false instead of falling through to the legacy pf_visibility check, so only explicit share rules grant access. Default: OFF (back-compat).
Share dialog
Triggered by 🔗 chips on assessment reports, profile sections, life-story timeline. Modal with three tabs:
- People: username autocomplete (↓/↑/Enter/Esc keyboard nav), per-shared-user pill with × to remove just that user, optional expires DatePicker, "Auto-share back" reciprocal toggle
- Link: "Generate link" creates a
link_tokenrule; copies the URL to clipboard with toast; optional max-uses + expires - Cohorts: dropdown of the owner's cohorts (managed at
Special:MyCohorts); optional expires
Audit log
Every permitted view through a rule (not legacy) writes a row to pcp_visibility_view_log. Special:MyShareLog shows the last 200 views with timestamp, viewer (or anonymous IP masked to /24), namespace, key, rule id.
Observer perspectives
A structured way to collect second-party (observer) input on something a user owns, without the observer needing an account. The v1 use case is an observer-rated attention report: a registered user invites someone who knows them well to rate them. A two-gate consent model governs the whole flow.
- Gate 1, contribution. An owner-issued, token-bearing invite link is the only way to submit a perspective. The invitee URL carries only an opaque random token (
VisibilityResolver::generateLinkToken()); the owner's identity is never in the URL. The invitee sees only the owner's chosen display name. - Gate 2, publication. Every submission is born
psp_consent = 0, private to the owner. Only the owner, re-checked for ownership, can flip it to published. A perspective is never publicly visible while unconsented.
Flow: the owner opens Special:MyPerspectives, picks a display name, mints an invite link, and sends it. The invitee opens Special:Perspective/<token>, reads a generic intro and a non-anonymity notice, optionally states their relationship to the owner, fills the type-specific slider form (with per-item "not sure" markers), clears a Turnstile challenge, and submits. The perspective lands in the owner's consent inbox, where the owner can publish it, return it to private, or delete it.
Special:Perspective is unlisted and unauthenticated; its POST path is defended by pingLimiter, a per-token APCu velocity cap, and fail-closed Turnstile. pcp_perspective_invite holds the token-bearing invitations (pvi_max_uses NULL means an unlimited reusable link); pcp_perspective holds the submissions, each with a psp_validity response-quality flag and the psp_consent gate. The client module ext.pharmacopedia.perspective adds the slider readout and progress bar on the invitee form and the confirm steps on the owner inbox.
Administer assessments to others
A registered user (the owner) can send any of the thirteen registered assessment scales to people outside the wiki (the respondents), collect their results, and follow them over time. Respondents need no account; a one-time invite link is their only credential. The subsystem is built so that, by default, a respondent's results are readable only by the owner, and the owner can additionally choose a zero-knowledge mode in which not even a site administrator can read them.
Assessment registry
AssessmentRegistry (includes/Assessments/AssessmentRegistry.php) is the single source of truth: an assessment registered there once autopopulates the owner's scale-picker, the respondent take-flow, and the dashboards. Each entry names a scorer class and a response model:
radio, discrete labelled radio buttons (CATI, CAT-Q, PID-5-BF, NFCS, BPNS, OCI-PCP, WHOQOL-BREF, ASRS)slider, one continuous slider per item with uniform end anchors (OCEAN, Enneagram, AMAAS, HYD-PCP)bipolar, one slider per item with the item's own two opposing phrases as the anchors (MBTI)
Every take-flow item also carries a "Not sure" control that disables the item.
The four surfaces
- Entry point, a control that opens the feature.
- Owner hub (
Special:AdministerAssessments): the owner manages respondents (a named contact list), composes a send (a respondent plus one or more scales), generates a one-time link, and reviews each respondent's results over time. The owner can delete an invite. - Respondent take-flow (
Special:RespondToAssessment/<token>), reached only by the invite link: a consent screen, then the model-aware item forms; once every scale is done the same URL becomes a revisitable results dashboard. Either party can delete. - Key setup, where the owner chooses how their results are protected (see below).
Cryptography
Each owner has an X25519 keypair. The public key is stored in the clear; the secret key is wrapped at rest. A respondent submits while the owner is absent, so the result is sealed to the owner's public key with crypto_box_seal, which needs only the public key; only the owner's secret key can open it. Two protection modes:
- Managed (default, seamless): the secret key is wrapped with a server master key held in a file outside the web root, never in the database. No passphrase to remember, and a forgotten site password never costs the owner their data. A site administrator could technically read results.
- Passphrase (opt-in, zero-knowledge): the secret key is wrapped with an Argon2id-derived key from a passphrase only the owner knows. Not even the site can read the results. The passphrase cannot be recovered or reset; losing it makes every collected result permanently unreadable.
AES-256-GCM is used for key wrapping in both modes. AdminCrypto (includes/Assessments/AdminCrypto.php) is the helper: setupOwnerKey, verifyPassphrase, unlockSecretKey, encryptForOwner / decryptForOwner, encryptForRespondent / decryptForRespondent, mintInviteToken / hashInviteToken. Invite tokens are 256-bit; only their SHA-256 hash is stored, so a stolen database yields no usable tokens. The respondent's own copy of each result is sealed to a key derived from the raw invite token, so the link doubles as the respondent's read credential. A scheme-version column on the key and result rows lets the cipher or KDF be revised over time; the Mode A passphrase KDF currently runs at scheme v2 (Argon2id at MODERATE limits), and an owner created under an earlier scheme is transparently re-wrapped on their next unlock.
De-identified research pool
Each submission also writes one row to pcp_administer_research, decoupled from the result write, with no foreign key to the invite, respondent, or owner. res_id is a random 128-bit value (not sequential) and the only time field is res_month ('YYYY-MM', no day), so a research row cannot be time-correlated or rank-correlated back to the owner-side tables. The payload is the plaintext item responses plus the computed score. Deleting an invite removes the owner-sealed and respondent-sealed result copies but intentionally leaves the de-identified research row, as the consent screen states.
Tables
pcp_administer_respondents, an owner's named contacts (the label is sealed to the owner's public key, so the roster is readable only by the owner)pcp_administer_invites, one token-bearing link per send (stores only the SHA-256 of the token)pcp_administer_assessments, the scale(s) inside an invite, each with the owner-sealed (aa_payload_enc) and respondent-sealed (aa_respondent_enc) result copiespcp_administer_userkey, per-owner key material (public key, wrapped secret key, Mode-A salt and verifier)pcp_administer_research, the de-identified research pool
The 13-instrument submission handler reuses the existing assessment scorers in includes/Assessments/ rather than reimplementing scoring.
Choice / multi voting
The <vote> tag's binary up/down mode is unchanged. With type="single" or type="multi" + options="A; B; C" (2-5 entries, semicolon separator), it renders a compact chart-icon chip that expands inline to a radio (single) or checkbox (multi) picker with per-option count bars.
Server-side storage:
pcp_votable_elements.ve_options(JSON array of labels)pcp_votable_elements.ve_options_h(first 8 hex of sha256; drift hash)pcp_votable_elements.ve_results_policy(live / after-vote / hidden)pcp_votes.v_choices(CSV indices)pcp_votes.v_options_h(drift hash at vote time)
API route: same pharmacopediavote endpoint; presence of choices or options_h params routes to castChoice(). Response includes tally + user_choices (null for binary). Tally hidden per results policy.
Drift behavior: if the page editor changes the options list after votes exist, ve_options_h updates and new votes' v_options_h reflects the new value. Existing votes stay but their hash no longer matches (marked stale). New votes whose submitted hash mismatches the live one are rejected, protects against browser cache races. Tallies still aggregate by raw index, so RENAMING an option in place silently turns old votes into new-label votes; reordering is the dangerous case. Appending new options is safe.
Research ID
Every user profile carries a research_id: a 10-character hex string (bin2hex(random_bytes(5)) = 40 bits), generated once at profile create, stored UNIQUE in pcp_user_profiles.prof_research_id, and never reassigned.
Purpose: provides a stable opaque identifier for de-identified research participation. It does not reveal the user's wiki username, user_id, or HMAC voter_hash; it survives username changes; and it stays constant across the user's lifetime on the wiki. Users can find theirs in the Public identity fieldset on Special:MyProfile (single-click to select-and-copy).
Backfilled retroactively for all pre-existing profiles on 2026-05-18 (v0.9.4).
ClamAV scan rule (project standard)
Hard rule, set 2026-05-17: every server-accepted file upload (image, PDF, document, anything) MUST go through VirusScanner::scanFile($path) (includes/VirusScanner.php) BEFORE being moved to permanent storage. Fail-closed: if /usr/bin/clamdscan is unavailable or returns error, the upload is rejected.
Wired into:
LifeStoryStore::addImage(), life events / episodes / observationsLiteratureStore::storeUploadedPdf(), literature PDFsProviderAppStore::saveUploadedFile(), provider verification documents
AttachmentScanner (used by feature-request attachments) is left alone, its status-return model is intentional for the queued-moderation flow there. AntivirusHelper (the old silent-no-op variant) was deleted; all callers consolidated onto VirusScanner.
Autosave infrastructure
The save-status indicator is a single colored dot (amber = saving, green = saved, red = error) reparented to document.body 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 Special:MyProfile is wrapped in <div data-pcp-save-block="block-name">. The blocksave.js library:
- Listens for
inputandchangeevents on every input inside any save-block - 800 ms after the last event, POSTs the block's serialized form data to
Special:SaveProfileBlockwithblock=block-name - Shows a transient chip (top-right and bottom of the block): pending… → saving… → ✓ saved (fades after 1.2 s) or ✗ error (sticks, clickable to retry)
- Race-safe: if user keeps typing during an in-flight save, the in-flight save records what it sent; newer changes mark the block dirty again and schedule another save when the response returns
- Diagnosis + medicines "Add a row" slots are exempted from autosave (would create duplicates); they require an explicit + Add button
- Programmatic widgets (chip-pickers, units, smoking, alcohol, chronotype) fire
changeevents on their hidden fields so the listener notices
Slider numbers are also clickable: a single delegated handler on every <output> next to a range slider swaps it for a number input on click, accepts a precise typed value (clamps to the slider's min/max), commits with Enter, cancels with Escape.
Scroll position is preserved across the rare reloads (delete operations on diagnoses / medicines / experience reports, and the auto-reload after a new diagnosis or medicine is added) via sessionStorage (the ext.pharmacopedia.bounceback module).
Special pages
| Page | Purpose |
|---|---|
Special:MyProfile |
Edit your full profile (identity, demographics, personality, dx, meds). Autosave throughout. Privacy mode toggle + share chips per fieldset. |
Special:UserProfile/<name> |
Public profile view (filtered by per-field visibility + rule-based access). 🔗 Share chip in subtitle for self-view. |
Special:MyAssessment |
Index of rich assessment reports |
Special:MyAssessment/cati, /catq, /pid5bf, /mbti, /enneagram, /ocean |
Rich report per assessment; 🔗 Share chip in subtitle for owner |
Special:TakeAssessment/<key> |
Generic paginated self-assessment runner; stores raw items and computes scores |
Special:MyLifeStory |
Owner editor + visual timeline + card list. Quick-add observation textarea + Event/Episode buttons. 🔗 Share chip in subtitle. Privacy-mode + free-text-refs banners. |
Special:MyLifeStory/add-episode, /edit-episode/<id> |
Episode form (create / edit) |
Special:MyLifeStory/edit-observation/<id> |
Observation edit form (re-parses raw text) |
Special:LifeStory/<name> |
Public life-story view (read-only, visibility-filtered) |
Special:LifeImage |
Visibility-gated image streamer for life-story event images |
Special:MyCohorts |
Owner-managed groups for share-with-cohort flows; create / rename / delete / add+remove members |
Special:MyShareLog |
Who has viewed your shared content (timestamp, viewer, namespace, rule id; anonymous IPs masked) |
Special:MyRefLinks |
Bulk linker: find free-text refs in observations that now match a structured entity; one-click upgrade or dismiss |
Special:MyPerspectives |
Owner: mint observer-perspective invite links and review the consent inbox |
Special:Perspective/<token> |
Public, token-gated observer-perspective form (no account; unlisted, anti-abuse defended) |
Special:AdministerAssessments |
Owner hub: send assessment scales to outside respondents and follow their results |
Special:RespondToAssessment/<token> |
Token-gated respondent take-flow and revisitable results dashboard |
Special:SaveProfileBlock |
AJAX endpoint for autosave (POST-only, JSON response) |
Special:Problems |
Browse the problems repository (165+ entries, 18 categories) |
Special:Problem/<slug> |
Individual problem page |
Special:SuggestProblem |
User-facing form to suggest a new problem (page-tied or standalone) |
Special:SuggestAnecdote |
Logged-in users propose an anecdote for a medicine page |
Special:SuggestEffect |
Logged-in users propose an effect for a medicine page |
Special:SuggestTitration |
Logged-in users propose a titration schedule for a medicine page |
Special:ManageProblems |
Sysop tool for problem-repository moderation |
Special:ManageEffects |
Sysop CRUD for the global effects vocabulary |
Special:ManageInteractions |
Sysop bulk-edit interaction reports |
Special:ReviewExperience |
Sysop queue for pending experience reports |
Special:DeletePharmaElement |
Sysop delete tool for any votable element |
Special:VerifyProvider |
User-facing form to apply for provider verification and check status |
Special:ProviderApplications |
Sysop queue to approve / reject provider-verification applications |
Special:VerificationDoc |
Permission-gated streamer for provider-verification document files |
Special:FeatureRequests |
User-facing feature-request board (submit and browse) |
Special:RequestReview |
Sysop feature-request review console (status counters, prioritized queue, triage) |
Special:LiteratureDoc |
Download proxy for approved literature PDFs (reviewers may preview pending) |
Special:LiteratureQueue |
Sysop literature review queue (approve / reject / delete) |
Special:PharmacopediaActivity |
Recent-activity feed: last 30 votes, effect reports, comments, literature submissions |
Special:NewUsers |
The 20 most recently registered accounts |
Special:ProfileAnalysis |
Sysop dashboard of cross-table profile aggregates; per-section CSV export |
Special:ProfileFilter |
Sysop cross-filter UI over user profiles (demographics, OCEAN ranges, dx, med); CSV export |
Special:PCPCtrls |
Sysop controls hub (gated by $wgSpecialPageLockdown)
|
Special:AdminCtrls |
Admin-controls landing page (renders sysop-editable MediaWiki:Adminctrls-body)
|
Special:DatePickerTest |
Developer sandbox exercising the date-input widget (point / range / possibility) |
API modules
| Action | Purpose |
|---|---|
pharmacopediavote |
Binary OR choice/multi vote (routes by presence of choices param). Returns tally + user_choices for choice modes; gated by results-policy.
|
pharmacopedialikert |
Submit problem-efficacy likert (0–100 + −1 DK) |
pharmacopediaeffect |
Submit effect report (patient or provider perspective) |
pharmacopediainteractionreport |
Submit interaction report |
pharmacopediainteractionadd |
Create a new interaction edge |
pharmacopediacomment |
Threaded discussion ops (add / edit / delete / reply) |
pharmacopediaexperiencesubmit |
Submit experience report (multi-field form) |
pharmacopediaexperiencereview |
Sysop approve / reject experience report |
pharmacopediadxsearch |
Diagnosis autocomplete against the 41k-row abbreviation table |
pharmacopediaproblemsearch |
Problem-repository autocomplete |
pharmacopediaeffectslookup |
Picker used by the experience-submit form |
pharmacopedialiteratureadd, literaturedelete |
Literature attachment ops |
pharmacopediaobservation |
op=preview / op=submit for plain-text observations (routes to addObservation OR addEpisode) |
pharmacopediavisrules |
Visibility rule CRUD (list / create / update / revoke / newtoken) |
pharmacopediausersearch |
Username autocomplete for share-with-people picker |
pharmacopediacohorts |
Cohort CRUD + membership |
pharmacopediarefupgrade |
Free-text ref linker (op=candidates / apply / dismiss) |
pharmacopediaformaltest |
Formal-testing score operations (list / add / update / delete), with per-field visibility |
Interactions feature
The Interactions section is rendered by placing <pharmaInteractions/> anywhere in the wikitext of a med article (NS_MAIN) or a Category page (NS_CATEGORY).
Entity model
An interaction is an undirected edge between two endpoints. Each endpoint has a type (med or category) and a slug (DB-key form of the page title). Pairs are stored in canonical order: smaller (type, slug) tuple on the left.
Rendering rules
- On a med page M, list:
- Direct edges: rows where M is one side.
- Transitive edges: rows where one side is a category C that M is itself a member of (via MW's
categorylinks).
- Direct wins: if the same counterparty is reachable both directly and transitively, drop the transitive duplicate.
- On a Category page, list direct edges only (no transitive walk).
- Sort: pooled
valence_meanascending (most negative on top). Nulls sink. Tiebreakers:ndesc, then alphabetic. - Severe (any of pooled / user / provider vmean ≤ −83.0): red 4 px left border + red-tinted background + "severe" pill + counterparty title in red.
Add-interaction modal
Triggered by the + Add interaction button at the bottom of the section. Two-stage UX: search → click Use → confirm with Add interaction → POST to pharmacopediainteractionadd. Categories appear in the modal only if tagged with the marker category (default Category:MedCategory, configurable via $wgPharmacopediaInteractionCategoryMarker).
Experience reports
User-submitted reports of personal or clinical experience with a medicine, via <pharmaExperience/> on a med page. Stored pending in pcp_experience_reports; visible publicly only after sysop approval through Special:ReviewExperience.
Captured fields:
- Perspective (personal / clinical)
- Currently taking it (yes / no, stopped)
- Duration (value + unit)
- Dose (mg, decimal-precise)
- Route (16-option dropdown: PO, IV, IM, SC, SL, buccal, inhaled, intranasal, topical, transdermal, PR, ophthalmic, otic, vaginal, insufflated, other)
- Schedule (free text with datalist of QD / BID / TID / QID / q4h / q6h / q8h / q12h / qHS / qAM / qPM / PRN)
- Patient count (clinical only): min + optional max for ranges
- Efficacy (0–100 slider)
- Side-effect burden (0–100 slider)
- Stop reasons (personal + stopped only): JSON multi-select with optional severity slider per reason, codes: side_effects / ineffective / cost / no_longer_needed / clinician_advised / other
- Free-text anecdote
- Problems addressed (multi-pick with per-problem efficacy)
- Effects experienced (multi-pick with per-effect valence + frequency)
Storage tables (selected)
| Table | Purpose |
|---|---|
pcp_votable_elements |
Stable per-(page, slug) handle reused by votes / likert / comments. Also ve_options + ve_options_h + ve_results_policy for choice votes.
|
pcp_votes |
Binary +1/−1 votes (v_value) AND choice/multi votes (v_choices CSV + v_options_h drift hash) |
pcp_likert_reports |
Problem efficacy (0–100 + −1 DK), TINYINT signed |
pcp_effect_reports |
Effect ratings: experienced / frequency / valence (±100); perspective 1/2 |
pcp_interaction_reports |
Interaction ratings: experience 1–5 / valence ±100 / note; perspective 1/2 |
pcp_interactions |
Interaction edges (canonical-ordered pair) |
pcp_comments |
Threaded <discuss>-tag discussions (soft-delete, optional display name)
|
pcp_user_profiles |
Per-user profile meta (alias, attribution, voter hash, prof_research_id) |
pcp_profile_fields |
Generic key-value field store: (namespace, key, num, text, visibility) |
pcp_profile_diagnoses |
Per-user diagnoses (system, code, description, status, origin, severity 0–100, disability 0–100, dates, notes, visibility) |
pcp_user_meds |
Per-user medicines (name, page_id, efficacy 0–100, burden 0–100, dose_mg, route, schedule, duration, periods JSON, current, notes, visibility) |
pcp_diagnosis_abbreviations |
ICD-10-CM + ICD-11 + DSM-5 + aliases (~41,500 rows, VARCHAR/utf8mb4 for native case-insensitive search) |
pcp_problem, pcp_problem_alias |
Problems repository + alias lookup |
pcp_experience_reports |
Experience reports (pending → approved); efficacy + burden 0–100; route + schedule; stop-reasons JSON; patient-count min + max |
pcp_life_events |
Timeline events. Columns: le_type (0=story, 1=image, 2=keyframe, 3=observation, 4=episode), le_polarity, le_raw_text (parser input), le_episode_type, le_episode_subtype, le_severity, le_date_struct (PCPDatePicker JSON; supports range) |
pcp_life_event_refs |
Join table linking events to entities (med / effect / problem / diagnosis / med_page / diagnosis_code / free); role (subject / cause / context / symptom / trigger / treatment) |
pcp_life_traits |
Keyframe trait values (assessment subscale snapshots → trajectory graph) |
pcp_life_images |
Image attachments (ClamAV-scanned) |
pcp_visibility_rules |
Per-(profile, namespace, key) sharing rules. Types: public / private / users / cohort / link_token / reciprocal. Optional vr_expires, vr_revoked, vr_attribution, vr_label. |
pcp_cohorts, pcp_cohort_members |
Owner-managed user groups for share-with-cohort flows |
pcp_visibility_view_log |
Audit trail of rule-permitted views (Special:MyShareLog) |
pcp_perspective_invite |
Token-bearing observer-perspective invitations (display name, object, type, max-uses) |
pcp_perspective |
Submitted observer perspectives (payload JSON, validity flag, consent gate) |
pcp_administer_respondents |
An owner's named contacts; the label is sealed to the owner's public key |
pcp_administer_invites |
One token-bearing administer link per send; stores only SHA-256 of the token |
pcp_administer_assessments |
The scale(s) in an invite; owner-sealed and respondent-sealed result copies |
pcp_administer_userkey |
Per-owner key material (X25519 public key, wrapped secret key, Mode-A salt + verifier) |
pcp_administer_research |
De-identified research pool: random id, coarsened month, no link back to the owner |
pcp_provider_apps |
Provider-verification applications (profession, specialty, jurisdiction, license, status, doc paths) |
pcp_literature |
Per-page literature submissions (citation metadata, optional PDF, status, reviewer) |
pcp_feature_request, pcp_feature_request_attachment, pcp_feature_request_comment |
Feature-request board: requests, ClamAV-scanned attachments, threaded comments |
pcp_formal_tests |
Catalog of standardized tests (abbrev, full name, category, score format) |
pcp_user_test_scores |
Per-user formal-test scores; raw score / percentile / pass-fail, each with its own visibility (uts_vis_raw / uts_vis_pct / uts_vis_passfail) and an estimate flag |
Notable lessons learned
- Synthetic Event needs bubbles:true to trigger delegated listeners.
new Event('input')defaults tobubbles:false, so listeners on parent wrappers never see programmatic dispatches. Native input/change events bubble by default; only JS-fired ones don't. Pass{ bubbles: true }explicitly. Bit DatePicker calendar-cell clicks 2026-05-18, typing in the text field autosaved fine, but picking a date from the calendar didn't, because blocksave's wrapper listener never received the event. - MediaWiki form-field names collide with reserved URL params. A form
<input name="title">orname="action">with user-controlled value silently HIJACKS MW's dispatch when POSTed (body param overrides URL param). Symptom: form submits but lands on a wiki article named whatever the user typed, with URL bar still showing the special page. Bit the episode form 2026-05-18 (user typed 'Fake one' as title → got a 404 'create article: Fake one' page). Fix: prefix ALL custom form inputs withpcp_(both the inputname="..."AND the matching$request->getVal('...', ...)read). Self-referential hidden inputs (name="title" value="<getPageTitle()>") are safe. - Cargo string fields cap at ~300 chars.
structureandmechanismon MedTemplate are short VARCHARs; long prose goes in MEDIUMBLOB sections. Overruns silently lose Cargo data (MySQL Error 1406). - VARBINARY + LOWER() is a no-op. MariaDB's LOWER() returns binary types unchanged.
pcp_diagnosis_abbreviationswas migrated VARBINARY → VARCHAR/utf8mb4 so case-insensitive LIKE works natively without CONVERT() wrappers. - FlaggedRevs locks template inclusions by default. Config fix:
$wgFlaggedRevsHandleIncludes=0+ remove NS_TEMPLATE from$wgFlaggedRevsNamespacesvia an extension-function callback. - CSP must allow Cloudflare Turnstile and any other 3rd-party widget script source. Audit script-src / frame-src / connect-src / style-src whenever adding any 3rd-party JS widget.
- Sidebar cache must be purged after CLI
maintenance/edit.phpwrites toMediaWiki:Sidebaror other chrome pages. - CLI E_USER_DEPRECATED suppressed in LocalSettings.php (EmbedVideo / FlaggedRevs spam on MW 1.46). Web behavior unaffected.
- MW ApiResult drops keys starting with
_. Any field whose key starts with underscore in an ApiResult payload is treated as internal metadata and stripped. Rename to plain identifier. Bit Phase 2-4 of visibility-rules subsystem. - MW API serializes int-keyed assoc arrays unreliably.
[23 => 'Alice']may arrive as{"23": "Alice"}or worse. Always use list-of-objects ([{"id": 23, "name": "Alice"}]) for id-to-value maps across the API boundary. - PHP single-quoted strings don't interpret
\xNNbyte escapes.'\xF0\x9F\x94\x97'is 16 literal chars, NOT the 4-byte UTF-8 for 🔗. Use literal Unicode char or double-quoted\u{1F517}. Bit three times in one session. - PHP "0" is FALSY.
!"0"evaluates to TRUE. Soif ( !$x )silently treats"0"(string zero) as missing. Bit choice-vote tallying when voter picked option index 0; vote was IN the DB but invisible to readers. Use=== null || ===for "missing or blank" intent.empty()has the same problem. <span>can't contain<p>. MediaWiki auto-wraps tag content in<p>when there are newlines; browsers auto-close any<span>before the<p>, scattering child elements into wrong DOM positions. Use<div>for parser-tag wrappers whose content can span paragraphs (choice votes, life-story cards).- vis.Graph2d group
stylebelongs at TOP LEVEL not nested inoptions. Nesting silently fails (no error, just invisible lines). Bit the trait-trajectory graph on first build. - A symmetric data-key cannot serve an asynchronous flow. The administer-to-others result must be encryptable while the owner is absent. A purely symmetric per-owner key has no holder at submission time; the model must be an asymmetric keypair, with the public key stored openly so a sealed box can always be written and only the owner's wrapped secret key can open it.
- A de-identified row must carry no order and no link. An auto-increment id rank-correlates with the source table and an insert timestamp time-correlates with it. The research pool uses a random 128-bit id and a month-only date so a row genuinely cannot be traced back.
Hooks
ParserFirstCallInit: register all parser tagsLoadExtensionSchemaUpdates: install / migrate schema (the sql/ directory + patches)BeforePageDisplay: inject the ext.pharmacopedia.* ResourceLoader modules; resolve and apply the page skin (thepcp-skin-*body class) and, on the Main Page / Category index, thepcp-diptych-pagechromeless classUserGetRights+UserEffectiveGroups: verified-provider role wiring- Various special-page registrations via
SpecialPage_initList
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 $wgExtensionFunctions so it runs after the extension's defaults merge, the same timing fix as the original NS_TEMPLATE block).
| 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 pcp_problem row; p_page_id column links the canonical DB row to its page id; <problemMedicines slug="X"/> auto-emits the medicines list inside each page
|
| 3010 / 3011 | Effect: / Effect talk: | Per-Effect wiki pages; one per pcp_effects row; e_page_id column links the canonical DB row to its page id; <effectMedicines slug="X"/> 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 maintenance/migrateProblemEffectStubs.php. Each stub carries a one-line "Stub" header, the canonical description if any, the auto-generated medicines section, and the sentinel Category:Problem stubs / Category:Effect stubs for the buildout queue. The migration script is CLI-only, idempotent on re-run, and credits MDElliottMD via EDIT_INTERNAL (skips AbuseFilter + captcha + rate limits per MW convention for ops migrations).
Inbound linkage: every <problem ref="X"> on a medicine page links its problem-card title to Problem:<Name>; every <effect ref="X"> links its label to Effect:<Name>; the sidebar Common-uses list links the same way. Special:Problem/<slug> auto-redirects to the matching NS page when p_page_id 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:
About:Pharmacopedia.ext: this spec, kept lockstep with extension version (interface-claude updates body + version line on every close-out).About:Privacy: 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.Category:Pharmaceutical+Category:Plants: 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 (
Category:Euphorica,Category:Evaesthetica, etc.): per-class wiki articles with an opening English-gloss clause sourced from Pendell's trilogy. - The seven USLegal status pages (
USLegal:Prescription only,USLegal:Over-the-counter,USLegal:DEA Schedule I...V) plus 26 terse redirects; medicine pages link these via the MedTemplatelegal=field. MediaWiki:Sidebar: standard MW sidebar with the local additions (My profile, My assessments, etc.).
Configuration globals
$wgPharmacopediaInteractionCategoryMarker(defaultCategory:MedCategory): only categories tagged with this marker appear in the add-interaction modal$wgPharmacopediaVoteHashSecret(required): HMAC secret forv_voter_hashso vote rows can't be mapped back to user accounts by anyone reading the DB without the secret$wgPharmacopediaAdminKeyDir: filesystem directory, outside the web root, holding the administer-to-others server master key (managed mode); never in the database, never in the DB backup set- (Various permission-grant arrays via standard MW
$wgGroupPermissions)
Source layout
extensions/Pharmacopedia/
|-- extension.json
|-- includes/
| |-- Hooks.php
| |-- *Tag.php (parser tags: VoteTag, EffectTag, ProblemTag,
| | ClassGridTag, ClassTreeTag, LiteratureTag,
| | FrontPageTag, CategoryIndexTag, ...)
| |-- DiptychChrome.php (shared topbar + footer for the diptych splashes)
| |-- *Store.php (data access: EffectStore, ProblemStore, ElementStore, LifeStoryStore, ...)
| |-- UserProfileStore.php (the big one; profile / dx / meds / abbreviations)
| |-- VisibilityResolver.php (per-record sharing decision engine)
| |-- VirusScanner.php (ClamAV gate; fail-closed)
| |-- ObservationParser.php (plain-text -> structured observation)
| |-- SpecialMyProfile.php (the user profile editor; large)
| |-- SpecialMyAssessment.php (rich reports for the dimensional assessments; large)
| |-- SpecialMyLifeStory.php (life-story editor + visual timeline; large)
| |-- SpecialMyPerspectives.php / SpecialPerspective.php (observer perspectives)
| |-- SpecialAdministerAssessments.php / SpecialRespondToAssessment.php (administer to others)
| |-- SpecialPCPCtrls.php (sysop controls hub)
| |-- Special*.php (other special pages)
| |-- ProfileDatasets.php (countries, languages, genders, religions, etc.)
| |-- DatePicker.php (range / possibility-mix date widget backend + injectBirthdayContextOnce)
| |-- Assessments/
| | |-- AssessmentRegistry.php (single source of truth for the 13 instruments)
| | |-- AdminCrypto.php (X25519 / Argon2id / AES-256-GCM helper)
| | |-- Cati.php, CatiNorms.php
| | |-- Catq.php, CatqNorms.php
| | |-- Pid5bf.php, Pid5bfNorms.php
| | |-- Mbti.php
| | |-- Enneagram.php
| | |-- Ocean.php, OceanNorms.php
| | |-- Asrs.php, Amaas.php
| | |-- Bpns.php, BpnsNorms.php
| | |-- Nfcs.php, NfcsNorms.php
| | |-- Ocipcp.php
| | |-- WhoqolBref.php, WhoqolBrefNorms.php
| | `-- Raadsr.php (deprecated 2026-05-17 in favor of CATI, kept for archival reads)
| `-- Api/
| |-- VoteApi.php, EffectApi.php, ...
| |-- ObservationApi.php
| |-- VisibilityRulesApi.php, UserSearchApi.php, CohortsApi.php
| `-- RefUpgradeApi.php
|-- resources/
| |-- ext.pharmacopedia.js (single IIFE: chip-picker, dx autocomplete, vote logic, ...)
| |-- ext.pharmacopedia.styles.css (base stylesheet, self-hosted fonts)
| |-- ext.pharmacopedia.blocksave.js (debounced autosave per block)
| |-- ext.pharmacopedia.bounceback.js (scroll-position preservation)
| |-- ext.pharmacopedia.confirmdelete.* (styled destructive-action prompt)
| |-- ext.pharmacopedia.datepicker.js + .datepicker.styles.css
| |-- ext.pharmacopedia.timepicker.js + .timepicker.styles.css
| |-- ext.pharmacopedia.share.js + .css
| |-- ext.pharmacopedia.perspective.* (observer-perspective form enhancement)
| |-- ext.pharmacopedia.administer.* (administer-to-others surfaces)
| |-- ext.pharmacopedia.observation.js + .css
| |-- ext.pharmacopedia.refupgrade.js + .css
| |-- ext.pharmacopedia.lifetimeline.js + .css
| |-- ext.pharmacopedia.lifegraph.js + .css
| |-- ext.pharmacopedia.kitsync.js
| |-- ext.pharmacopedia.frontpage.* / ext.pharmacopedia.categoryindex.* (diptych splashes)
| |-- ext.pharmacopedia.appearance.* (the Appearance rail)
| |-- ext.pharmacopedia.skin.plants.css (plants-skin overlay)
| `-- vendor/vis-timeline/ (vis-timeline 7.7.3, Apache-2.0 + MIT)
| |-- vis-timeline-graph2d.min.js
| |-- vis-timeline-graph2d.min.css
| `-- LICENSE
|-- sql/
| |-- (one .sql per table; patches as patch-*.sql)
`-- i18n/
`-- en.json