POST endpoint for PBS - #3646
Conversation
📊 Code Quality Score: 50/100
Was this score accurate? 👍 Yes · 👎 No Scored by GitVelocity · How are scores calculated? |
Implements clean_and_default_post_body() function with supporting constants to validate and sanitize POST request bodies for the powered_by_api endpoint. Adds 15 unit tests covering all validation scenarios (required fields, type checking, choice validation, URL validation, field allowlisting, and defaults). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wire POST support into powered_by_api view: parse JSON body, validate via clean_and_default_post_body, create Project on success (201) or return validation error (400). New _powered_by_post helper handles POST logic. Adds 7 comprehensive tests for create path with anonymous/staff auth, validation, and field gating. Removes now-unused @require_GET decorator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace Project.objects.create with update_or_create to implement idempotent behavior: same project_link updates existing row (HTTP 200) instead of creating duplicate (HTTP 201). Partial updates only modify specified fields, and staff-only fields (is_published, featured, tags, is_buggy) are preserved across updates since they're excluded from WRITABLE_FIELDS allowlist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ayer Move defaulting of submission_source and submission_date out of clean_and_default_post_body into _powered_by_post, applying defaults only when creating new projects. This prevents partial updates from silently resetting these fields to defaults when they're omitted from the POST body, which violated the partial-update contract. Also update Task 1 tests to reflect that clean_and_default_post_body no longer applies defaults, and add test coverage verifying these fields are preserved on updates. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ields Fixes three review findings on the powered_by_api POST endpoint: - Force is_published=False on every UPDATE (not just create), since project_link is a public field an anonymous caller can learn from GET and then use to silently deface a live project via POST. - Restrict powered_by_api to GET/POST via require_http_methods, so other verbs (DELETE, PUT, ...) return 405 instead of falling through to the GET list handler. - Validate submission_date (parseable ISO 8601), writable CharFields against their model max_length (introspected, not hardcoded), and creator_email as a real email address in clean_and_default_post_body, so bad input is rejected with a 400 before it can reach the DB and raise an uncaught 500. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7e68f17 to
b8bf3ea
Compare
There was a problem hiding this comment.
Pull request overview
Adds unauthenticated POST/upsert support for Powered by Sefaria projects, including Formstack payload translation.
Changes:
- Adds validation, Formstack mapping, and project upserts.
- Expands API tests for creation, updates, and validation.
- Documents the endpoint design and implementation plan.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
powered_by/views.py |
Implements POST handling and Formstack translation. |
powered_by/tests/powered_by_api_test.py |
Tests POST behavior and validation. |
docs/superpowers/specs/2026-08-12-powered-by-post-endpoint-design.md |
Documents the API design. |
docs/superpowers/plans/2026-08-12-powered-by-post-endpoint.md |
Records the implementation plan. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| project, created = Project.objects.update_or_create( | ||
| project_link=project_link, | ||
| defaults=cleaned, | ||
| ) |
| project, created = Project.objects.update_or_create( | ||
| project_link=project_link, | ||
| defaults=cleaned, | ||
| ) |
There was a problem hiding this comment.
This is resolved by us removing the ability to upsert for now.
saengel
left a comment
There was a problem hiding this comment.
Still needs some polishing, great progress in the right direction.
| # _formstack_field() tolerate the reasonable variants. Once the webhook is | ||
| # live, sanity-check a real payload against these assumptions. | ||
|
|
||
| FORMSTACK_FIRST_NAME_FIELD = "179244240" |
There was a problem hiding this comment.
Why are these fields separated out? Shouldn't they all live in the same dict?
| # "Which Sefaria data or tools did you use?", "Which categories of endpoints | ||
| # did you utilize?", and the 11 conditional "Which specific endpoints did you | ||
| # use?" fields all merge into Project.sefaria_tools_used. | ||
| FORMSTACK_TOOLS_USED_FIELDS = ( |
There was a problem hiding this comment.
This makes sense to keep separate
| if not Project.objects.filter(project_link=project_link).exists(): | ||
| cleaned.setdefault("submission_source", SubmissionSource.FORMSTACK) | ||
| cleaned.setdefault("submission_date", timezone.now()) | ||
| # TEMP LOCAL TESTING ONLY (elza, 2026-08-20): is_published force-False on |
There was a problem hiding this comment.
This comment needs to be cleaned up
| project, created = Project.objects.update_or_create( | ||
| project_link=project_link, | ||
| defaults=cleaned, | ||
| ) |
| project, created = Project.objects.update_or_create( | ||
| project_link=project_link, | ||
| defaults=cleaned, | ||
| ) |
There was a problem hiding this comment.
This is resolved by us removing the ability to upsert for now.
|
Looks like Formstack can accept some kind of key passed in the headers for webhooks (see: https://help.formstack.com/hc/en-us/articles/44592107329683-Webhooks) let's also add a key of sorts to avoid any un-authenticated POST issues. |
yitzhakc
left a comment
There was a problem hiding this comment.
Overall looks good. I would ideally want a way for us to be notified if we're getting invalid submissions from Formstack in a situation where their fields or structure changes, but that can be a separate story.
Now that project_link is no longer an upsert key, the remaining un-authenticated-POST risk is spam/junk submissions bypassing Formstack entirely. Require a HandshakeKey field in the POST body matching settings.FORMSTACK_HANDSHAKE_KEY (constant-time compared), sourced only from an env var in the untracked local_settings.py -- never committed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tomation-from-formstack-to-database
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
powered_by/views.py:275
HandshakeKeyis attacker-controlled Unicode, buthmac.compare_digestonly accepts ASCII when comparingstrvalues. A request such as{"HandshakeKey": "é"}therefore raisesTypeErrorand returns 500 instead of 401. Compare UTF-8 bytes (and validate the configured value's type) so every invalid key is rejected cleanly.
expected = getattr(settings, "FORMSTACK_HANDSHAKE_KEY", None)
provided = body.get("HandshakeKey") if isinstance(body, dict) else None
return bool(expected) and isinstance(provided, str) and hmac.compare_digest(provided, expected)
validate_email() raises TypeError (not DjangoValidationError) on a non-string value, so a malformed creator_email (e.g. a number) was escaping the try/except and surfacing as an unhandled 500 instead of a 400. Guard with an isinstance check first. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
yodem
left a comment
There was a problem hiding this comment.
Independent review pass on the handshake implementation, run with multi-agent verification and every claim re-checked against commit 3e20344c before posting. Findings below are only the ones not already covered by Copilot or @saengel, and each was executed or grepped rather than reasoned about.
Two things I checked and am NOT reporting, because they turned out to be wrong:
- Non-string
image_url/project_source_codecauses a 500. Refuted — Django 6.0.4'sURLValidator.__call__opens withif not isinstance(value, str) ... raise ValidationError, so it's caught at views.py:206-208 and returns a clean 400. (This was true of thecreator_emailpath before 3e20344 fixed it; it was never true of the URL fields.) - The handshake key gets captured by request logging. Refuted for this deployment —
sefaria/system/logging.py:decompose_request_infobinds onlyrequestUrlandrequestMethod, no body, and no Sentry/APM body capture is configured. The generic risk of a body-carried secret stands, but there is no live exposure here.
The design call itself (handshake key rather than the HMAC signature Formstack also offers) is defensible: the credential is fail-closed, compared with compare_digest, and auth sits inside the POST branch so the public GET is untouched. The findings below are about the edges around it, not the choice.
| "technical_experience", "vibe_coded", "project_why", "project_name", "project_link", | ||
| "project_source_code", "project_reach", "project_desc", "project_category", | ||
| "image_url", "has_pbs_logo", "consent_to_display", "creator", "creator_email", | ||
| "is_developer", "job_title", "found_sefaria", "submitter", "salesforce_id", "notes", |
There was a problem hiding this comment.
submitter, salesforce_id and notes are writable here, but powered_by/models.py:43-46 declares all three in PRIVATE_FIELDS — "PII / internal staff metadata." They're stripped on read and settable on write.
The risk is trust rather than data loss: a forged notes reads in the admin as if a colleague already vetted the submission, and salesforce_id is a soft FK something may cross-reference. is_published/featured/tags/status are correctly excluded.
Suggest dropping submitter and salesforce_id from WRITABLE_FIELDS.
| ) | ||
|
|
||
|
|
||
| def _formstack_field(body, field_id): |
There was a problem hiding this comment.
Worth settling before the webhook goes live: per Formstack's docs, "Post using field names" is the default and field IDs are an opt-in checkbox (postDataFieldKeys: field_names | field_ids | api_friendly_field_names | ...).
Left on the default, keys arrive as labels ("Project Name"), this lookup finds nothing, project_link is missing, and the whole submission 400s — every real submission dropped.
Not a code change: a Formstack config setting plus one captured real payload to test the map against.
There was a problem hiding this comment.
Did we check this with Elise? @ekoslow1-creator
| WEBHOOK_USERNAME = os.getenv("WEBHOOK_USERNAME") | ||
| WEBHOOK_PASSWORD = os.getenv("WEBHOOK_PASSWORD") | ||
|
|
||
| FORMSTACK_HANDSHAKE_KEY = os.getenv("FORMSTACK_HANDSHAKE_KEY") |
There was a problem hiding this comment.
Blocker — this setting never reaches any k8s environment, so every POST 401s on cauldron, staging and prod.
sefaria/settings.py:362-370 imports sefaria.local_settings, which in k8s is rendered from helm-chart/sefaria/templates/configmap/local-settings-file.yaml and mounted with subPath: local_settings.py (rollout/web.yaml:210-213). That template has no FORMSTACK line, so getattr(...) at views.py:275 returns None and line 286 returns 401 — even once the cluster secret exists, because nothing turns the env var into a Django setting.
Fix: add the os.getenv line to local-settings-file.yaml beside WEBHOOK_USERNAME (~line 399). Cluster secrets are staged in Sefaria/infrastructure#671 (dev) and #672 (prod, draft).
| # --- view: HandshakeKey requirement -------------------------------------------- | ||
|
|
||
| @pytest.mark.django_db | ||
| def test_post_missing_handshake_key_returns_401(client): |
There was a problem hiding this comment.
These tests never run in CI, so a green pytest-job here says nothing about them.
build/ci/createJobFromRollout.sh:27 runs pytest ... ./sefaria ./sso ./reader — no ./powered_by — and pytest.ini's python_files has no powered_by pattern, so the file is excluded twice. The job is triggered; it just collects nothing from here.
Pre-existing, but this PR adds ~470 lines of security-relevant tests into the blind spot. Fix: add ./powered_by to the pytest args and powered_by/tests/*_test.py to python_files.
Recommendation: switch the auth to Formstack's HMAC signatureI should have raised this in my review above instead of reviewing around it — apologies. The design we agreed on was Formstack's HMAC signature, and this PR implements the handshake key instead. They're two separate Formstack features ( Both are shared-secret schemes, so this isn't "simple vs. enterprise." The difference is where the secret travels:
The second row is the crux: a handshake key is a bearer secret, so anyone who sees one delivery can mint submissions indefinitely. A signature is bound to the bytes it signed. It also removes two real bugs rather than patching them. I originally filed both as inline comments and have deleted them, because switching makes them vanish:
Sketchdef _formstack_signature_ok(request):
secret = getattr(settings, "POWERED_BY_FORMSTACK_HMAC_SECRET", None)
sent = request.headers.get("X-FS-Signature", "")
if not secret or not sent:
return False
# request.body = the exact bytes received; never re-serialise a parsed dict
digest = hmac.new(secret.encode(), request.body, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={digest}", sent)
def _powered_by_post(request):
if not _formstack_signature_ok(request): # BEFORE json.loads
return jsonResponse({"error": "Unauthorized"}, status=401)
try:
body = json.loads(request.body)
...Formstack side: set HMAC Key (not Shared Secret) and keep Payload Format = JSON — with What this does not solveFormstack signs the body with no timestamp, so a captured signed request stays replayable indefinitely. Idempotency on the Formstack submission ID is still needed either way — which is the same change as @saengel's "let's use an ID instead." Happy to push this as a commit on the branch if that's easier than reworking it. |
|
Changed from Handshake to HMAC key. One thing worth flagging: the infra tickets (Sefaria/infrastructure#671 dev, #672 prod) were staged for a FORMSTACK_HANDSHAKE_KEY secret — those need to be updated to provision POWERED_BY_FORMSTACK_HMAC_SECRET instead, or the endpoint will 401 in those environments the same way as before. |
Replace the shared-secret-in-body HandshakeKey check with an HMAC signature over the raw request body, sent by Formstack in the X-FS-Signature header and verified against POWERED_BY_FORMSTACK_HMAC_SECRET. Also wires the powered_by test suite into CI (was silently excluded from both the pytest args and pytest.ini's python_files, so ~470 lines of security-relevant tests were never run). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| ) | ||
|
|
||
|
|
||
| def _formstack_field(body, field_id): |
There was a problem hiding this comment.
Did we check this with Elise? @ekoslow1-creator
📊 Code Quality Score: 50/100
Was this score accurate? 👍 Yes · 👎 No Scored by GitVelocity · How are scores calculated? |
Description
POST request for sefaria.org/api/powered-by which is also specifically compatible with Formstack submission source
Code Changes
Updates to sefaria/powered_by/views.py