feat: REST API v1 — HMAC-signed product/content creation and file upload

Adds a public/private key pair authentication system and REST API endpoints
for programmatic product and content management. Designed for CI/CD pipelines
(permacomputer.com image hosting).

Auth: HMAC-SHA256 signed requests using public/private key pairs.
The secret key never travels over the wire. Replay window: 300 seconds.

Endpoints:
  POST /api/v1/products              create product (fiat/crypto priced)
  POST /api/v1/content               create content (free)
  GET  /api/v1/products/{id}         get product
  GET  /api/v1/content/{id}          get content
  POST /api/v1/products/{id}/upload-url     presigned S3 POST for direct upload
  POST /api/v1/content/{id}/upload-url      presigned S3 POST for direct upload
  POST /api/v1/products/{id}/files/confirm  confirm upload, register metadata
  POST /api/v1/content/{id}/files/confirm   confirm upload, register metadata

Key management UI in shop settings. Secret shown once on generation.

Migration: mps_api_key table (id, shop_id, public_key, secret_key, label,
created_timestamp, last_used_timestamp, is_active)

Tests: 12 MpsApiKey unit tests, 8 REST API functional tests (269 total passing)
This commit is contained in:
russell@unturf.com 2026-04-06 15:23:30 -04:00
parent dc06551967
commit b72fc6bf3b
13 changed files with 1137 additions and 0 deletions

154
docs/tickets/mps-17.md Normal file
View file

@ -0,0 +1,154 @@
# MPS-17: REST API v1 — HMAC-signed product/content creation + file upload
## Purpose
Enable CI/CD pipelines (e.g. permacomputer.com) to programmatically:
- Create products (fiat/crypto priced) or content (free)
- Upload files directly to Spaces via presigned POST
- Confirm uploads and get CDN URLs
## Auth — HMAC public/private key pairs
Each shop has API key pairs. A key pair is:
- `public_key``mps_pub_{32 hex}` — identifies the pair, safe to log
- `secret_key``mps_sec_{64 hex}` — signs requests, shown **once** on creation
No bearer tokens. The secret never travels over the wire. Every request is
signed with HMAC-SHA256. Replay window: ±300 seconds.
### Signing scheme
```
string_to_sign = "{METHOD}\n{PATH}\n{TIMESTAMP}\n{SHA256_OF_BODY_HEX}"
signature = hmac_sha256(secret_key, string_to_sign).hexdigest()
Request headers:
X-MPS-Key: mps_pub_abc123...
X-MPS-Timestamp: 1712345678
X-MPS-Signature: sha256=abcdef...
```
### Shell example (for CI)
```bash
METHOD=POST
PATH=/api/v1/products
TIMESTAMP=$(date +%s)
BODY='{"title":"Debian permacomputer","description":"...","price":"0.00"}'
BODY_HASH=$(echo -n "$BODY" | sha256sum | awk '{print $1}')
STRING_TO_SIGN="${METHOD}\n${PATH}\n${TIMESTAMP}\n${BODY_HASH}"
SIG=$(echo -n "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$MPS_SECRET_KEY" | awk '{print $2}')
curl -X POST https://my.makepostsell.com/api/v1/products \
-H "X-MPS-Key: $MPS_PUBLIC_KEY" \
-H "X-MPS-Timestamp: $TIMESTAMP" \
-H "X-MPS-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
```
## API Endpoints
```
POST /api/v1/products create product (is_sellable=True)
POST /api/v1/content create content (is_sellable=False)
GET /api/v1/products/{id} get product
GET /api/v1/content/{id} get content
POST /api/v1/products/{id}/upload-url get presigned POST URL for file upload
POST /api/v1/content/{id}/upload-url get presigned POST URL for file upload
POST /api/v1/products/{id}/files/confirm confirm S3 upload, register file, get CDN URL
POST /api/v1/content/{id}/files/confirm confirm S3 upload, register file, get CDN URL
```
### POST /api/v1/products
Request:
```json
{
"title": "Debian permacomputer 6.12 amd64",
"description": "Debian bookworm with CWE-407 patched Linux 6.12 kernel",
"price": "9.99",
"visibility": "public"
}
```
Response 201:
```json
{
"id": "abc123...",
"url": "https://my.makepostsell.com/p/abc123/debian-permacomputer",
"edit_url": "https://my.makepostsell.com/p/abc123/edit"
}
```
### POST /api/v1/content
Same body minus `price`. Response 201 same shape with `/c/` URL.
### POST /api/v1/products/{id}/upload-url
Request:
```json
{
"filename": "debian-permacomputer-6.12-amd64.qcow2",
"content_type": "application/octet-stream"
}
```
Response 200:
```json
{
"upload_url": "https://nyc3.digitaloceanspaces.com/...",
"fields": { "key": "...", "AWSAccessKeyId": "...", ... },
"confirm_path": "/api/v1/products/{id}/files/confirm",
"key": "products/shop_id/product_id/product.qcow2"
}
```
### POST /api/v1/products/{id}/files/confirm
Request:
```json
{
"key": "products/shop_id/product_id/product.qcow2",
"filename": "debian-permacomputer-6.12-amd64.qcow2"
}
```
Response 200:
```json
{
"cdn_url": "https://plan-period-files.nyc3.cdn.digitaloceanspaces.com/..."
}
```
## Shop Settings UI
New section at bottom of `/s/{shop_id}/settings`:
- List active key pairs (label, public key, created date, last used)
- "Generate new key pair" form (label input)
- Secret shown **once** in a flash-style `<output>` element after generation
- Per-key revoke button
Routes:
```
POST /s/{shop_id}/api-keys/generate
POST /s/{shop_id}/api-keys/{key_id}/revoke
```
## Files
| File | Change |
|------|--------|
| `models/api_key.py` | New: `MpsApiKey` model |
| `models/__init__.py` | Import `MpsApiKey` |
| `models/meta.py` | Add `MpsApiKey` to `CLASS_TO_TABLE` |
| `views/api/__init__.py` | HMAC auth: `api_key_required` decorator |
| `views/api/items.py` | All API endpoints |
| `routes.py` | Add API + key management routes |
| `views/shop.py` | `api_keys_generate`, `api_key_revoke` handlers |
| `templates/shop_settings.j2` | API Keys section |
| `alembic/versions/` | Migration: `mps_api_key` table |
| `tests/test_models.py` | `MpsApiKey` unit tests |
| `tests/test_integration.py` | HMAC signing integration tests |
| `tests/test_functional.py` | API endpoint functional tests |