Skip to content

feat(keys): opt-in rotation overlap window and previousKeyExpiresAt parity (0.3.0) - #3

Merged
BSalaeddin merged 1 commit into
mainfrom
feat/rotation-overlap-parity
Aug 21, 2026
Merged

feat(keys): opt-in rotation overlap window and previousKeyExpiresAt parity (0.3.0)#3
BSalaeddin merged 1 commit into
mainfrom
feat/rotation-overlap-parity

Conversation

@BSalaeddin

Copy link
Copy Markdown
Collaborator

Brings the Python SDK level with the rotation-overlap contract that shipped in prod and in @dodomain/node 0.4.0 on 2026-08-20. Mirrors zRotateAppSecretKeyInput / zRotateAppSecretKeyResponse in packages/core/src/schemas.ts and the /api/v1/keys/rotate operation in apps/docs/public/openapi.json — no shapes invented here.

The contract change

POST /api/v1/keys/rotate now accepts an optional JSON body {"overlapHours": 0 | 1 | 24}, and the response gained previousKeyExpiresAt (ISO datetime or null).

before after
request body-less POST body-less POST, or {"overlapHours": 1 | 24}
response appId, publicKey, secretKey, rotatedAt + previousKeyExpiresAt: string | null

Wire behavior for the default is unchanged, deliberately. keys.rotate() and keys.rotate(overlap_hours=0) both send no body at all — byte for byte the request every server version has always accepted — matching node 0.4.0. Only a requested window puts a body on the wire, so the common call cannot be broken by a server that has not shipped the field. Two tests pin this; a mutant that always sent a body failed both.

Semantics documented (README, docstrings, CHANGELOG)

  • Default 0 is an immediate cutover and remains the kill switch. A zero-overlap rotation also terminates a window still running from an earlier rotation — that is how you revoke a previous key early.
  • During a window BOTH keys authenticate, until previous_key_expires_at. That is the zero-downtime deploy path: rotate, ship the new key, let the old one lapse.
  • Exactly one previous key is ever kept. Rotating again overwrites the slot and kills key n-1 immediately, whatever was left of its window — so the safe rhythm is rotate, deploy, then rotate again, never two rotations in a row.

What changed

  • keys.rotate(*, overlap_hours: RotationOverlapHours = 0), sync and async.
  • RotatedSecretKey.previous_key_expires_at: datetime | None, parsed with the repo's tolerant _opt_datetime — a response recorded before the field existed still reads as a zero-overlap rotation.
  • RotationOverlapHours = Literal[0, 1, 24], exported, so callers can type a configured window instead of passing a bare int.
  • validate_overlap_hours refuses a value the API does not offer locally, with status_code == 0, before the call that would mint a credential. True is refused too: True == 1 would otherwise buy a one-hour window nobody asked for.
  • Version 0.2.0 → 0.3.0 (__init__.py + the test that pins it), CHANGELOG entry. Purely additive — upgrading is a drop-in.

Tests

14 new tests. No body on the default and on an explicit 0; {"overlapHours": N} for 1 and 24; previous_key_expires_at parsed both null and set; the pre-overlap legacy payload still parses; bad windows refused with zero requests sent; async twin sends the same window and reads the same expiry; a new keys.rotate(overlap_hours=24) case in the sync/async parity matrix.

Both halves were proven red before green: forcing a body onto the default failed 2 tests, and dropping the previousKeyExpiresAt parse failed 3.

Gate results (local, CI-equivalent)

gate result
ruff check . All checks passed
ruff format --check . 35 files already formatted
ty check All checks passed
pytest tests --ignore=tests/e2e --cov=dodomain --cov-fail-under=90 359 passed, 99.21% total coverage (resources/keys.py and _validation.py at 100%)

tests/e2e is unchanged and not run here — it never exercises a live rotation (it only asserts a bogus key is refused with a 401), so nothing in this PR touches it.

🤖 Generated with Claude Code

@BSalaeddin
BSalaeddin merged commit 7f52923 into main Aug 21, 2026
4 checks passed
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.

1 participant