Metadata-Version: 2.5
Name: caplinks
Version: 0.0.2
Summary: Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only.
Project-URL: Homepage, https://github.com/i2mint/caplinks
Project-URL: Repository, https://github.com/i2mint/caplinks
Project-URL: Documentation, https://i2mint.github.io/caplinks
Author: Thor Whalen
License-Expression: MIT
License-File: LICENSE
Keywords: capability,expiry,password,rate limit,scrypt,share link,token
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Description-Content-Type: text/markdown

# caplinks

Capability links: hand someone a link to one thing, with no account. The token is the permission; a password may gate it; it expires; it can be revoked; password attempts are limited. Standard library only, no web framework.

```
pip install caplinks
```

```python
from caplinks import CapabilityLinks

links = CapabilityLinks()  # records in a dict; pass any MutableMapping to persist them
rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
url = f"https://example.com/s/{rec['token']}/"

state, stored = links.status(rec["token"])   # "ok", "expired" or "missing"
links.check_password(stored, "open sesame")  # True
cookie = links.unlock_value(stored)          # what a correct password earns
links.unlocked(stored, cookie, max_age_s=3600)  # True, until the password changes or the link is revoked
links.revoke(rec["token"])                   # now "missing"
```

## What is in it

- `CapabilityLinks`: create, read, list and revoke links, and gate them on a password. Records are plain dicts in any `MutableMapping` keyed by token: the generic fields are `token`, `created`, `expires`, `password` (a hash) and `revoked`; your own fields (what the link reaches) sit beside them.
- `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
- `parse_expires` reads what people write (`30m`, `12h`, `7d`, `2w`, `2026-11-01` meaning the end of that day UTC, an ISO timestamp, `never`) and refuses the past; `is_expired` fails closed.
- `SlidingWindowLimiter` behind the `RateLimiter` protocol (`allow(key) -> bool`): count password attempts per client, per link, or both. It is per process; a shared limiter of the same shape (Redis, say) fits where several workers serve the links.

The unlock value is an HMAC keyed by the stored password hash, so changing the password or revoking the link (which clears the hash) ends every open session at once.

## What stays with your app

What a link reaches (its scope), how it is served (routes, cookie name, headers), and the attempt policy. `annals` (share links) is the first host.

## Seams

| keyword | default | for |
|---|---|---|
| `store` | `dict` | persistence: a dol store, a file-backed mapping, S3 |
| `hasher` | `ScryptHasher()` | argon2id or another scheme |
| `clock` | `now_utc` | tests that pin the time |
| `min_password_len` | 8 | a stricter or laxer gate |

A store that cannot vouch for a record raises `KeyError`, `ValueError` or `OSError` when it is read, and the link is then missing; so is a record whose generic fields have the wrong types. Every failure closes the link.
