Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

Notable changes to `dodomain-sdk`. The import package is `dodomain`.

## 0.3.0

Parity with the rotation-overlap contract the API shipped on 2026-08-20 (and
`@dodomain/node` 0.4.0). Purely additive: `keys.rotate()` with no argument sends
the identical body-less request it always did and still means an immediate
cutover, so upgrading from 0.2.0 is a drop-in.

### Added

* **`keys.rotate(overlap_hours=...)`** — `0` (the default), `1` or `24`. A window
keeps the **old key authenticating alongside the new one** until it closes, so
a rotator can deploy the new key with zero downtime instead of racing its own
cutover. Only a requested window puts a body on the wire; the default stays the
request every server version has always accepted. A value the API does not
offer is refused locally with `InvalidRequestError` and `status_code == 0`,
before the call that would mint a credential — including `True`, which `== 1`
would otherwise have bought as an unasked-for one-hour window.
* **`RotatedSecretKey.previous_key_expires_at`** — when the previous key stops
authenticating, or `None` if it already has. Parses tolerantly, so a response
recorded before the field existed still reads as a zero-overlap rotation.
* **`RotationOverlapHours`** — the `Literal[0, 1, 24]` alias, exported so callers
can type a configured window rather than pass a bare `int`.

### Changed

* The docs for `keys.rotate` no longer say rotation has no grace window
unconditionally: the **default** has none, and it is also the kill switch — a
zero-overlap rotation terminates a window still running from an earlier one.
**Exactly one previous key is ever kept**, so rotating twice in a row kills key
n-1 immediately regardless of its remaining window.

## 0.2.0

