12 KiB
| created | path | project | tags | type | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-06-10 | Sources/Dev | pbs-membership-loe1-phase1-version3 |
|
project-plan |
Supersedes / Source-of-Truth Note
This is the build-ready, canonical Phase 1 plan. It supersedes both prior docs:
pbs-membership-loe1-recipe-saving— stale on database name (pbs_hub), topology (extension of pbs-hub), and auth (Python reimplementation of WordPress cookie HMAC validation). Architecture is wrong; implementation specifics were correct and are carried forward here.pbs-membership-loe1-phase1-backend-foundation— correct architecture and auth, but written without the original in context, which de-specified the SQL schema and collapsed the API surface to a single vague checkbox. Architecture is right; build detail was thin and is restored here.
Where any of the three disagree, this doc wins.
Goal
Build the backend foundation for membership: the database, the data layer, and the WordPress-side auth endpoint that everything else (the Phase 2 save button, the member area later) builds on. No member-facing UI in this phase.
Success = the data layer exists, a member's WordPress login can be validated server-side via WordPress itself, and an authenticated request can write/read/delete an interaction keyed to a WordPress user — testable via curl and verifiable in phpMyAdmin.
Estimated time: 4–6 hours (carried from original; auth approach is smaller to build than the original's HMAC reimplementation, so this is conservative).
Locked Decisions
Identity vs. features
- WordPress (Ultimate Member) owns identity — registration, login, profiles, roles/tiers. The membership backend never manages credentials.
- The membership backend (the Garden) owns features — saves, and later likes/cooked/planned/rated, preferences.
- The bridge is the validated WordPress session plus read-only access to WordPress tables.
Topology
- The membership data layer belongs to a standalone Flask application (the Garden) in its own container, separate from
pbs-hub.pbs-hubis a live team/production tool; the Garden must not share its deploy lifecycle or blast radius. - Shared MySQL server, but no shared application tables and no shared application logic with
pbs-hub.
Database — pbs_garden
- The Garden owns a dedicated database,
pbs_garden, on the shared MySQL server. - Holds the generic interactions model — a single
user_interactionstable serving all interaction types (save / like / cooked / planned / rated) rather than one table per feature, plususer_preferences. wp_user_id(the WordPress user ID, returned by the auth contract) is the foreign key on interaction rows.- Read-only, one-directional bridge to WordPress tables (
wp_postsfor recipe title/slug). - Scoped MySQL user: full control on
pbs_garden, read-only on the specific WordPress tables needed, nothing else. No root for application access.
Concrete schema (restored from original; confirm post type before first migration — see Open Items)
CREATE DATABASE pbs_garden;
CREATE TABLE pbs_garden.user_interactions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
wp_user_id INT UNSIGNED NOT NULL,
wp_post_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(50) NOT NULL,
metadata JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_action (wp_user_id, wp_post_id, action)
);
CREATE TABLE pbs_garden.user_preferences (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
wp_user_id INT UNSIGNED NOT NULL,
pref_key VARCHAR(100) NOT NULL,
pref_value JSON NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_pref (wp_user_id, pref_key)
);
wp_post_id is BIGINT UNSIGNED to match WordPress's post ID type. (Instagram-style IDs are not relevant here — that VARCHAR concern is from the content-hub work, not this.)
Scoped MySQL user (restored from original; database name updated)
CREATE USER 'pbsgarden_app'@'%' IDENTIFIED BY 'strong_password_here';
-- Read-only on WordPress (auth + recipe display)
GRANT SELECT ON wordpress.wp_posts TO 'pbsgarden_app'@'%';
-- Full control on pbs_garden
GRANT ALL ON pbs_garden.* TO 'pbsgarden_app'@'%';
FLUSH PRIVILEGES;
Note: the app no longer needs SELECT on wp_users / wp_usermeta for auth — WordPress validates its own cookie and returns the user. The only WordPress read the app needs is wp_posts for recipe title/slug. (Audit existing MySQL users — pbs-api, n8n — and scope them off root while you're in here.)
Auth — mechanism (Option 2: WordPress validates its own cookie)
- On an authenticated request, the Garden forwards the incoming
wordpress_logged_in_*cookie to a small internal WordPress endpoint (mu-plugin / REST route). WordPress reads its own session server-side and returns the user. - Chosen for security (WP secret keys and auth crypto stay inside hardened WordPress — never copied into the Garden) and reliability (WordPress cannot drift out of sync with its own session format; survives WP/plugin/security-stack changes). The Garden already depends on WordPress at request time for read-only recipe data, so no runtime-independence is lost.
- Endpoint is internal-network only (server-to-server on the Docker network), never exposed via Traefik/Cloudflare. This constraint is load-bearing for security — an exposed endpoint would be an unauthenticated "who is this cookie" oracle.
- Validation responses are cached per-session, short TTL (~5 min), so WordPress isn't queried on every request. Logout/expiry still works because the cookie itself becomes invalid.
Auth — contract (WordPress endpoint → Garden)
Authenticated:
{
"authenticated": true,
"user_id": 47,
"username": "janedoe",
"display_name": "Jane Doe",
"email": "jane@example.com",
"roles": ["subscriber"]
}
Unauthenticated:
{ "authenticated": false }
user_id— critical; foreign key intopbs_gardeninteraction rows (maps towp_user_id).roles— array; membership tiers are expressed as WordPress roles (WordPress/Ultimate Member assigns them; the Garden is a pure reader). No separate tier field, no second source of truth. Multi-tier supported from day one.email— included now (cheap; wanted for later account/notification features).
API endpoint surface (restored from original; auth row reflects new mechanism)
| Method | Path | Description | Auth |
|---|---|---|---|
| POST | /api/interactions |
Create interaction (save, like, etc.) | Required |
| DELETE | /api/interactions |
Remove interaction | Required |
| GET | /api/interactions?action=save |
List user's interactions by action type | Required |
| GET | /api/interactions/check?post_id=123&action=save |
Check if a specific interaction exists | Required |
The check endpoint is not optional — Phase 2's save button needs it to render a recipe as already-saved on page load.
Request body (POST):
{ "post_id": 123, "action": "save", "metadata": null }
Response (GET list): joins pbs_garden.user_interactions with wordpress.wp_posts to return title and slug:
{
"interactions": [
{
"post_id": 123,
"action": "save",
"post_title": "Southern Black-Eyed Pea Stew",
"post_slug": "southern-black-eyed-pea-stew",
"saved_at": "2026-04-06T12:00:00"
}
]
}
wp_posts join filter (restored from original — load-bearing): filter on post_status = 'publish' and post_type IN ('post', 'wprm_recipe') to exclude revisions, drafts, autosaves, and other post types. Forgetting this returns garbage rows.
Tier authorization — future-proofed, not built
Tier gating is out of scope for Phase 1 (everything is free). The pattern, for when tiers exist:
TIER_PERMISSIONS = {
"free": ["save", "like"],
"basic": ["save", "like", "planned", "cooked"],
"premium": ["save", "like", "planned", "cooked", "collections", "ai_suggest"],
}
Adding paid tiers later is a code change, not a schema change. The contract already carries roles[], so the source of truth is in place; only the enforcement layer is deferred.
Open Items
- Recipe post type: are recipes the
wprm_recipepost type or regular WordPresspostwith recipe metadata? Determines thepost_type IN (...)filter on thewp_postsjoin. Confirm before first migration. user_interactionsfinal columns: schema above is the intended shape; confirm column set and thewp_post_idtype matches WordPress (BIGINT UNSIGNED) before the first migration.- Ultimate Member tier roles: confirm UM can assign tier roles/capabilities the way tiering will need. Contract is unaffected either way; flag if awkward.
- WordPress endpoint form: mu-plugin vs. custom REST route for the internal validation endpoint — pick at build, both satisfy the contract.
Build Phases
Phase 1 deliverables checklist
- Create
pbs_gardendatabase on staging MySQL - Create
user_interactionstable - Create
user_preferencestable - Create
pbsgarden_appMySQL user with scoped permissions (full onpbs_garden, read-only onwp_posts) - Audit and scope existing MySQL users (pbs-api, n8n) off root
- Standalone Garden Flask app skeleton (its own container) with data layer / models
- WordPress-side internal validation endpoint (mu-plugin or REST route) returning the contract
- App-side auth: forward cookie → call endpoint → cache per-session (~5 min TTL) → attach
user_idto request POST /api/interactionsendpointDELETE /api/interactionsendpointGET /api/interactionsendpoint (withwp_postsjoin + publish/post-type filter)GET /api/interactions/checkendpoint- Test all endpoints via curl
- Verify data in phpMyAdmin
- Verify validation endpoint is unreachable from the public internet (internal-network only)
- Error handling and logging
Out of scope
- Save button UI on WordPress (Phase 2)
- Member area / browsing UI (later)
- Tier gating enforcement (the contract carries roles; enforcing comes when tiers exist)
Notes
LOE 1 phase roadmap (orienting context)
- Phase 0 — Ultimate Member setup (registration, login, roles, basic profile)
- Phase 1 — Backend foundation ← THIS DOC
- Phase 2 — Save button on WordPress recipe pages (JS widget; needs the check endpoint)
- Phase 3 — Saved recipes member area (the Garden frontend; see phase3a-garden-skeleton)
- Phase 4 — Collections (named groups)
- Phase 5 — Polish (mobile, loading states, error handling)
The four Lines of Effort
| LOE | Project | Status |
|---|---|---|
| 1 | Recipe Saving | Active — Phase 1 |
| 2 | Weekly Meal Plan Viewer | Queued |
| 3 | Whole Food Diversity Tracker | Queued |
| 4 | User Profiles & Preferences | Queued |
Why the auth approach changed (for future readers)
The original doc had the Garden reimplementing WordPress's cookie HMAC validation in Python, which required copying LOGGED_IN_KEY/LOGGED_IN_SALT into the Garden and reproducing WP's crypto. That spreads auth secrets to a second app (security risk) and couples the Garden to WP's internal cookie format (breaks silently on WP/security-stack changes). Option 2 — WordPress validates its own cookie — keeps secrets sealed in hardened WordPress and cannot drift out of sync with itself. Chosen against the criteria "always works, always secure."
What this doc fixed relative to the two it supersedes
- From recipe-saving: corrected db name, topology, auth.
- From phase1-backend-foundation: restored the concrete SQL, the four-endpoint API surface (including the DELETE and check endpoints), and the
wp_postspublish/post-type filter — all of which had been de-specified in the rewrite.