Metadata-Version: 2.5
Name: x-tkn
Version: 2.0.0
Summary: SDK for the X-TKN API — stateful tokens as a service
Project-URL: Homepage, https://x-tkn.com
Project-URL: Repository, https://github.com/fennecstudio/platform
Project-URL: Issues, https://github.com/fennecstudio/platform/issues
Author: Fennec Studio
License: ISC
Keywords: expiring,one-time,sdk,secret,token,x-tkn
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: ISC License (ISCL)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.23
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Description-Content-Type: text/markdown

# x-tkn

Python client for [X-TKN](https://x-tkn.com) — stateful tokens as a service. Mint
a token, hand the code to someone, and control how many times and for how long it
can be redeemed.

Get an API key by signing up at [x-tkn.com](https://x-tkn.com).

```bash
pip install x-tkn
```

Python 3.10+. One dependency, [httpx](https://www.python-httpx.org), which is what
gives you a sync and an async client from the same package.

## Quickstart

```python
from x_tkn import xtkn

token = xtkn.create_token(
    type="password-reset",
    ref_id=user.id,
    max_uses=1,
    expires_in={"minutes": 30},
)

# token.code is returned once and never again. Persist or send it now.
send_email(user.email, f"https://app.example.com/reset?c={token.code}")
```

…and on the other side:

```python
from x_tkn import xtkn, XTknGoneError, XTknNotFoundError

try:
    token = xtkn.redeem_token(code, ref_id=user.id, type="password-reset")
    reset_password(user, token.payload)
except XTknGoneError:
    return render("That link has already been used.")
except XTknNotFoundError:
    return render("That link is not valid.")
```

## Async

`AsyncXTkn` has the same methods, the same arguments and the same validation —
the request building is shared code, not a parallel implementation.

```python
from x_tkn import AsyncXTkn

async with AsyncXTkn() as client:
    token = await client.create_token(max_uses=1, expires_in={"minutes": 15})
```

In a web application, hold one client for the process rather than building one
per request — a client built per request throws its connection pool away each
time. Close it on shutdown:

```python
client = AsyncXTkn()

@app.on_event("shutdown")
async def close_xtkn():
    await client.aclose()
```

The module-level `xtkn` is sync-only and deliberately so. An `AsyncClient` that
nothing closes is a resource leak the caller cannot see, so the async client is
yours to construct and close.

## Authentication

The key is read on every request, so it can be set after import.

```bash
X_TKN_API_KEY=your_key_here
```

Or pass it explicitly — necessary if you talk to more than one account:

```python
from x_tkn import XTkn

client = XTkn(api_key=os.environ["MY_KEY"])
```

**Keep the key server-side.** It grants full token CRUD for your account.

### Options

| Option      | Default                                  |                                                            |
| ----------- | ---------------------------------------- | ---------------------------------------------------------- |
| `api_key`   | `X_TKN_API_KEY`, then `X_TKN_API_KEY_ID` | Resolved per request                                       |
| `base_url`  | `https://api.x-tkn.com`                  |                                                            |
| `timeout`   | `30.0`                                   | **Seconds.** `None` or `0` disables                        |
| `headers`   | —                                        | Merged into every request; cannot override `Authorization` |
| `transport` | httpx's default                          | For tests, proxies or retries                              |

## `UNSET` versus `None`

JSON has two ways to say nothing and the API means different things by them: an
absent key takes the server's default, an explicit `null` clears the field.
Python's `None` can only carry one of those, so every optional argument defaults
to `UNSET` and `None` is left to mean `null`.

```python
xtkn.update_token(code)                   # changes nothing
xtkn.update_token(code, max_uses=None)    # clears the ceiling — unlimited uses
```

You rarely have to name `UNSET` yourself. It matters when you are forwarding
values you may or may not have:

```python
from x_tkn import UNSET

xtkn.create_token(ref_id=user.id if user else UNSET)
```

## Methods

Every method raises on failure — see [Errors](#errors). `code` is the token's
public identifier, returned by `create_token`.

### `create_token(...)`

```python
token = xtkn.create_token(
    type="handoff",              # [a-z0-9_-], ≤64 chars, defaults to "generic"
    ref_id="user_123",           # your own identifier, ≤256 chars
    payload={"role": "admin"},   # JSON-encoded, ≤64 KB
    max_uses=1,                  # 1–1,000,000. omit for unlimited
    description="Admin invite",  # operator-facing note
    expires_in={"hours": 2},     # or expires_at=datetime(...) — not both
)
```

`token.code` is the only copy. The server stores `sha256(code)` and cannot return
it later; every subsequent read leaves the field `None`.

With no `expires_at` or `expires_in`, the server expires the token 30 days after
creation.

`expires_in` takes a `timedelta` as readily as a dict — `timedelta(hours=2)` and
`{"hours": 2}` are the same request.

### `read_token(code)`

Returns the token without consuming a use. Check `is_active`, `is_expired`,
`is_used`.

### `redeem_token(code, ...)`

Consumes one use and returns the token. Raises `XTknGoneError` if it is revoked,
expired or exhausted; `XTknNotFoundError` if the code is wrong.

```python
xtkn.redeem_token(code, ref_id=user.id, type="handoff")
```

Pass both `ref_id` and `type` where you can. `ref_id` confines the lookup to the
identity the code was issued for, which is what bounds guessing; `type` stops the
redemption consuming a different kind of code held by the same identity.

### `update_token(code, ...)`

Changes `type`, `ref_id`, `payload`, `max_uses` or the expiry. `description` is
not updatable — the server's update handler ignores it.

### `extend_expiration(code, duration)`

Moves the expiry to `duration` from **now**, not from the existing expiry — so
this revives an already-expired token rather than extending from a past date.

```python
xtkn.extend_expiration(session_code, {"hours": 2})
```

### `revoke_token(code)` / `revoke_tokens(...)`

```python
xtkn.revoke_token(code)

# Log a user out everywhere. One call revokes a bounded batch.
while xtkn.revoke_tokens(ref_id=user.id, type="session").has_more:
    pass
```

`revoke_tokens` requires `type` or `ref_id`. An empty filter means "revoke
everything", which is not allowed by omission.

### `delete_token(code)`

Permanent. Prefer `revoke_token` — a revoked token can still explain why a
redemption failed, a deleted one is indistinguishable from one that never existed.

### `list_tokens(...)`

```python
result = xtkn.list_tokens(
    ref_id="user_123",
    is_revoked=False,
    sort="-createdAt",
    page=1,
    limit=50,  # capped at 100
)

for token in result:          # TokenList iterates its tokens
    print(token.display_name)

print(result.count)           # total matching the filter, not len(result)
```

Only `type`, `ref_id` and `is_revoked` are filterable. On the raw API the server
silently drops anything else, so a typo widens the result rather than erroring;
here it is a `TypeError` before anything is sent.

`sort` accepts `createdAt`, `updatedAt`, `expiresAt`, `lastUsedAt` or `uses`,
each optionally prefixed with `-`. Anything else is silently replaced with
`-createdAt` by the server; the `TokenSort` literal type catches it in a type
checker first.

**Codes are not exposed as a filter.** The API accepts one and hashes it to match
`sha256(code)`, but it can only return the single token you already hold the code
for — `read_token(code)` does that directly.

## The `Token` object

Attributes are snake_case and timestamps are parsed into aware `datetime`s.
Whatever the server actually sent is on `token.raw`, unrenamed and unparsed, so
a field this SDK does not know about is still reachable.

```python
token.expires_at            # datetime | None
token.raw["expiresAt"]      # "2026-09-30T12:00:00.000Z"
```

`repr(token)` masks `code`, because reprs reach logs and tracebacks far more
readily than a deliberate `print`. Attribute access still returns the real value.

## Errors

Every non-2xx response raises. Catch `XTknError` for all of them, or a subclass
to tell them apart.

| Class                 | Status   | Means                                             |
| --------------------- | -------- | ------------------------------------------------- |
| `XTknRequestError`    | 400      | Malformed request or failed validation            |
| `XTknAuthError`       | 401, 403 | Key missing, unknown or revoked                   |
| `XTknNotFoundError`   | 404      | No such token on this account                     |
| `XTknGoneError`       | 410      | Exists but spent: revoked, expired or out of uses |
| `XTknRateLimitError`  | 429      | Hourly guard or monthly quota exhausted           |
| `XTknServerError`     | 5xx      |                                                   |
| `XTknConnectionError` | —        | Never reached the server: DNS, reset, timeout     |
| `XTknConfigError`     | —        | Bad arguments; raised before any request          |

Each carries `status`, and `details` when the API supplied a field-level map.

## Payload encryption

The server never needs to read your `payload`, and accounts with
`requireEncryptedPayload` set reject anything that is not already an `xtkn.v1.`
or `xtkn.v1r.` envelope.

**This SDK does not encrypt for you.** Pass an already-encrypted string as
`payload` if your account enforces it.

## Differences from the JavaScript SDK

The two clients cover the same API and are deliberately close, but they are not
transliterations of each other.

| `@fennecstudio/x-tkn-js`             | `x-tkn`                                                 |
| ------------------------------------ | ------------------------------------------------------- |
| `timeoutMs`, milliseconds            | `timeout`, **seconds**                                   |
| `fetch` injection                    | `transport` injection                                    |
| Options objects (`{ maxUses: 1 }`)   | Keyword arguments (`max_uses=1`)                         |
| `listTokens({ where: {...} })`       | `list_tokens(ref_id=..., is_revoked=...)` — flattened    |
| Returns plain objects, camelCase     | Returns dataclasses, snake_case, with `.raw` for the wire |
| Timestamps are ISO strings           | Timestamps are `datetime`                                |
| `undefined` omits, `null` sends null | `UNSET` omits, `None` sends null                         |
| Async only                           | `XTkn` and `AsyncXTkn`                                   |

`x-tkn` 2.0.0 and `@fennecstudio/x-tkn-js` 2.x ship against the same generation
of the API. The two are versioned independently from here, so do not read
matching major numbers as a promise they stay matched.

## Development

```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest          # unit tests
.venv/bin/mypy            # strict type check
```

Tests live in `__tests__/` and are named `<module>.<function>.unit.py`, matching
the monorepo's convention rather than pytest's default `test_*.py` glob — see the
`python_files` setting in `pyproject.toml`.

## License

ISC