Full parity with the API's current `/v1` surface: every callable REST operation
Expand Down
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,13 +236,36 @@ Three things that will bite if assumed away:
rotated = client.keys.rotate()
rotated.secret_key # the NEW dd_sk_… — the only copy that will ever exist
rotated.public_key # unchanged, so CI can assert it rewrote the right app
rotated.previous_key_expires_at # None: the old key is already dead
```

**There is no grace window.** The key you authenticated the call with stops
working the instant the response is produced. Write `rotated.secret_key` to your
secret store before doing anything else — drop it and you are locked out until you
rotate again from the dashboard. The client you called it on still holds the old
key; build a new one from the result.
**The default is an immediate cutover.** The key you authenticated the call with
stops working the instant the response is produced. Write `rotated.secret_key` to
your secret store before doing anything else — drop it and you are locked out
until you rotate again from the dashboard. The client you called it on still
holds the old key; build a new one from the result.

### Rotating with no downtime

If you cannot deploy the new key in the same breath, ask for an overlap window
and **both keys authenticate** until it closes:

```python
rotated = client.keys.rotate(overlap_hours=24) # 0 (default), 1 or 24
rotated.previous_key_expires_at # when the OLD key stops working
```

Rotate, ship `rotated.secret_key` everywhere, and let the old one lapse on its
own. Three things to hold on to:

* **Exactly one previous key is ever kept.** Rotating again overwrites that 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.
* **A zero-overlap rotation is the kill switch.** `keys.rotate()` with the
default also terminates a window still running from an earlier rotation, which
is how you revoke a previous key early.
* The client you called it on keeps using the key it was built with, so within a
window it keeps working; past the window its next call is a `401`.

There is deliberately no `keys.create`, `keys.list` or `keys.revoke`: key
inventory stays behind a human dashboard session, so a stolen key can never mint a
Expand Down
4 changes: 3 additions & 1 deletion src/dodomain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from __future__ import annotations

__version__ = "0.2.0"
__version__ = "0.3.0"

from ._client import AsyncDoDomain, DoDomain
from ._transport import DEFAULT_BASE_URL, RateLimitSnapshot
Expand Down Expand Up @@ -62,6 +62,7 @@
PublicSession,
ReverifyResult,
RotatedSecretKey,
RotationOverlapHours,
Session,
SessionWarning,
Tier,
Expand Down Expand Up @@ -115,6 +116,7 @@
"RateLimitSnapshot",
"ReverifyResult",
"RotatedSecretKey",
"RotationOverlapHours",
"Session",
"SessionWarning",
"Tier",
Expand Down
29 changes: 27 additions & 2 deletions src/dodomain/_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
from urllib.parse import urlsplit

from .errors import InvalidRequestError
from .models import RECORD_TYPES, DnsRecord
from .models import OVERLAP_HOURS_VALUES, RECORD_TYPES, DnsRecord

__all__ = ["validate_create_session"]
__all__ = ["validate_create_session", "validate_overlap_hours"]

# Two-or-more DNS labels, <=63 chars each, no leading/trailing hyphen. Case is
# accepted as sent (DNS is case-insensitive) but a trailing root dot is not: the
Expand Down Expand Up @@ -73,6 +73,31 @@ def validate_return_url(return_url: str) -> str:
return value


def validate_overlap_hours(overlap_hours: object) -> int:
"""Return the requested rotation overlap, or raise before any request is sent.

The API takes ``0 | 1 | 24`` and nothing else, so an arbitrary number is a
mistake worth naming here rather than as a server-side 400 — this call mints
a credential, and the least useful moment to learn the argument was wrong is
while wondering whether the old key is still alive.

``True`` is refused despite ``True == 1``: a boolean here is a caller who
meant "yes, overlap" and would silently get a one-hour window they never
named.
"""
if isinstance(overlap_hours, bool) or not isinstance(overlap_hours, int):
raise _reject(
f"overlap_hours must be one of {OVERLAP_HOURS_VALUES} "
f"(got {type(overlap_hours).__name__})."
)
if overlap_hours not in OVERLAP_HOURS_VALUES:
raise _reject(
f"overlap_hours must be one of {OVERLAP_HOURS_VALUES}; "
f"{overlap_hours} is not a window doDomain offers."
)
return overlap_hours


def validate_records(records: Sequence[DnsRecord] | Iterable[DnsRecord]) -> list[dict[str, object]]:
"""Return the wire shape of a session's records, or raise.

Expand Down
30 changes: 26 additions & 4 deletions src/dodomain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"PublicSession",
"ReverifyResult",
"RotatedSecretKey",
"RotationOverlapHours",
"Session",
"SessionWarning",
"Tier",
Expand All @@ -64,10 +65,20 @@
ApexToken = Literal["@", "(blank)", "%domain%"]
WarningCode = Literal["duplicate_host_label"]

#: How long the *previous* secret key keeps authenticating after a rotation.
#: ``0`` — the default — is an immediate cutover; ``1`` and ``24`` are the only
#: windows the API offers. Transcribed from ``zRotateAppSecretKeyInput`` in
#: ``packages/core/src/schemas.ts``; the server refuses anything else.
RotationOverlapHours = Literal[0, 1, 24]

#: Every DNS record type a connect session may request, from the app repo's one
#: record-type home (``packages/core/src/record-capabilities.ts``).
RECORD_TYPES: tuple[str, ...] = ("A", "AAAA", "CNAME", "TXT", "MX")

#: The overlap windows ``keys.rotate`` accepts, for the runtime check the
#: ``Literal`` above only makes at type-check time.
OVERLAP_HOURS_VALUES: tuple[int, ...] = (0, 1, 24)


# ── payload readers ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -916,10 +927,15 @@ def _from_api(cls, payload: Any) -> DeletedWebhookEndpoint:
class RotatedSecretKey:
"""The result of ``keys.rotate`` — a new secret key, **shown once**.

:attr:`secret_key` is the only copy that will ever exist. There is no grace
window: the previous key stopped authenticating the instant this response was
produced, so a caller that drops it has locked itself out of the API and must
rotate again from the dashboard.
:attr:`secret_key` is the only copy that will ever exist, whichever overlap you
asked for. With the default immediate cutover the previous key stopped
authenticating the instant this response was produced, so a caller that drops
the new one has locked itself out of the API and must rotate again from the
dashboard.

:attr:`previous_key_expires_at` is the whole difference an overlap window
makes: ``None`` when the old key is already dead, otherwise the moment it
stops authenticating.

:attr:`public_key` is echoed *unchanged* — it identifies the app in the widget
and is not rotated here. Assert on it to prove a CI job rewrote the right app's
Expand All @@ -932,6 +948,11 @@ class RotatedSecretKey:
#: that prints this object must not put a live credential in your logs.
secret_key: str = field(repr=False)
rotated_at: datetime
#: When the PREVIOUS key stops authenticating, or ``None`` if it already has
#: (a zero-overlap rotation, which is the default). Only ever one previous
#: key exists — a later rotation replaces it and kills key n-1 immediately.
#: Also ``None`` on a response recorded before the field existed.
previous_key_expires_at: datetime | None = None
raw: dict[str, Any] | None = field(default=None, compare=False, repr=False)

@classmethod
Expand All @@ -942,5 +963,6 @@ def _from_api(cls, payload: Any) -> RotatedSecretKey:
public_key=_req_str(data, "publicKey"),
secret_key=_req_str(data, "secretKey"),
rotated_at=_req_datetime(data, "rotatedAt"),
previous_key_expires_at=_opt_datetime(data, "previousKeyExpiresAt"),
raw=data,
)
59 changes: 47 additions & 12 deletions src/dodomain/resources/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,28 @@
from typing import TYPE_CHECKING

from dodomain._transport import RequestSpec
from dodomain.models import RotatedSecretKey
from dodomain._validation import validate_overlap_hours
from dodomain.models import RotatedSecretKey, RotationOverlapHours

if TYPE_CHECKING: # pragma: no cover
from dodomain._client import AsyncDoDomain, DoDomain

__all__ = ["AsyncKeys", "Keys"]

_ROTATE_SPEC = RequestSpec("POST", "/api/v1/keys/rotate")
_ROTATE_PATH = "/api/v1/keys/rotate"


def _rotate_spec(overlap_hours: RotationOverlapHours) -> RequestSpec:
"""Build the rotate call, sending a body only when a window was asked for.

The default cutover goes on the wire as **no body at all** — byte for byte
the request every server version has ever accepted, including the ones that
predate ``overlapHours``. Only a requested window sends the newer body, so
the common call cannot be broken by a server that has not shipped the field.
"""
hours = validate_overlap_hours(overlap_hours)
body = None if hours == 0 else {"overlapHours": hours}
return RequestSpec("POST", _ROTATE_PATH, json_body=body)


class Keys:
Expand All @@ -35,14 +49,28 @@ class Keys:
def __init__(self, client: DoDomain) -> None:
self._client = client

def rotate(self) -> RotatedSecretKey:
def rotate(self, *, overlap_hours: RotationOverlapHours = 0) -> RotatedSecretKey:
"""Rotate the calling app's secret key and receive the new one — **once**.

**There is no grace window.** The key you authenticated this very call with
stopped working the instant the response was produced, and the response is
the only copy of the replacement. Write ``result.secret_key`` to your secret
store *before* you do anything else; a caller that drops it has locked
itself out of the API and must rotate again from the dashboard.
The response is the only copy of the replacement whichever window you
choose. Write ``result.secret_key`` to your secret store *before* you do
anything else; a caller that drops it has locked itself out of the API and
must rotate again from the dashboard.

**The default is an immediate cutover.** With ``overlap_hours=0`` the key
you authenticated this very call with stops working the instant the
response is produced. That is also the kill switch: a zero-overlap
rotation **terminates an overlap window still running** from an earlier
rotation, so it is how you revoke a previous key early.

**An overlap window is opt-in.** ``overlap_hours=1`` or ``24`` keeps the
old key authenticating alongside the new one until
``result.previous_key_expires_at``, which is how you deploy a new key with
no downtime: rotate, ship the new key everywhere, and let the old one
lapse. **Exactly one previous key is ever kept** — rotating again
overwrites that slot and key n-1 dies immediately, whatever was left of
its window. So the safe rhythm is rotate, deploy, *then* rotate again, not
two rotations in a row.

``result.public_key`` comes back unchanged — it identifies your app in the
widget and is not rotated here — which is what lets a CI job assert it just
Expand All @@ -51,14 +79,21 @@ def rotate(self) -> RotatedSecretKey:
The client you called this on still holds the **old** key in memory. Build a
new client from ``result.secret_key`` for subsequent calls; this SDK does
not silently re-key a live client, because a rotation you did not notice is
worse than one that fails loudly.
worse than one that fails loudly. Within an overlap window that client
keeps working until the window closes; without one its next call is a 401.

Args:
overlap_hours: ``0`` (default), ``1`` or ``24``. Anything else is
refused locally, before the request that would mint a key.

Raises:
InvalidRequestError: With ``status_code == 0``, when ``overlap_hours``
is not a window the API offers.
PermissionError_: With
:attr:`~dodomain.errors.PermissionError_.secret_key_required` set,
when called with an OAuth access token rather than a ``dd_sk_`` key.
"""
return RotatedSecretKey._from_api(self._client.request(_ROTATE_SPEC))
return RotatedSecretKey._from_api(self._client.request(_rotate_spec(overlap_hours)))


class AsyncKeys:
Expand All @@ -67,6 +102,6 @@ class AsyncKeys:
def __init__(self, client: AsyncDoDomain) -> None:
self._client = client

async def rotate(self) -> RotatedSecretKey:
async def rotate(self, *, overlap_hours: RotationOverlapHours = 0) -> RotatedSecretKey:
"""Rotate the calling app's secret key. See :meth:`Keys.rotate`."""
return RotatedSecretKey._from_api(await self._client.request(_ROTATE_SPEC))
return RotatedSecretKey._from_api(await self._client.request(_rotate_spec(overlap_hours)))
15 changes: 15 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,26 @@ def webhook_endpoint_with_secret(**overrides: Any) -> dict[str, Any]:
return webhook_endpoint(**overrides)


#: A default (zero-overlap) rotation: `previousKeyExpiresAt` is present and null
#: because the old key is already dead.
ROTATED_KEY_RESPONSE: dict[str, Any] = {
"appId": "app_1",
"publicKey": "dd_pk_live_abc",
"secretKey": "dd_sk_live_the_new_one",
"rotatedAt": "2026-08-17T12:00:00.000Z",
"previousKeyExpiresAt": None,
}

#: The same rotation asked for a 24h window — the only field that differs.
ROTATED_KEY_WITH_OVERLAP_RESPONSE: dict[str, Any] = {
**ROTATED_KEY_RESPONSE,
"previousKeyExpiresAt": "2026-08-18T12:00:00.000Z",
}

#: A rotate response exactly as the API shipped it before overlap windows
#: existed — the shape a cached or archived payload still has.
LEGACY_ROTATED_KEY_RESPONSE: dict[str, Any] = {
key: value for key, value in ROTATED_KEY_RESPONSE.items() if key != "previousKeyExpiresAt"
}


Expand Down
12 changes: 12 additions & 0 deletions tests/test_async_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
LIST_APPS_RESPONSE,
PUBLIC_SESSION_RESPONSE,
ROTATED_KEY_RESPONSE,
ROTATED_KEY_WITH_OVERLAP_RESPONSE,
VERIFY_RESPONSE,
api,
connection,
Expand Down Expand Up @@ -241,6 +242,17 @@
lambda c: c.keys.rotate(),
lambda c: c.keys.rotate(),
),
(
"keys.rotate(overlap_hours=24)",
lambda: (
respx.post(api("/api/v1/keys/rotate")).mock(
return_value=httpx.Response(200, json=ROTATED_KEY_WITH_OVERLAP_RESPONSE)
)
and None
),
lambda c: c.keys.rotate(overlap_hours=24),
lambda c: c.keys.rotate(overlap_hours=24),
),
]


Expand Down
Loading
Loading