--- created: '2026-06-10' path: Sources/Dev project: pbs-membership-loe1-phase1-version3 tags: - pbs - membership - loe1 - flask - mysql - wordpress - auth - docker type: 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-hub` is 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_interactions` table serving all interaction types (save / like / cooked / planned / rated) rather than one table per feature, plus `user_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_posts` for 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) ```sql 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) ```sql 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: ```json { "authenticated": true, "user_id": 47, "username": "janedoe", "display_name": "Jane Doe", "email": "jane@example.com", "roles": ["subscriber"] } ``` Unauthenticated: ```json { "authenticated": false } ``` - `user_id` — **critical**; foreign key into `pbs_garden` interaction rows (maps to `wp_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):** ```json { "post_id": 123, "action": "save", "metadata": null } ``` **Response (GET list):** joins `pbs_garden.user_interactions` with `wordpress.wp_posts` to return title and slug: ```json { "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: ```python 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_recipe` post type or regular WordPress `post` with recipe metadata? Determines the `post_type IN (...)` filter on the `wp_posts` join. Confirm before first migration. - [ ] **`user_interactions` final columns:** schema above is the intended shape; confirm column set and the `wp_post_id` type 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_garden` database on staging MySQL - [ ] Create `user_interactions` table - [ ] Create `user_preferences` table - [ ] Create `pbsgarden_app` MySQL user with scoped permissions (full on `pbs_garden`, read-only on `wp_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_id` to request - [ ] `POST /api/interactions` endpoint - [ ] `DELETE /api/interactions` endpoint - [ ] `GET /api/interactions` endpoint (with `wp_posts` join + publish/post-type filter) - [ ] `GET /api/interactions/check` endpoint - [ ] 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_posts` publish/post-type filter — all of which had been de-specified in the rewrite.