wiki-vault/Sources/Dev/pbs-membership-loe1-phase1-version3.md

234 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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: 46 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.