234 lines
12 KiB
Markdown
234 lines
12 KiB
Markdown
---
|
||
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. |