pwpush-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pwpush_mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """pwpush-mcp: a Model Context Protocol server for Password Pusher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
pwpush_mcp/__main__.py ADDED
@@ -0,0 +1,97 @@
1
+ """Entry point: ``python -m pwpush_mcp`` or the ``pwpush-mcp`` console script.
2
+
3
+ Default transport is stdio (Claude Desktop / Claude Code / Docker MCP Gateway).
4
+ Pass ``--listen PORT`` to expose the server over Streamable-HTTP/SSE behind a
5
+ network gateway.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import logging
13
+ import sys
14
+ from typing import Any
15
+
16
+ from .config import Config
17
+ from .server import build_server
18
+
19
+ log = logging.getLogger("pwpush_mcp")
20
+
21
+ _SSL_WARNING = (
22
+ "SECURITY WARNING: PWPUSH_VERIFY_SSL=false — TLS certificate verification is "
23
+ "DISABLED. All HTTPS connections are vulnerable to MITM attacks. Set "
24
+ "PWPUSH_VERIFY_SSL=true (or PWPUSH_CA_BUNDLE) for production use."
25
+ )
26
+
27
+
28
+ async def _run_stdio() -> None:
29
+ from mcp.server.stdio import stdio_server
30
+
31
+ server = build_server()
32
+ if not server._cfg.verify_ssl:
33
+ log.warning(_SSL_WARNING)
34
+ async with stdio_server() as (read_stream, write_stream):
35
+ await server.run(read_stream, write_stream, server.create_initialization_options())
36
+
37
+
38
+ async def _run_http(host: str, port: int, log_level: str) -> None:
39
+ """Run as an SSE / Streamable-HTTP server."""
40
+ import uvicorn
41
+ from mcp.server.sse import SseServerTransport
42
+ from starlette.applications import Starlette
43
+ from starlette.responses import Response
44
+ from starlette.routing import Mount, Route
45
+
46
+ server = build_server()
47
+ if not server._cfg.verify_ssl:
48
+ log.warning(_SSL_WARNING)
49
+ sse = SseServerTransport("/messages/")
50
+
51
+ async def handle_sse(request: Any) -> Response: # starlette Request
52
+ async with sse.connect_sse(request.scope, request.receive, request._send) as streams:
53
+ await server.run(streams[0], streams[1], server.create_initialization_options())
54
+ return Response()
55
+
56
+ app = Starlette(
57
+ routes=[
58
+ Route("/sse", endpoint=handle_sse),
59
+ Mount("/messages/", app=sse.handle_post_message),
60
+ ]
61
+ )
62
+ config = uvicorn.Config(app, host=host, port=port, log_level=log_level.lower())
63
+ await uvicorn.Server(config).serve()
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ parser = argparse.ArgumentParser(prog="pwpush-mcp", description="Password Pusher MCP server.")
68
+ parser.add_argument(
69
+ "--listen",
70
+ type=int,
71
+ metavar="PORT",
72
+ help="Run as an HTTP/SSE server on this port. Default: stdio mode.",
73
+ )
74
+ parser.add_argument("--host", default="0.0.0.0", help="Bind host for --listen mode.")
75
+ parser.add_argument(
76
+ "--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]
77
+ )
78
+ args = parser.parse_args(argv)
79
+
80
+ logging.basicConfig(
81
+ level=args.log_level,
82
+ stream=sys.stderr,
83
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
84
+ )
85
+
86
+ # Fail fast on a missing base URL / malformed config before opening transport.
87
+ Config.from_env()
88
+
89
+ if args.listen:
90
+ asyncio.run(_run_http(args.host, args.listen, args.log_level))
91
+ else:
92
+ asyncio.run(_run_stdio())
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
pwpush_mcp/audit.py ADDED
@@ -0,0 +1,110 @@
1
+ """Structured audit logging for write operations.
2
+
3
+ Every invocation of a WRITE tool (``create_push``, ``expire_push``) emits one
4
+ JSON line on the ``pwpush_mcp.audit`` logger. The default destination is
5
+ stderr, which makes it trivial to ship to Loki / CloudWatch / journald via the
6
+ container runtime.
7
+
8
+ The payload contains:
9
+ - ts: ISO-8601 timestamp (UTC, second precision)
10
+ - tool: MCP tool name
11
+ - args: redacted call arguments (secret-bearing keys stripped)
12
+ - target: best-effort identifier (url_token / name) for grep-ability
13
+ - status: "ok" | "error"
14
+ - error: scrubbed exception text (only when status=error)
15
+
16
+ The secret ``payload`` / ``passphrase`` / file contents are NEVER logged.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import re
24
+ import sys
25
+ from datetime import datetime, timezone
26
+ from typing import Any
27
+
28
+ __all__ = ["configure", "log_call", "scrub"]
29
+
30
+ log = logging.getLogger("pwpush_mcp.audit")
31
+
32
+ # Argument keys whose values must never appear in the audit log.
33
+ _REDACT: frozenset[str] = frozenset({"payload", "passphrase", "token", "api_token", "file_paths"})
34
+
35
+ # Free-text secret patterns applied by :func:`scrub` to any string about to be
36
+ # logged or surfaced to the operator. Each pattern keeps the *label* (group 1)
37
+ # so the scrubbed string stays diagnosable (e.g. ``Authorization: Bearer ***``).
38
+ _SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
39
+ re.compile(r"(authorization\s*:\s*bearer\s+)\S+", re.IGNORECASE),
40
+ re.compile(r"(X-User-Token\s*[:=]\s*)[^&\s\"']+", re.IGNORECASE),
41
+ re.compile(r"(\bapi[_-]?token\s*=\s*)[^&\s\"']+", re.IGNORECASE),
42
+ )
43
+
44
+
45
+ def scrub(text: str) -> str:
46
+ """Return *text* with known secret-bearing substrings masked.
47
+
48
+ Idempotent and safe to call on already-scrubbed strings.
49
+ """
50
+ for pat in _SECRET_PATTERNS:
51
+ text = pat.sub(r"\1***", text)
52
+ return text
53
+
54
+
55
+ def _redact(value: Any) -> Any:
56
+ if isinstance(value, dict):
57
+ return {k: ("***" if k in _REDACT else _redact(v)) for k, v in value.items()}
58
+ if isinstance(value, list):
59
+ return [_redact(v) for v in value]
60
+ return value
61
+
62
+
63
+ def _target(args: dict[str, Any]) -> str | None:
64
+ token = args.get("url_token")
65
+ if token:
66
+ return str(token)
67
+ name = args.get("name")
68
+ if name:
69
+ return f"name:{name}"
70
+ return None
71
+
72
+
73
+ def log_call(
74
+ tool_name: str,
75
+ arguments: dict[str, Any],
76
+ *,
77
+ status: str = "ok",
78
+ error: str | None = None,
79
+ ) -> None:
80
+ """Emit one audit JSON line. Called from the call_tool handler."""
81
+ record: dict[str, Any] = {
82
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
83
+ "tool": tool_name,
84
+ "args": _redact(arguments),
85
+ "target": _target(arguments),
86
+ "status": status,
87
+ }
88
+ if error:
89
+ record["error"] = scrub(error)
90
+ log.info(json.dumps(record, default=str))
91
+
92
+
93
+ def configure(enabled: bool = True) -> None:
94
+ """Idempotently configure the audit logger.
95
+
96
+ When enabled, emit one JSON line per record on stderr. ``enabled=False``
97
+ silences it via a NullHandler.
98
+ """
99
+ log.handlers.clear()
100
+ if not enabled:
101
+ log.addHandler(logging.NullHandler())
102
+ log.propagate = False
103
+ return
104
+ handler = logging.StreamHandler(sys.stderr)
105
+ handler.setFormatter(logging.Formatter("%(message)s"))
106
+ log.addHandler(handler)
107
+ log.setLevel(logging.INFO)
108
+ # propagate=True so pytest's caplog can intercept; the root logger is
109
+ # unconfigured by default, so this does not duplicate output in production.
110
+ log.propagate = True
pwpush_mcp/client.py ADDED
@@ -0,0 +1,448 @@
1
+ """Thin async wrapper around the Password Pusher API (v1 and v2).
2
+
3
+ Two API generations exist in the wild:
4
+
5
+ - **v2** (pwpush.com, eu.pwpush.com, recent self-hosted): JSON under
6
+ ``/api/v2/pushes``, a ``push`` wrapper, ``expire_after_duration`` as an enum
7
+ index 0..17, and ``Authorization: Bearer`` auth.
8
+ - **v1** (older self-hosted instances): the classic ``/p.json`` endpoints, a
9
+ ``password`` wrapper, ``expire_after_days`` (whole days), and
10
+ ``X-User-Token`` / ``X-User-Email`` auth.
11
+
12
+ The client auto-detects the generation (overridable via ``PWPUSH_API_VERSION``)
13
+ and presents a single normalized interface to the tools.
14
+
15
+ Design invariants (both versions):
16
+ - The secret ``payload``/``files`` are never logged and are stripped from any
17
+ object returned to callers.
18
+ - No "retrieve" operation is exposed: retrieving a push consumes a view.
19
+ - Authentication is sent when configured; an operation only fails up-front for
20
+ a missing token when the endpoint is inherently account-scoped (list, audit).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import mimetypes
27
+ import random
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ from . import __version__
34
+ from .config import Config
35
+ from .durations import resolve_days, resolve_duration
36
+
37
+ # API fields that must never be returned to the model.
38
+ _SENSITIVE_FIELDS = ("payload", "files")
39
+
40
+ SUPPORTED_KINDS = ("text", "url", "qr", "file")
41
+
42
+
43
+ class PwpushError(Exception):
44
+ """Raised for any API, authentication, or network failure."""
45
+
46
+
47
+ class FeatureDisabledError(PwpushError):
48
+ """Raised when an instance does not enable a requested push type."""
49
+
50
+
51
+ def _public(obj: Any) -> Any:
52
+ """Return a copy of an API object with sensitive fields removed."""
53
+ if isinstance(obj, list):
54
+ return [_public(item) for item in obj]
55
+ if isinstance(obj, dict):
56
+ return {k: v for k, v in obj.items() if k not in _SENSITIVE_FIELDS}
57
+ return obj
58
+
59
+
60
+ def _safe_detail(resp: httpx.Response) -> str:
61
+ """Extract a human-readable error message without echoing secrets."""
62
+ try:
63
+ data = resp.json()
64
+ except ValueError:
65
+ text = resp.text.strip()
66
+ return text[:200] if text else f"HTTP {resp.status_code}"
67
+ if isinstance(data, dict):
68
+ for key in ("error", "message", "errors"):
69
+ if key in data:
70
+ return str(data[key])
71
+ return f"HTTP {resp.status_code}"
72
+
73
+
74
+ def _form_value(value: Any) -> str:
75
+ if isinstance(value, bool):
76
+ return "true" if value else "false"
77
+ return str(value)
78
+
79
+
80
+ def _open_files(file_paths: list[str], field: str) -> tuple[list, list]:
81
+ """Open local files for multipart upload. Caller must close the handles."""
82
+ files: list[tuple[str, Any]] = []
83
+ handles: list[Any] = []
84
+ try:
85
+ for raw in file_paths:
86
+ path = Path(raw).expanduser()
87
+ if not path.is_file():
88
+ raise PwpushError(f"file not found: {raw}")
89
+ handle = path.open("rb")
90
+ handles.append(handle)
91
+ ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
92
+ files.append((field, (path.name, handle, ctype)))
93
+ except Exception:
94
+ for handle in handles:
95
+ handle.close()
96
+ raise
97
+ return files, handles
98
+
99
+
100
+ # Status codes worth retrying with backoff: rate limiting and transient
101
+ # upstream/server errors. 4xx other than 429 are caller errors — never retried.
102
+ _RETRYABLE_STATUS: frozenset[int] = frozenset({429, 500, 502, 503, 504})
103
+
104
+
105
+ class PwpushClient:
106
+ def __init__(self, config: Config, *, timeout: float | None = None) -> None:
107
+ self._config = config
108
+ self._timeout = timeout if timeout is not None else config.timeout
109
+ self._verify = config.verify
110
+ self._max_retries = config.max_retries
111
+ self._version: str | None = (
112
+ config.api_version if config.api_version in ("v1", "v2") else None
113
+ )
114
+ # Lazily created on first use so it is bound to the running event loop.
115
+ self._semaphore: asyncio.Semaphore | None = None
116
+
117
+ def _limiter(self) -> asyncio.Semaphore | None:
118
+ """Return the concurrency semaphore, creating it on first use.
119
+
120
+ ``max_concurrent == 0`` means unlimited, so no semaphore is used.
121
+ """
122
+ if self._config.max_concurrent <= 0:
123
+ return None
124
+ if self._semaphore is None:
125
+ self._semaphore = asyncio.Semaphore(self._config.max_concurrent)
126
+ return self._semaphore
127
+
128
+ def _new_client(self) -> httpx.AsyncClient:
129
+ """Build an AsyncClient with transport-level connection retries.
130
+
131
+ ``httpx`` retries only connection establishment errors here; HTTP-level
132
+ retries (429 / 5xx) are handled explicitly in :meth:`_send` so we can
133
+ honour ``Retry-After`` and apply jittered backoff.
134
+ """
135
+ transport = httpx.AsyncHTTPTransport(retries=self._max_retries)
136
+ return httpx.AsyncClient(timeout=self._timeout, verify=self._verify, transport=transport)
137
+
138
+ @staticmethod
139
+ def _backoff_seconds(resp: httpx.Response, attempt: int) -> float:
140
+ """Backoff delay: honour ``Retry-After`` on 429, else jittered expo."""
141
+ if resp.status_code == 429:
142
+ raw = resp.headers.get("Retry-After")
143
+ if raw:
144
+ try:
145
+ return min(float(raw), 30.0)
146
+ except ValueError:
147
+ pass
148
+ return min(2.0**attempt + random.random(), 30.0)
149
+
150
+ # -- Version detection --------------------------------------------------
151
+
152
+ async def _detect_version(self) -> str:
153
+ if self._version:
154
+ return self._version
155
+ url = f"{self._config.base_url}/api/v2/version.json"
156
+ try:
157
+ async with self._new_client() as client:
158
+ resp = await client.get(url, headers={"Accept": "application/json"})
159
+ except httpx.RequestError as exc:
160
+ raise PwpushError(f"network error contacting {self._config.base_url}: {exc}") from exc
161
+ # v2 instances serve this route (200, no auth); v1 instances 404 it.
162
+ self._version = "v2" if resp.status_code != 404 else "v1"
163
+ return self._version
164
+
165
+ # -- Low-level transport ------------------------------------------------
166
+
167
+ def _headers(self, version: str, *, require_auth: bool) -> dict[str, str]:
168
+ headers = {
169
+ "Accept": "application/json",
170
+ "User-Agent": f"pwpush-mcp/{__version__}",
171
+ }
172
+ token = self._config.api_token
173
+ if version == "v2":
174
+ if token:
175
+ headers["Authorization"] = f"Bearer {token}"
176
+ elif require_auth:
177
+ raise PwpushError(self._auth_hint())
178
+ else: # v1
179
+ if token:
180
+ headers["X-User-Token"] = token
181
+ if self._config.api_email:
182
+ headers["X-User-Email"] = self._config.api_email
183
+ elif require_auth:
184
+ raise PwpushError(self._auth_hint())
185
+ return headers
186
+
187
+ def _auth_hint(self) -> str:
188
+ return (
189
+ "This operation requires authentication, but PWPUSH_API_TOKEN is not "
190
+ "set. Generate a token at "
191
+ f"{self._config.base_url}/api_tokens and set the env var "
192
+ "(v1 instances also need PWPUSH_API_EMAIL)."
193
+ )
194
+
195
+ async def _send(
196
+ self,
197
+ method: str,
198
+ path: str,
199
+ version: str,
200
+ *,
201
+ require_auth: bool,
202
+ json: dict[str, Any] | None = None,
203
+ params: dict[str, Any] | None = None,
204
+ data: dict[str, Any] | None = None,
205
+ files: list[tuple[str, Any]] | None = None,
206
+ ) -> Any:
207
+ url = f"{self._config.base_url}{path}"
208
+ headers = self._headers(version, require_auth=require_auth)
209
+ limiter = self._limiter()
210
+
211
+ # Application-level retry loop for 429 / 5xx. Multipart uploads carry
212
+ # open file handles positioned at EOF after the first send, so they are
213
+ # not retried (file pushes are rare and one-shot by nature).
214
+ attempts = 1 if files else self._max_retries + 1
215
+ resp: httpx.Response | None = None
216
+ for attempt in range(attempts):
217
+ try:
218
+ if limiter is not None:
219
+ async with limiter, self._new_client() as client:
220
+ resp = await client.request(
221
+ method,
222
+ url,
223
+ headers=headers,
224
+ json=json,
225
+ params=params,
226
+ data=data,
227
+ files=files,
228
+ )
229
+ else:
230
+ async with self._new_client() as client:
231
+ resp = await client.request(
232
+ method,
233
+ url,
234
+ headers=headers,
235
+ json=json,
236
+ params=params,
237
+ data=data,
238
+ files=files,
239
+ )
240
+ except httpx.RequestError as exc:
241
+ raise PwpushError(
242
+ f"network error contacting {self._config.base_url}: {exc}"
243
+ ) from exc
244
+
245
+ if resp.status_code in _RETRYABLE_STATUS and attempt < attempts - 1:
246
+ await asyncio.sleep(self._backoff_seconds(resp, attempt))
247
+ continue
248
+ break
249
+
250
+ assert resp is not None # loop always assigns or raises
251
+
252
+ if resp.status_code == 429:
253
+ retry = resp.headers.get("Retry-After", "unknown")
254
+ raise PwpushError(f"rate limited (429); retry after {retry}s")
255
+ if resp.status_code == 401:
256
+ raise PwpushError(
257
+ "unauthorized (401): this instance/operation requires valid "
258
+ "credentials (PWPUSH_API_TOKEN, plus PWPUSH_API_EMAIL on v1)"
259
+ )
260
+ if resp.status_code == 403:
261
+ raise PwpushError("forbidden (403): the credentials lack permission for this action")
262
+ if resp.status_code == 404:
263
+ raise PwpushError("not found (404): unknown url_token, or the push has expired")
264
+ if resp.status_code >= 400:
265
+ raise PwpushError(f"API error {resp.status_code}: {_safe_detail(resp)}")
266
+
267
+ if not resp.content:
268
+ return {}
269
+ try:
270
+ return resp.json()
271
+ except ValueError as exc:
272
+ raise PwpushError("API returned a non-JSON response") from exc
273
+
274
+ # -- Operations ---------------------------------------------------------
275
+
276
+ async def create_push(
277
+ self,
278
+ *,
279
+ payload: str | None,
280
+ kind: str,
281
+ duration: str,
282
+ expire_after_views: int,
283
+ passphrase: str | None,
284
+ name: str | None,
285
+ note: str | None,
286
+ deletable_by_viewer: bool,
287
+ retrieval_step: bool,
288
+ file_paths: list[str] | None = None,
289
+ ) -> dict[str, Any]:
290
+ version = await self._detect_version()
291
+ if version == "v2":
292
+ return await self._create_v2(
293
+ payload=payload,
294
+ kind=kind,
295
+ duration=duration,
296
+ expire_after_views=expire_after_views,
297
+ passphrase=passphrase,
298
+ name=name,
299
+ note=note,
300
+ deletable_by_viewer=deletable_by_viewer,
301
+ retrieval_step=retrieval_step,
302
+ file_paths=file_paths,
303
+ )
304
+ return await self._create_v1(
305
+ payload=payload,
306
+ kind=kind,
307
+ duration=duration,
308
+ expire_after_views=expire_after_views,
309
+ passphrase=passphrase,
310
+ deletable_by_viewer=deletable_by_viewer,
311
+ retrieval_step=retrieval_step,
312
+ file_paths=file_paths,
313
+ )
314
+
315
+ async def _create_v2(self, **k: Any) -> dict[str, Any]:
316
+ push: dict[str, Any] = {
317
+ "expire_after_duration": resolve_duration(k["duration"]),
318
+ "expire_after_views": k["expire_after_views"],
319
+ "deletable_by_viewer": k["deletable_by_viewer"],
320
+ "retrieval_step": k["retrieval_step"],
321
+ }
322
+ if k["payload"]:
323
+ push["payload"] = k["payload"]
324
+ if k["passphrase"]:
325
+ push["passphrase"] = k["passphrase"]
326
+ if k["name"]:
327
+ push["name"] = k["name"]
328
+ if k["note"]:
329
+ push["note"] = k["note"]
330
+
331
+ if k["file_paths"]:
332
+ form = {f"push[{key}]": _form_value(val) for key, val in push.items()}
333
+ form["push[kind]"] = "file"
334
+ files, handles = _open_files(k["file_paths"], "push[files][]")
335
+ try:
336
+ data = await self._send(
337
+ "POST", "/api/v2/pushes.json", "v2", require_auth=False, data=form, files=files
338
+ )
339
+ finally:
340
+ for handle in handles:
341
+ handle.close()
342
+ else:
343
+ push["kind"] = k["kind"]
344
+ data = await self._send(
345
+ "POST", "/api/v2/pushes.json", "v2", require_auth=False, json={"push": push}
346
+ )
347
+ return _public(data)
348
+
349
+ async def _create_v1(self, **k: Any) -> dict[str, Any]:
350
+ days = resolve_days(k["duration"])
351
+ common = {
352
+ "expire_after_views": k["expire_after_views"],
353
+ "expire_after_days": days,
354
+ "deletable_by_viewer": k["deletable_by_viewer"],
355
+ "retrieval_step": k["retrieval_step"],
356
+ }
357
+ if k["passphrase"]:
358
+ common["passphrase"] = k["passphrase"]
359
+
360
+ if k["file_paths"]:
361
+ form = {f"file[{key}]": _form_value(val) for key, val in common.items()}
362
+ if k["payload"]:
363
+ form["file[payload]"] = k["payload"]
364
+ files, handles = _open_files(k["file_paths"], "file[files][]")
365
+ try:
366
+ data = await self._send_v1_typed(
367
+ "/f.json", "file pushes", require_auth=False, data=form, files=files
368
+ )
369
+ finally:
370
+ for handle in handles:
371
+ handle.close()
372
+ elif k["kind"] == "url":
373
+ body = {"url": {"payload": k["payload"], **common}}
374
+ data = await self._send_v1_typed("/r.json", "URL pushes", require_auth=False, json=body)
375
+ else: # text (qr is not a distinct v1 endpoint; treated as text)
376
+ body = {"password": {"payload": k["payload"], **common}}
377
+ data = await self._send("POST", "/p.json", "v1", require_auth=False, json=body)
378
+ return _public(data)
379
+
380
+ async def _send_v1_typed(self, path: str, feature: str, **kw: Any) -> Any:
381
+ """POST to a v1 typed-push endpoint, mapping 404 to a feature hint."""
382
+ try:
383
+ return await self._send("POST", path, "v1", **kw)
384
+ except PwpushError as exc:
385
+ if "404" in str(exc):
386
+ raise FeatureDisabledError(
387
+ f"{feature} are not enabled on this instance ({path} returned 404)"
388
+ ) from exc
389
+ raise
390
+
391
+ async def preview_push(self, url_token: str) -> dict[str, Any]:
392
+ version = await self._detect_version()
393
+ if version == "v2":
394
+ return await self._send(
395
+ "GET", f"/api/v2/pushes/{url_token}/preview.json", "v2", require_auth=False
396
+ )
397
+ return await self._send("GET", f"/p/{url_token}/preview.json", "v1", require_auth=False)
398
+
399
+ async def expire_push(self, url_token: str) -> dict[str, Any]:
400
+ version = await self._detect_version()
401
+ if version == "v2":
402
+ data = await self._send(
403
+ "DELETE", f"/api/v2/pushes/{url_token}.json", "v2", require_auth=False
404
+ )
405
+ else:
406
+ data = await self._send("DELETE", f"/p/{url_token}.json", "v1", require_auth=False)
407
+ return _public(data)
408
+
409
+ async def push_audit(self, url_token: str, *, page: int = 1) -> Any:
410
+ version = await self._detect_version()
411
+ if version == "v2":
412
+ return await self._send(
413
+ "GET",
414
+ f"/api/v2/pushes/{url_token}/audit.json",
415
+ "v2",
416
+ require_auth=True,
417
+ params={"page": page},
418
+ )
419
+ return await self._send("GET", f"/p/{url_token}/audit.json", "v1", require_auth=True)
420
+
421
+ async def list_pushes(self, state: str, *, page: int = 1) -> Any:
422
+ if state not in ("active", "expired"):
423
+ raise PwpushError("state must be 'active' or 'expired'")
424
+ version = await self._detect_version()
425
+ if version == "v2":
426
+ data = await self._send(
427
+ "GET",
428
+ f"/api/v2/pushes/{state}.json",
429
+ "v2",
430
+ require_auth=True,
431
+ params={"page": page},
432
+ )
433
+ else:
434
+ data = await self._send("GET", f"/p/{state}.json", "v1", require_auth=True)
435
+ return _public(data)
436
+
437
+ async def version(self) -> dict[str, Any]:
438
+ detected = await self._detect_version()
439
+ if detected == "v2":
440
+ data = await self._send("GET", "/api/v2/version.json", "v2", require_auth=False)
441
+ if isinstance(data, dict):
442
+ data.setdefault("detected_api_version", "v2")
443
+ return data
444
+ # v1 instances expose no version endpoint.
445
+ return {
446
+ "detected_api_version": "v1",
447
+ "note": "legacy instance; no /version endpoint is exposed",
448
+ }