API — practice-share router¶
backend/src/routers/practice_share.py (509 lines).
APIRouter(prefix="/practices", tags=["practice-share"])
(practice_share.py:84) — shares the /practices prefix with the
practices router but is a separate module. Four routes
joined by the PracticeShareLink token table plus a listing route
(practice_share.py:1-13).
| Method | Path | Rate limit | Auth | Response | Success | Errors |
|---|---|---|---|---|---|---|
| POST | /practices/{practice_id}/share-link |
10/hour per user | JWT, owner (or preset) | ShareLinkResponse |
201 | 404 practice_not_found, 403 forbidden |
| GET | /practices/share/{token} |
30/hour | JWT (any signed-in user) | ShareLinkPreviewResponse |
200 | 404 share_link_not_found; 410 share_link_expired / share_link_revoked / share_link_exhausted |
| POST | /practices/share/{token}/import |
30/hour | JWT | ShareLinkImportResponse |
201 | 404 / 410 (as above); 400 cannot_import_own_practice |
| DELETE | /practices/share-links/{share_link_id} |
— | JWT, minter | — | 204 (idempotent) | 404 share_link_not_found, 403 forbidden |
| GET | /practices/{practice_id}/share-links?limit= |
— | JWT, owner | list[ShareLinkResponse] |
200 | 404 / 403 |
The 404/410 split is deliberate (practice_share.py:16-23): 404 means
the token does not exist; 410 means it existed but "has aged out, been
revoked, or hit its max_uses cap — 410 tells the client the link
itself is dead and not worth retrying."
Notes:
- Mint — ownership is "I submitted this row"; preset practices
(
submitted_by_user_id IS NULL) can be shared by anyone (#348); someone else's submission 403s (practice_share.py:259-265). Tokens aresecrets.token_urlsafe(32)with up to 4 mint attempts on the vanishingly-unlikely unique collision (practice_share.py:61-65). - Preview requires authentication "so an anonymous link harvester
cannot crawl tokens without paying the signup tax", and the response
omits
submitted_by_user_idso the endpoint cannot double as a user-id enumeration oracle (practice_share.py:298-305). - Import clones the source as an
approved=Falserow withsubmitted_by_user_id= recipient, keeping it private to them (practice_share.py:9-12). Self-import is a 400 "UX foot-gun" rejection (practice_share.py:389-391,404). Race safety: one guardedUPDATE … SET use_count = use_count + 1 WHERE … use_count < max_usesclaims the slot, so "two concurrent importers of amax_uses=1link cannot both bypass the cap"; the loser gets the same 410share_link_exhaustedas a sequential third importer, and the increment and clone share one transaction (practice_share.py:393-400; model rationale atbackend/src/models/practice_share_link.py:48-55). - Revoke — no-op 204 on an already-revoked link;
revoked_atis not pushed forward (practice_share.py:445-450). - List powers the frontend ShareSheet's "active links with Revoke"
panel, newest-first, bounded to
limit∈ [1, 200] (default 50) (practice_share.py:467-494).
DTOs: backend/src/schemas/practice_share.py. Model:
data-model/practice.
Grounded in adepthood@fbc529d, 2026-07-31.