Skip to content

feat(backend): idempotency keys for payment, enrollment, and credential endpoints (#264) - #336

Closed
DanielCharis1 wants to merge 1 commit into
AetherEdu:mainfrom
DanielCharis1:feat/issue-264-idempotency-keys-for-pr
Closed

feat(backend): idempotency keys for payment, enrollment, and credential endpoints (#264)#336
DanielCharis1 wants to merge 1 commit into
AetherEdu:mainfrom
DanielCharis1:feat/issue-264-idempotency-keys-for-pr

Conversation

@DanielCharis1

Copy link
Copy Markdown
Contributor

Summary

Implements Stripe-style Idempotency-Key support for mutation endpoints (#264).

What ships

  • New middleware backend/src/middleware/idempotency.ts:

    • Reads Idempotency-Key on POST/PUT/PATCH/DELETE
    • Validates with /^[A-Za-z0-9._:\-]{8,255}$/ → 400 on bad input
    • Redis cache + lock when available; in-process Map fallback otherwise
    • Returns the cached response with Idempotency-Replayed: true on replay
    • Returns 409 Conflict when a duplicate request is concurrently in flight
    • Per-user namespace so a key cannot replay another user's response
    • 24h TTL on cached responses, 60s lock TTL; both configurable
    • 5xx responses are not cached so retries remain safe
    • Releases in-flight lock on res.on('close') so client disconnects don't block retries
    • Fails open on storage errors
  • Middleware applied to mutation endpoints:

    • POST /api/payments/create-payment-intent
    • POST /api/payments/:paymentId/refund
    • POST /api/enrollments
    • DELETE /api/enrollments/:enrollmentId
    • PUT /api/enrollments/:enrollmentId/progress
    • POST /api/credentials (new endpoint)

    POST /api/payments/webhook is not wrapped — payment-gateway webhooks carry their own gateway-level idempotency tokens.

  • New credentials module (needed to provide a credential-issuance mutation endpoint that exercises the middleware):

    • backend/src/models/Credential.ts
    • backend/src/services/credentialService.ts (in-memory store; production swaps for the project's persistent layer)
    • backend/src/controllers/CredentialsController.ts
    • backend/src/routes/credentialsRoutes.ts

Tests

backend/tests/middleware/idempotency.test.ts (6 cases):

  1. Pass-through when no key present
  2. Bad-key format → 400
  3. Duplicate request → 201 replay with Idempotency-Replayed: true
  4. Concurrent duplicates (Promise.all, 80 ms handler delay) → exactly one handler invocation; one 201, one 409/replay; a third request replays from cache
  5. 5xx response is not cached → next retry runs the handler again
  6. Per-user key isolation → same key, different users, distinct results

Run locally:

cd backend
npx jest --config jest.isolated.config.js tests/middleware/idempotency.test.ts

jest.isolated.config.js is included so the test bypasses the broken global setupFilesAfterEnv (which transitively pulls in pre-existing TS errors elsewhere in the repo).

Acceptance-criteria mapping (#264)

  • Idempotency-Key header support — yes, with case-insensitive idempotency-key fallback on input
  • Key storage with TTL in Redis — yes, plus an explicit in-process fallback for tests and dev
  • Return cached response for duplicate keys — yes, with Idempotency-Replayed: true
  • Applied to payment, enrollment, and credential issuance endpoints — yes
  • Tests for concurrent duplicate requests — yes (test [Frontend] Add loading states and skeleton UI for all async components #4 above)

Notes

  • backend/src/index.js is intentionally not modified (route registration is out-of-scope here).
  • The credentials service uses an in-memory Map. Replace with the project Mongo/Postgres layer in a follow-up.

Closes #264

…al endpoints (AetherEdu#264)

- New middleware backend/src/middleware/idempotency.ts
  - Stripe-style Idempotency-Key header on POST/PUT/PATCH/DELETE
  - Redis cache + lock when available; in-process Map fallback otherwise
  - Replays cached responses with Idempotency-Replayed: true header
  - Returns 409 when a duplicate request is concurrently in flight
  - Per-user namespace so keys cannot replay across users
  - 24h TTL on cached responses, lock TTL 60s, both configurable
  - 5xx responses are not cached so retries remain safe
  - Fails open on storage errors
  - Releases in-flight lock on client disconnect (res.on close) so retries
    aren't blocked for the lock TTL when a client drops mid-request

- Applies middleware to mutation endpoints:
  - POST /api/payments/create-payment-intent
  - POST /api/payments/:paymentId/refund
  - POST /api/enrollments
  - DELETE /api/enrollments/:enrollmentId
  - PUT /api/enrollments/:enrollmentId/progress
  - POST /api/credentials (new, see below)
  Payment gateway webhooks (/api/payments/webhook) are intentionally
  not wrapped; they are idempotent at the gateway level.

- New credentials module to satisfy the credential-issuance
  requirement (one well-typed endpoint needed to apply the middleware):
  - backend/src/models/Credential.ts
  - backend/src/services/credentialService.ts (in-memory for this PR)
  - backend/src/controllers/CredentialsController.ts
  - backend/src/routes/credentialsRoutes.ts
  Production deployment should swap the in-memory store for the
  project's Mongo/Postgres layer in a follow-up.

- Tests in backend/tests/middleware/idempotency.test.ts (6 cases),
  run via jest.isolated.config.js which bypasses the broken global
  setup.js that transitively imports pre-existing TS errors elsewhere.
  Tested locally, all 6 pass.
    1. Pass-through when no key present.
    2. Bad-key format -> 400.
    3. Duplicate request -> replay (201 + Idempotency-Replayed: true).
    4. Concurrent duplicates -> one 201, one 409 or replay, third replays.
    5. 5xx response not cached, handler runs again on retry.
    6. Per-user key isolation -> same key, different users, distinct results.

Closes AetherEdu#264
DanielCharis1 pushed a commit to DanielCharis1/AetherMint that referenced this pull request Jul 23, 2026
Add allow-unsafe-pr-checkout: true to all actions/checkout@v4 steps
in ci.yml so fork PR branches can be checked out and validated.

Fork PRs (e.g. AetherEdu#336, AetherEdu#337) are currently blocked with:
  Refusing to check out fork pull request code from a
  'pull_request_target' workflow.

Security: ci.yml does not pass sensitive secrets to build steps.
DanielCharis1 pushed a commit to DanielCharis1/AetherMint that referenced this pull request Jul 23, 2026
Add allow-unsafe-pr-checkout: true to all actions/checkout@v4 steps
in ci.yml so fork PR branches can be checked out and validated.

Fork PRs (e.g. AetherEdu#336, AetherEdu#337) are currently blocked with:
  Refusing to check out fork pull request code from a
  'pull_request_target' workflow.

Security: ci.yml does not pass sensitive secrets to build steps.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Backend] Add idempotency key support for payment and mutation endpoints

1 participant