- ID
- c2b5ad1e-35fa-44bc-b7ef-ec8f2a39a0b9
Exp 242 - Shareable User-Created Cookbooks
Strategy summary
Add the share-pattern primitives that let a user-created Cookbook be shared
outside Hungryroot via a short opaque URL, with referral credit accruing to
the sharer. Three additive fields on =Cookbook= (=owner=, =visibility=,
=share_token=), one new read endpoint, one rotate endpoint, and a serializer
guard. *No* parallel attribution system — sharing reuses the existing
=Customer.referral_code= rails (=/r/<code>= redirector, =PromoMiddleware=,
=award_referral_bonus=). This document covers only the share primitives;
user-cookbook CRUD scaffolding (create/update/list-mine/delete) is a separate
effort that consumes these primitives. Companion to [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 - Favorites +
User Cookbooks]]; builds on [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1430945793][Exp 232 - Cookbooks]].
Overview
- POD :: Brand
- Product Requirements / Brief :: TODO
- Design Document :: This doc
- Figma :: TODO
- Epic :: [[https://hungryroot.atlassian.net/browse/EP-650][EP-650]]
- Experiment :: Exp 242
- Slack Channel :: [[https://hungryroot.slack.com/archives/C0B4QA7Q05A][#exp-242]]
- Target Complete Date (Backend) :: TODO
- Target Launch Date (Feature) :: TODO
- WEB spec :: TODO
People
| Role | Review Approved (add initials) |
|-------------------------------+--------------------------------|
| Stakeholder Lead - TODO @ | |
| Product Lead - TODO @ | |
| Technical Team Lead - TODO @ | |
| Technical Primary - TODO @ | |
| Technical Secondary - TODO @ | |
| QA Lead - TODO @ | |
| Data Lead - TODO @ | |
| BFL Lead - TODO @ | |
| Additional Technical - TODO @ | |
Timeline and milestones
| Item | Date | Description | Key Stakeholder Sign-off |
|--------------------------------+------+-------------+---------------------------------------|
| Spec review complete | | | Primary Dev / Dev Lead / Project Lead |
| Code complete | | | ... |
| QA starts | | | ... |
| P1 features staging roll out | | | ... |
| P1 features production rollout | | | ... |
Background
Cookbooks today (=cookbooks/models.py:50-94=) are admin-managed, identified
by =slug=, and fully public when =is_active=True=. There is no customer
ownership, no per-cookbook visibility setting, and no share-token surface.
Product wants users to author cookbooks and share them with a flexible URL
pattern, and wants the sharer to earn referral credit when a recipient signs
up and places a first order.
The companion Phase 2 work in [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 - Favorites + User Cookbooks]] introduces
=Cookbook.owner= and =Cookbook.kind= so non-staff callers can write
=kind=user= Cookbooks. This spec extends that change with the *sharing*
primitives: visibility states, a short opaque share token, and a referral
attribution path. The two specs ship together (or sharing ships immediately
after user-cookbook CRUD), share the same =owner= column, and never touch
the Favorites proxy.
High-Level Solution
- *Three additive fields on* =Cookbook=. =owner= (FK → =Customer=, nullable
to preserve existing admin rows), =visibility= (tri-state enum:
=PRIVATE= / =UNLISTED= / =PUBLIC=), =share_token= (16-char unique
URL-safe string, nullable, lazily set on first share, rotatable). One
migration; existing admin cookbooks backfill to =visibility=PUBLIC= to
preserve current public-browsing behavior.
- *Token-keyed share URL.* =GET /api/v3/cookbooks/shared/<str:token>/=
returns the cookbook envelope for any caller (=AllowAny=) as long as
=visibility != PRIVATE=. Frontend reserves =/c/<token>= as the canonical
short URL.
- *Owner-only rotation.* =POST
/api/v3/cookbooks/<int:pk>/share-token/rotate/= mints a new token and
invalidates the previous one. Authenticated, owner check inside the view.
- *Serializer guard.* =share_token= is exposed in the general
=CookbookSerializer= only when the requesting user is the owner. A
dedicated =CookbookSharedSerializer= is used by the shared-detail
endpoint so the token + =owner_referral_code= never leak via list
responses or admin upserts.
- *Referral credit reuses existing rails.* The shared response includes
=owner_referral_code=; the frontend stamps the visitor's session via
the existing =/r/<code>= flow. =award_referral_bonus()= fires unchanged
on the referee's first order.
Experiment
*Hypothesis*: If we let customers share user-created Cookbooks via a short
URL and credit the sharer through the existing referral system, we will see
incremental new-customer acquisition through organic sharing without
degrading the public Cookbooks browsing experience.
*Experiment cleanup if it wins:* Keep the share primitives. Remove only the
client-cutover gate (if added). The =owner=, =visibility=, =share_token=
columns and the new endpoints become permanent.
*Experiment cleanup if it loses:* Delete the two new view classes, the
=cookbooks/share.py= helper, and =CookbookSharedSerializer=. The schema
fields can stay (nullable, no rows in losing arms beyond admin backfill of
=visibility=PUBLIC=) or a follow-up migration drops them. No data to roll
back.
Dynamic Configs
One optional DC:
- =cookbook_sharing_enabled= (bool, default =False=): When false the
=/api/v3/cookbooks/shared/<token>/= and =/share-token/rotate/= endpoints
return 404. Pure feature gate; no data-shape implication since the
underlying columns are populated either way.
No dual-write or backfill DC is needed.
Launch Plan
TBD with Product. See *Cutover Plan* below.
Implementation
All changes land in =cookbooks/=. No changes to =app/= models, REST, or
middleware.
Apps
- =cookbooks= :: EXTEND (model fields, new helper module, two views,
serializer field + new serializer class, URL wiring, list-view filter
tweak)
- =app= :: NO CHANGE. =Customer.referral_code= and the =/r/<code>=
redirector are reused as-is.
Models & Fields
Add to =cookbooks/models.py= near the top:
#+begin_src python
class CookbookVisibility(TextChoices):
PRIVATE = "private", "Private"
UNLISTED = "unlisted", "Unlisted"
PUBLIC = "public", "Public"
#+end_src
Extend =Cookbook= (=cookbooks/models.py:50-94=) with:
#+begin_src python
class Cookbook(BaseModelV1):
# ... existing Exp 232 fields ...
owner = ForeignKey -> app.Customer (
null=True,
blank=True,
on_delete=SET_NULL,
related_name="cookbooks",
)
visibility = CharField(
choices=CookbookVisibility.choices,
default=CookbookVisibility.PRIVATE,
db_index=True,
)
share_token = CharField(
max_length=16,
unique=True,
null=True,
blank=True,
db_index=True,
)
#+end_src
Notes:
- =owner=NULL= is the marker for admin/global cookbooks; preserves all
existing rows without backfill.
- =share_token= is lazily set the first time a cookbook is shared; rotation
overwrites in place.
- =owner= overlaps with the Phase-2 favorites spec's =owner= addition.
These specs ship together (or sharing ships after the Phase-2 columns
are already in place) so the migration appears in exactly one PR.
Migration
Single migration in =cookbooks/migrations/=:
- Add the three columns above.
- Backfill: =UPDATE cookbook SET visibility = 'public' WHERE owner_id IS
NULL;= so existing admin cookbooks remain browsable on the public list.
- Leave =share_token= NULL on all backfilled rows.
APIs
Retrieve Shared Cookbook
=GET /api/v3/cookbooks/shared/<str:token>/= → =CookbookSharedDetailView=
- =permission_classes = [AllowAny]= (mirrors =CookbookDetailView= at
=cookbooks/views.py:541-559=).
- Lookup by =share_token=. Returns 404 if not found *or* if
=visibility == PRIVATE= (a token may still exist from a prior shared
state but must never be reachable while private).
- Serialized via =CookbookSharedSerializer= so neither =share_token= nor
=owner_referral_code= can leak through the general serializer.
*Response (sketch):*
#+begin_src json
{
"id": 4321,
"slug": "summer-grilling",
"name": "Summer Grilling",
"owner_display_name": "Colin P.",
"owner_referral_code": "aB3xK9p2",
"visibility": "unlisted",
"sections": [ /* same shape as CookbookDetailView */ ]
}
#+end_src
=owner_referral_code= is populated only when =visibility ∈ {unlisted,
public}= and =owner_id IS NOT NULL=, and is omitted when the requesting
user is authenticated as the owner (self-referral guard; belt-and-
suspenders, since =award_referral_bonus()= and =redeem_promo_code()=
already block self-credit).
Rotate Share Token
=POST /api/v3/cookbooks/<int:pk>/share-token/rotate/= →
=CookbookRotateShareTokenView=
- =permission_classes = [IsAuthenticated]=, owner-only check in =post()=
(=request.user.customer == cookbook.owner=).
- Calls =rotate_share_token(cookbook)=; returns the new token in the
response body. The previous token immediately 404s on the shared-detail
endpoint.
Public List Filter
=CookbookListView= (=cookbooks/views.py= around line 515): default
queryset gains a =visibility=PUBLIC= filter for anonymous / non-owner
callers. Admin =include_inactive= path stays untouched. Owner-private and
unlisted cookbooks are excluded from public listings; unlisted cookbooks
are reachable only via direct token URL.
Out of scope here
- Owner CRUD endpoints for user-created cookbooks (list-mine, create,
update visibility, delete) — they belong to the user-cookbook scaffolding
effort and are wired separately.
- Username-namespaced vanity URL (=/c/<owner>/<slug>= for =public=
cookbooks). Additive, does not conflict with the token route.
Services
All token logic lives in =cookbooks/share.py=:
#+begin_src python
# cookbooks/share.py
import secrets
from django.db import IntegrityError
from cookbooks.models import Cookbook
_TOKEN_BYTES = 8
_MAX_RETRIES = 3
def generate_share_token() -> str:
# secrets.token_urlsafe(8) -> 11-char URL-safe base64, ~64 bits entropy.
return secrets.token_urlsafe(_TOKEN_BYTES)
def assign_share_token(cookbook: Cookbook) -> str:
if cookbook.share_token:
return cookbook.share_token
for _ in range(_MAX_RETRIES):
cookbook.share_token = generate_share_token()
try:
cookbook.save(update_fields=["share_token"])
return cookbook.share_token
except IntegrityError:
continue
raise RuntimeError("Could not allocate a unique share_token")
def rotate_share_token(cookbook: Cookbook) -> str:
cookbook.share_token = None
return assign_share_token(cookbook)
#+end_src
Key properties:
- *Idempotent on assign.* =assign_share_token= returns the existing token
if one is set; never silently rotates.
- *Bounded retry.* Three attempts on =IntegrityError= is enough at any
foreseeable scale (~64 bits of entropy); revisit token length only if
collision rate becomes measurable.
- *Single source of truth for the token shape.* Views never call
=secrets.token_urlsafe= directly.
Serializers
In =cookbooks/serializers.py= (around =CookbookSerializer= at line 266):
- Add =share_token= as a =SerializerMethodField= that returns the token
only when =self.context["request"].user.customer == obj.owner=.
Otherwise the field is =None= / omitted. Prevents the unlisted token
from leaking via any public response that happens to embed the
cookbook.
Add =CookbookSharedSerializer= for =CookbookSharedDetailView=:
- Exposes the public-safe cookbook fields plus =owner_referral_code= and
=owner_display_name=.
- Lazily ensures =cookbook.owner.referral_code= is populated before
serialization. *Reuses the existing reset/generate helper in
=app/services/api/pricing.py= (the one called at signup) — do not copy
the generation logic.* Keeps =PromoCode= side-effects consistent.
Referral Attribution on Shared Cookbooks
Reuse the existing referral rails — do not build a parallel attribution
system.
*Existing rails (verified):*
- =Customer.referral_code= — 8-char unique, generated via
=utils.generate_short_hash(8)= in =app/services/api/pricing.py:59=.
Doubles as a =PromoCode= (=type=REFERRAL_DISCOUNT=, =customer=referrer=).
- =/r/<code>= redirector (=app/views/web.py:388-407=) stamps
=request.session["promo"]= + =referral_code= cookie.
- =PromoMiddleware= (=app/middleware.py:178-212=) reads session /
=?promo== and redeems via =api.redeem_promo_code()= before first
purchase.
- =award_referral_bonus()= (=app/services/api/pricing.py:350-376=)
issues credit on referee's first order, idempotent via =CustomerLog=
(=ref3="Referral"=).
*Plan:*
1. =CookbookSharedDetailView= response carries =owner_referral_code=.
If =cookbook.owner.referral_code= is empty, call the shared
reset/generate helper before serializing.
2. Frontend, on loading =/c/<token>=, fires the same cookie-stamping
flow =/r/<code>= already triggers (easiest: hidden fetch to
=/r/<code>= so =redirector()= runs unchanged; or a future minimal
JSON endpoint that only sets the session/cookie — frontend choice,
out of scope here).
3. *Self-referral guard*: if the visitor is already authenticated as
the cookbook owner, =owner_referral_code= is omitted entirely
(belt-and-suspenders even though the underlying credit paths already
guard against self-credit).
4. No new credit logic. =award_referral_bonus()= fires unchanged when
the referee places their first order.
Caching
The shared-detail endpoint is keyed by an opaque token and is rendered
per-request; do not place it behind the public Cookbooks CDN cache. The
Phase-2 curated Cookbooks list keeps its existing CDN strategy from Exp
232 — the new =visibility=PUBLIC= filter is compatible with that cache
because all currently cached rows backfill to =PUBLIC=.
Django Settings
No new app registration. Add the optional DC
=cookbook_sharing_enabled= to dynamic config.
Cutover Plan
There is no data migration beyond the additive column backfill described
under *Models & Fields*. Cutover is purely deploy + feature gate.
1. Land schema migration + =cookbooks/share.py= + serializer guard
without exposing the new endpoints (URLs gated on
=cookbook_sharing_enabled=, default =False=).
2. Verify in staging: existing admin cookbooks return =visibility=public=
and remain listable; the public list endpoint excludes
private/unlisted/owner-private rows.
3. Ship the two new views and the URL routes behind the same DC.
4. Frontend ships the =/c/<token>= page and the share / rotate UI under
the same feature flag.
5. Flip =cookbook_sharing_enabled= for the experiment cohort. Compare
new-customer acquisition from share-attributed sessions vs. the
holdout.
6. (Post-experiment) On a win, remove the DC gate; on a loss, delete
the two new view classes, the URL routes, the helper, and
=CookbookSharedSerializer=. Columns can stay or be dropped in a
follow-up.
Risks & Mitigations
| Risk | Mitigation |
|---------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Unlisted token leaks through a list response or admin upsert | =share_token= exposed only via =SerializerMethodField= guarded by owner check. Shared endpoint uses dedicated =CookbookSharedSerializer=; never reuses =CookbookSerializer= for that view. |
| Token collision at scale | ~64 bits of entropy; 3-attempt retry on =IntegrityError= inside =assign_share_token=. Revisit length only when collision rate becomes measurable. |
| Owner has no =referral_code= when their cookbook is first shared | =CookbookSharedSerializer= lazily ensures one via the same reset/generate helper used at signup; never duplicates the generation logic. |
| Self-referral credit | View omits =owner_referral_code= when caller is authenticated as owner; =award_referral_bonus()= + =redeem_promo_code()= already guard at the credit layer. |
| Existing admin cookbooks disappear from public list after migration | Migration backfills =visibility=PUBLIC= for all =owner IS NULL= rows. Verified in staging before frontend rollout. |
| Rotate endpoint abused to invalidate shared links rapidly | Out of scope for v1. Add per-cookbook rate limit if/when measured. Flagged under *Future / Out of Scope*. |
Data Analytics
Track:
- Shared cookbook GET requests, with properties: =visibility=,
=owner_id=, anonymous-vs-authenticated.
- Share-token rotations.
- Sign-ups attributed to a shared-cookbook session (via existing
=CustomerLog= entries from =award_referral_bonus=, filtered by
source = share).
Segment
TBD with Data.
Back Office Considerations
Django Admin
Extend the existing =cookbooks= admin from Exp 232:
- Filterable list on =visibility= and =owner=.
- =share_token= visible to staff (read-only); rotation button optional.
- Owner-owned cookbooks should be effectively read-only for staff
(avoid editing customer-authored content); only =is_active=,
=visibility=, and =share_token= are editable.
Observability
Feature-level =structlog= events grouped under ="Cookbook Sharing"= with
=event_type==:
- =shared_view= — emitted on successful GET. Properties: =cookbook_id=,
=owner_id=, =visibility=, =authenticated= (bool).
- =share_token_rotated= — emitted on successful rotate. Properties:
=cookbook_id=, =owner_id=.
- =share_token_assigned= — emitted on first lazy assignment in
=assign_share_token=. Properties: =cookbook_id=, =owner_id=.
Referral credit events continue to flow through =award_referral_bonus()=
and =CustomerLog=; no new credit-side logging.
Technical Debt
*Debt incurred*
- =owner= column is nullable to preserve existing admin rows. New code
has to remember that =owner IS NULL= means "admin cookbook" rather
than "missing data." Documented in the model docstring; admin filters
enforce visibility of the distinction.
*Debt avoided*
- No parallel attribution system. Sharing rides on
=Customer.referral_code= + =/r/<code>= + =PromoMiddleware= +
=award_referral_bonus()= unchanged.
- No bespoke share-credit log table.
- No custom token-storage scheme; one column, one unique index.
Open Questions
- *Referral on =public= cookbooks?* Default here: yes — every share is
creditable. Easy to flip to "only =unlisted= shares earn credit" if PM
prefers.
- *Stamping mechanism on =/c/<token>=.* Hidden fetch to =/r/<code>= vs.
dedicated cookie-only endpoint. Frontend choice; doesn't affect the
backend response shape.
- *Vanity URL for public cookbooks* (=/c/<owner>/<slug>=). Out of scope
for v1; additive when wanted.
- *Audit log / metrics on token rotations.* Out of scope; revisit if
abuse appears.
- *Rate-limit on rotate endpoint.* Out of scope; revisit on abuse.
Ticket Breakdown
| Name | Estimate | Description | QA |
|-----------------------------------------------------+----------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------|
| TODO ticket - Schema + migration | 1 | Add =CookbookVisibility= enum and =owner= / =visibility= / =share_token= columns to =Cookbook=. Backfill admin rows to =visibility=PUBLIC=. Model docstring updates. | TODO |
| TODO ticket - =cookbooks/share.py= token helper | 1 | =generate_share_token=, =assign_share_token=, =rotate_share_token=. Unit tests for retry-on-collision, idempotent assign, rotate-overwrites-previous. | TODO |
| TODO ticket - Serializer guard + shared serializer | 1 | =share_token= owner-guarded =SerializerMethodField= on =CookbookSerializer=. New =CookbookSharedSerializer= with =owner_referral_code= + lazy referral-code generation via existing pricing helper. Tests for leak prevention and self-referral guard. | TODO |
| TODO ticket - Shared GET + rotate endpoints | 2 | =CookbookSharedDetailView= and =CookbookRotateShareTokenView=. URL wiring under =cookbooks/urls_v3.py=. =cookbook_sharing_enabled= DC gate. Tests: 200 unlisted/public, 404 private/unknown, 401 anon rotate, 403 non-owner rotate, 200 owner rotate, old token 404s. | TODO |
| TODO ticket - Public list filter + admin tweaks | 1 | Default-public filter on =CookbookListView=. Admin filters by =visibility= / =owner=; owner-owned cookbooks effectively read-only for staff. | TODO |
| *Total* | 5–6 | | |
Appendix
Glossary
- *Cookbook* :: A Hungryroot cookbook (=cookbooks.Cookbook=, defined at
=cookbooks/models.py:50-94=). Originally admin-authored under Exp 232.
- *Owner* :: =Cookbook.owner= → =app.Customer=. =NULL= for legacy
admin/global cookbooks; non-null for user-created cookbooks (Phase 2
of [[https://hungryroot.atlassian.net/wiki/spaces/BA/pages/1606746116/Exp+242+-+Favorites+User+Cookbooks][Exp 242 Favorites]]).
- *Visibility* :: =CookbookVisibility= enum on =Cookbook=: =PRIVATE= (not
exposed anywhere outside owner's own surfaces), =UNLISTED= (reachable
only by direct share token), =PUBLIC= (appears in public list).
- *Share token* :: 11-char URL-safe base64 string (=secrets.token_urlsafe(8)=)
stored in =Cookbook.share_token=. Opaque to clients; rotatable.
- *Referral code* :: =Customer.referral_code= — existing 8-char unique
string; doubles as a =PromoCode= of type =REFERRAL_DISCOUNT=.
Pre-existing mechanism, reused here unchanged.
- */c/<token>* :: Canonical short share URL on the marketing site.
Resolves to a frontend page that calls
=GET /api/v3/cookbooks/shared/<token>/= and stamps the referral
cookie via the existing =/r/<code>= flow.
Inline Review Comments
Open
- TODO — none yet; ready for first review pass.