Metadata-Version: 2.5
Name: spaps
Version: 0.8.2
Summary: Python client for the hosted Sweet Potato / SPAPS API
Project-URL: Homepage, https://sweetpotato.dev
Project-URL: Documentation, https://docs.sweetpotato.dev
Author-email: buildooor <buildooor@gmail.com>
License: SWEET POTATO / SPAPS PROPRIETARY LICENSE NOTICE
        
        Copyright (c) 2025-2026 build000r. All rights reserved.
        
        Sweet Potato / SPAPS, including its source code, documentation, first-party
        packages, schemas, designs, and related materials (collectively, the
        "Software"), is proprietary.
        
        No license or permission is granted to use, copy, modify, merge, publish,
        distribute, sublicense, sell, host, operate, reverse engineer, expose as a
        service, or create derivative works from the Software except as expressly
        authorized by a separate written agreement with build000r.
        
        Authorized customers may use first-party client packages and documentation
        solely to integrate with the hosted Sweet Potato / SPAPS service while their
        paid subscription remains active and subject to the applicable service terms.
        Possession of source code, a package artifact, a container image, documentation,
        or an API credential does not grant ownership of the Software or a right to
        deploy, redistribute, resell, or operate the SPAPS backend.
        
        Third-party software included with or used by the Software remains governed by
        its own license terms.
        
        Versions of any material that were previously distributed with a different
        license remain governed by the license that accompanied those versions. This
        notice does not retroactively revoke rights that were already granted under an
        earlier license.
        
        THE SOFTWARE AND HOSTED SERVICE ARE PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT
        WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
        OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
        NONINFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY LAW, BUILD000R SHALL NOT BE
        LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM OR RELATED TO THE
        SOFTWARE, THE HOSTED SERVICE, OR THEIR USE.
License-File: LICENSE
Keywords: authentication,hosted-api,payments,spaps,sweet-potato
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Requires-Python: >=3.9
Requires-Dist: httpx<0.29.0,>=0.27.0
Requires-Dist: pydantic<3.0.0,>=2.7.0
Provides-Extra: dev
Requires-Dist: build<2.0.0,>=1.2.1; extra == 'dev'
Requires-Dist: httpx<0.29.0,>=0.27.0; extra == 'dev'
Requires-Dist: mypy<2.0.0,>=1.10.0; extra == 'dev'
Requires-Dist: pydantic<3.0.0,>=2.7.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: respx<0.23.0,>=0.22.0; extra == 'dev'
Requires-Dist: ruff==0.15.17; extra == 'dev'
Requires-Dist: tomli<3.0.0,>=2.0.1; extra == 'dev'
Description-Content-Type: text/markdown

# spaps (Python client)

Python SDK for SPAPS-compatible APIs. The distribution name is `spaps`; the import path is `spaps_client`.

Examples in this README use placeholders such as `user@example.com`, `admin@example.com`, `spaps_test_key`, and `https://api.example.test`. Replace them with values from your own deployment.

## Install

```bash
pip install spaps
```

This package targets Python 3.9+.

## When It Fits

| Need | Package gives you |
| --- | --- |
| Sync and async clients | `SpapsClient` and `AsyncSpapsClient` with a similar surface |
| Typed responses | Pydantic models for auth, sessions, payments, entitlements, webhooks, and more |
| HTTP control points | Retry config, logging hooks, injected `httpx` clients, and custom token storage |
| Narrow integrations | Standalone helpers for device flow, webhooks, users, email, entitlements, marketing, and permission checks |

## Quick Start

```python
from spaps_client import MfaRequiredChallenge, SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    login = client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    if isinstance(login, MfaRequiredChallenge):
        tokens = client.auth.mfa_verify(
            challenge_id=login.challenge_id,
            challenge=login.challenge,
            code="123456",
        )
    else:
        tokens = login

    bootstrap = client.auth.get_session_context()
    session = client.sessions.get_current_session()
    products = client.payments.list_products(
        category="subscription",
        active=True,
        limit=5,
    )

    print(tokens.user.email)
    print(bootstrap.tier)
    print(session.session_id)
    print(products.total)
finally:
    client.close()
```

## Async Example

```python
import asyncio

from spaps_client import AsyncSpapsClient


async def main() -> None:
    client = AsyncSpapsClient(
        base_url="http://localhost:3301",
        api_key="spaps_test_key",
    )
    try:
        await client.auth.sign_in_with_password(
            email="user@example.com",
            password="correct-horse-battery-staple",
        )
        sessions = await client.sessions.list_sessions()
        print(sessions.total)
    finally:
        await client.aclose()


asyncio.run(main())
```

Sync and async auth calls raise `AuthError` for HTTP failures, including
malformed upstream error bodies. Use `status_code`, `error_code`, and
`request_id` for handling. Valid structured responses retain provider messages
and details; malformed and plain-text server responses use a generic message.

Sync and async `sessions.list_sessions()` use `limit` and zero-based `offset`
for pagination. The server defaults to 50 sessions per page.
Session calls raise `SessionError` for malformed HTTP responses, including
non-object 2xx bodies. Valid structured errors retain their code and message;
malformed or plain-text server errors use a generic message.

Sync and async whitelist calls raise `WhitelistError` for malformed HTTP
responses, including non-object 2xx bodies. Valid structured errors keep their
provider code and message; malformed or plain-text server errors use a generic
message.

## Modern Auth Methods

The auth client exposes the active SPAPS auth routes in both sync and async forms:

```python
from spaps_client import MfaRequiredChallenge, SpapsClient

client = SpapsClient(base_url="http://localhost:3301", api_key="spaps_test_key")

methods = client.auth.list_methods()
print([item.method for item in methods.methods if item.enabled])

oidc = client.auth.oidc_nonce()
login = client.auth.oidc_sign_in(
    provider="google",
    id_token="provider-id-token",
    challenge_id=oidc.challenge_id,
)

if isinstance(login, MfaRequiredChallenge):
    login = client.auth.mfa_verify(
        challenge_id=login.challenge_id,
        challenge=login.challenge,
        recovery_code="recovery-code",
    )
```

TOTP management and passkey registration are authenticated operations and use stored tokens unless you pass `access_token` explicitly:

```python
enrollment = client.auth.mfa_totp_enroll()
activation = client.auth.mfa_totp_activate(code="123456")
options = client.auth.webauthn_register_options(origin="https://app.example.com")
credential = client.auth.webauthn_register_verify(
    challenge_id=options.challenge_id,
    credential={"id": "browser-produced-credential"},
    origin="https://app.example.com",
)
```

The sync and async auth clients also cover passkey-first bootstrap, credential listing/labeling/revocation, application passkey profiles, action-bound step-up, user recovery, and administrator recovery preparation/execution. Pass `origin` on ceremonies so SPAPS can validate the browser origin. Public ceremonies send only `X-API-Key`; lifecycle, profile, step-up, and administrator operations also require a bearer token, using an explicit `access_token` before stored credentials.

Python coordinates the server half of each WebAuthn ceremony. It returns typed registration or assertion options and accepts the credential JSON produced by a browser or another WebAuthn-capable user agent. It does not call `navigator.credentials`, emulate an authenticator, perform CTAP, or sign challenges. Recovery registration rotates the recovery grant; it does not issue login tokens until the new credential completes the recovery assertion.

## Core Surface

### High-Level Clients

| Client | Best for |
| --- | --- |
| `SpapsClient` | Sync auth, sessions, payments, usage, access decisions, capability graph inspection, whitelist, secure messages, issue reporting, marketing, metrics, app links, dayrate, skill evals, token gates, and support telemetry |
| `AsyncSpapsClient` | Async auth, sessions, payments, usage, whitelist, secure messages, issue reporting, metrics, entitlements, app links, dayrate, skill evals, token gates, and users |

### Standalone Helpers

| Helper | Purpose |
| --- | --- |
| `EntitlementsClient` / `AsyncEntitlementsClient` | Resource entitlements, purchase history, manual grants/revokes, and project grants |
| `TokenGatesClient` / `AsyncTokenGatesClient` | Public enabled-gate list and user JWT verify; `UNVERIFIABLE` is unknown, not no |
| `UsageClient` / `AsyncUsageClient` | Secret-key usage authorization and immutable usage recording |
| `CapabilityClient` | Access decisions, action preparation, decision traces, graph inspection, and contract discovery |
| `EmailClient` / `AsyncEmailClient` | Template lookup, preview, and send flows |
| `UsersClient` / `AsyncUsersClient` | Batch user/email lookups, secret-key membership decisions, and app membership administration |
| `MarketingClient` | Browser-safe marketing event emission and server-side experiment results |
| `AppLinksClient` / `AsyncAppLinksClient` | Authenticated short-link creation/update and public link resolution |
| `DayrateClient` / `AsyncDayrateClient` | Availability, booking, cancellation, x402 booking holds, and admin booking reads |
| `DeviceFlowClient` | Device-code login workflows |
| `PermissionChecker` | Role and admin convenience checks |
| `verify_spaps_webhook` | Signature verification for incoming SPAPS webhooks |

Public app-link lookups omit `owner_user_id`; `AppLink.owner_user_id` is `None`
for those results and is present on authenticated create/update results. Sync
and async app-link calls raise `AppLinkError` for non-JSON or non-object HTTP responses;
malformed server errors use a generic message and retain the HTTP status.

Sync and async `dayrate.cancel_booking()` require the booking owner's access
token, stored on the client or passed as `access_token_override`. The legacy
`user_id` and `email` arguments remain accepted but are omitted from the
request: the server establishes ownership from the bearer token.

### Device-code login

`DeviceFlowClient` is an async public-client flow. Pass the SPAPS application
slug as `client_id`, or set `SPAPS_CLI_CLIENT_ID`. The authorize and token
requests are keyless: they send `client_id`, the RFC 8628 device-code grant
type, and the current protocol fields, but never `X-API-Key` or a bearer token.

```python
import asyncio

from spaps_client import DeviceFlowClient


async def device_login() -> None:
    client = DeviceFlowClient(
        base_url="https://api.example.test",
        client_id="my-app",
    )
    try:
        tokens = await client.login()
        print(tokens.expires_in)
    finally:
        await client.aclose()


asyncio.run(device_login())
```

`login()` displays the server-provided `verification_uri_complete` when
available, so the application-specific page can prefill the user code. The
older `api_key=` constructor keyword remains accepted for migration safety but
is ignored by device authorize/token calls; it cannot substitute for
`client_id`.

Device-flow HTTP failures raise `DeviceFlowError` even when an upstream error
body is malformed. Handle `status_code` and `error_code`; server-side failures
use a generic exception message instead of copying upstream response text.

## Configuration

Constructor values override package defaults.

```python
from spaps_client import SpapsClient, RetryConfig, default_logging_hooks

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    retry_config=RetryConfig(max_attempts=4, backoff_factor=0.2),
    logging_hooks=default_logging_hooks(),
)
```

Common parameters:

| Parameter | Purpose |
| --- | --- |
| `base_url` | Target API origin |
| `api_key` | Application or service API key |
| `request_timeout` | Per-request timeout |
| `token_storage` | Custom token persistence backend |
| `http_client` | Injected `httpx.Client` or `httpx.AsyncClient` |
| `retry_config` | Retry and backoff policy |
| `logging_hooks` | Structured request and response logging callbacks |

`default_logging_hooks()` records method, host, status, timing, attempt, and
error type. It omits URL paths and queries, URL credentials, response request
IDs, and exception messages because these can contain private data. Custom
logging hooks must apply their own redaction.

Requests that cannot be retried keep their streaming bodies; the SDK does not
buffer them before the first send. For retryable streamed bodies, it spools up
to 1 MiB in memory, then uses a temporary file and sends 64 KiB chunks on each
attempt. The spool is removed after the final attempt; large retries need
temporary disk space.

## Common Flows

### Agent Access Decisions

`client.access.check_access(...)` returns a typed decision for both allowed and
denied outcomes. A blocked action is not an exception; inspect `allowed`,
`outcome`, and `next_actions`.
Checkout clients reject non-boolean authority flags and contradictory
`allowed`/`outcome` or action-execution gates. Malformed response objects raise
`CapabilityError`; unstructured error bodies do not become exception text.

```python
from spaps_client import AccessDecisionRequest, CapabilityError, SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)
client.set_tokens(access_token="admin-access-token")

try:
    decision = client.access.check_access(
        AccessDecisionRequest.model_validate(
            {
                "actor": {"actor_type": "user", "actor_ref": "user_123"},
                "action": "checkout.create",
                "resource": {"resource_type": "product", "resource_ref": "dayrate"},
                "controls": {"entitlement_key": "bookme_paid"},
            }
        )
    )

    if not decision.allowed:
        print(decision.outcome, decision.next_actions)

    prepared = client.access.prepare_action(
        {
            "access": {
                "actor": {"actor_ref": "admin_123"},
                "action": "admin.delete_user",
                "resource": {"resource_type": "user", "resource_ref": "user_123"},
            },
            "include_command_templates": True,
            "environment": "production",
        }
    )
    print(prepared.status, prepared.execution.operator_gate_required)

    contract = client.contract.get_contract()
    refresh = client.graph.refresh_graph(correlation_id="local-refresh")
    nodes = client.graph.list_graph_nodes(node_type="x402_resource", q="dayrate")
    explanation = client.access.explain_decision(decision.decision_trace_id)
    print(contract.version, refresh.status, refresh.diagnostics.phase2_gate.recommendation if refresh.diagnostics.phase2_gate else None)
    print(nodes.projection.projection_status if nodes.projection else None, explanation.graph_node_keys)
except CapabilityError as exc:
    print(exc.status_code, exc.code, exc.request_id, exc.diagnostics, exc.remediations)
finally:
    client.close()
```

`client.contract.get_contract()` includes graph vocabulary fields:
`graph_node_types`, `graph_edge_types`, `graph_source_domains`, and
`source_domain_notes`. Use them before filtering graph nodes by stable v1 types
such as `wallet`, `api_key`, `role`, and `approver`.

`CapabilityError` exposes both Python-native names (`status_code`,
`error_code`, `request_id`) and parity aliases (`status`, `code`). Diagnostics
and remediations preserve server-provided `details` fields.

`operator-gated` is compatibility/descriptive input when a client supplies it;
it does not authorize mutation command templates. SPAPS returns mutation
command templates only when the request has a server-recognized non-publishable
key and an authenticated admin/operator user context. Publishable callers
cannot self-attest the gate.

### Magic Links and Password Reset

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="http://localhost:3301",
    api_key="spaps_test_key",
)

try:
    client.auth.send_magic_link(email="user@example.com")
    reset_request = client.auth.request_password_reset(email="user@example.com")
    reset_confirm = client.auth.confirm_password_reset(
        token="reset-token-from-email",
        new_password="Sup3rStrong!",
    )
    print(reset_request.email, reset_request.sent_at, reset_confirm.message)
finally:
    client.close()
```

Password reset responses use the standard SPAPS success envelope with top-level
`success`, `data`, `request_id`, and `timestamp`. The Python client returns the
nested `data` payload: reset requests expose `email` and `sent_at`, while reset
confirmation exposes the nested `message`. A top-level English `message` is not
canonical and should not drive UI copy. Prefer app-owned success copy, or map
future nested `data.message_key`/`data.status` values in your app.

#### Downstream Adoption

HTMA and future SPAPS consumers should use `spaps>=0.6.2` for password reset
flows and stream-aware email sends. Version `0.6.2` is available on PyPI; the
passkey-complete `0.7.0` line remains a retained candidate until publication.

Release execution is intentionally separate from this handoff. Publish only the
retained exact artifact from the package self-release lane. Do not use
`npm run publish:python-client`, deploy SPAPS, or publish the package as part of
a consumer-only adoption check.

### Authenticated Stripe Checkout

`create_checkout_session` is the convenience path for authenticated checkout.
Pass the server-managed Stripe `price_id`; the client sends the active Stripe
checkout contract with one line item for that price.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_test_key",
)

try:
    client.auth.sign_in_with_password(
        email="user@example.com",
        password="correct-horse-battery-staple",
    )
    checkout = client.payments.create_checkout_session(
        price_id="price_123",
        mode="subscription",
        success_url="https://app.example.test/success",
        cancel_url="https://app.example.test/cancel",
        trial_period_days=14,
    )
    print(checkout.checkout_url)
finally:
    client.close()
```

### Server-Side Usage Control

Use `usage` from a trusted backend with a secret SPAPS key. Browser apps should
call their own backend first; that backend asks SPAPS to authorize the work,
runs the work only on an allow or warning decision, then records the actual
usage with a stable idempotency key.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    authorization = client.usage.authorize_usage(
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={"requests": 1, "input_tokens": 1200},
    )
    if authorization.decision == "blocked":
        message = authorization.reasons[0].message if authorization.reasons else "Usage not authorized"
        raise RuntimeError(message)

    result = run_assistant_job()

    client.usage.record_usage(
        authorization_ref=authorization.authorization_ref,
        idempotency_key=result.job_id,
        feature_key="assistant_tokens",
        resource_type="company",
        resource_id="company_123",
        subject_user_id="user_123",
        dimensions={
            "input_tokens": result.input_tokens,
            "output_tokens": result.output_tokens,
        },
        metadata={"job_id": result.job_id},
    )
finally:
    client.close()
```

For dashboard and policy views, use `client.usage.get_features()`,
`client.usage.get_status(...)`, and `client.usage.get_history(...)` from the
same trusted backend context.

### Account Memberships and Project Grants

Use these helpers from a trusted backend with a secret SPAPS key. Membership
decisions need the secret key but no admin JWT. Admin mutations require an
admin access token; do not call membership invitation, project grant, or
project revoke helpers from browser code.

```python
from spaps_client import EntitlementsClient, UsersClient

admin_token = "admin-access-token"

users = UsersClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token=admin_token,
)
entitlements = EntitlementsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token=admin_token,
)

try:
    membership = users.memberships.decide(
        "user_123",
        grant_allowed=True,
    )
    if membership.decision == "deny":
        raise RuntimeError(", ".join(membership.reasons))

    users.memberships.add_account_user(
        email="teammate@example.com",
        capabilities={"projects": ["project_123"]},
        metadata={"invited_by": "admin@example.com"},
    )
    entitlements.project_grants.grant_user(
        project_id="project_123",
        entitlement_key="pds.project.viewer",
        email="teammate@example.com",
        reason="Project teammate invite",
    )

    project_users = entitlements.project_grants.list_project_users(
        "project_123",
        entitlement_key="pds.project.viewer",
    )
    user_projects = entitlements.project_grants.list_user_projects("user_123")
    access = entitlements.project_grants.check_access(
        project_id="project_123",
        entitlement_key="pds.project.viewer",
        user_id="user_123",
    )
    print(project_users.count, user_projects.count, access.has_access)
finally:
    users.close()
    entitlements.close()
```

### Token gates

`list_gates()` is API-key only. `verify_gates()` needs the secret key and the
user JWT. `UNVERIFIABLE` means the server could not check holdings — unknown,
not no. Concepts and presets: `docs/guides/token-gates.md` and
`docs/guides/token-gates-cookbook.md`.

```python
from spaps_client import (
    TokenGateAccessKind,
    TokenGatesClient,
    token_gate_access_decision,
)

gates = TokenGatesClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
    access_token="user-jwt",
)
try:
    listed = gates.list_gates()
    verified = gates.verify_gates(entitlement_key="fwa_holder")
    for row in verified.results:
        decision = token_gate_access_decision(row)
        if decision.kind is TokenGateAccessKind.ALLOW:
            entitled = True
        elif decision.kind is TokenGateAccessKind.DENY:
            entitled = False
        elif decision.kind is TokenGateAccessKind.UNVERIFIABLE:
            entitled = None
        else:
            raise RuntimeError(f"unknown token-gate access kind: {decision.kind}")
    print(listed.count, entitled)
finally:
    gates.close()
```

### Marketing Events

Use `client.marketing.emit_event(...)` with a publishable key to record anonymous
attribution touches or experiment exposures. Use a secret key from a trusted
server or agent to read experiment results and the current conservative stop
signal.

```python
from spaps_client import SpapsClient

browser_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    browser_client.marketing.emit_event(
        anon_id="anon_01HY...",
        event_type="experiment_exposure",
        experiment_id="landing-hero-copy",
        variant_id="treatment",
        dedupe_key="landing-hero-copy:anon_01HY:treatment",
    )
finally:
    browser_client.close()

agent_client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_sec_example",
)

try:
    results = agent_client.marketing.get_experiment_results("landing-hero-copy")
    print(results.decision.recommendation, results.decision.winner_variant_id)
finally:
    agent_client.close()
```

### Permission Checks With Explicit Admin Config

```python
from spaps_client import PermissionChecker

checker = PermissionChecker(customAdmins=["admin@example.com"])
role = checker.getRole("operator@example.com")

if checker.requiresAdmin({"email": "operator@example.com"}):
    raise PermissionError(
        checker.getErrorMessage("admin", role, action="change billing settings")
    )
```

### Issue Reporting Voice Token

Voice issue reporting uses a short-lived SPAPS token for ElevenLabs Scribe. Keep the ElevenLabs API key on the SPAPS server.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    voice_token = client.issue_reporting.create_voice_token()
    print(voice_token.provider, voice_token.model_id)
finally:
    client.close()
```

### Issue Reporting Screenshot Attachments

Screenshots are uploaded as private pending hosted assets first. Create, update, or reply calls then attach those IDs; raw image bytes, data URLs, and base64 payloads should not be stored in issue notes or target metadata.

```python
from pathlib import Path

from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    client.set_tokens(access_token="end-user-access-token")
    attachment = client.issue_reporting.upload_issue_report_attachment(
        file=Path("protocol-save-failure.png").read_bytes(),
        filename="protocol-save-failure.png",
        mime_type="image/png",
    )

    issue = client.issue_reporting.create_issue_report(
        target={
            "component_key": "patient_protocol_widget",
            "component_label": "Patient Protocol Widget",
            "page_url": "/patients/123/protocol",
            "surface_ref": "daily-log",
            "metadata": {"section": "daily log"},
        },
        note="The save action silently fails after I edit today's protocol note.",
        reporter_role_hint="practitioner",
        attachment_ids=[attachment.id],
    )

    access = client.issue_reporting.get_issue_report_attachment_access(
        attachment_id=attachment.id,
    )
    print(issue.id, access.expires_in_seconds)
finally:
    client.close()
```

SPAPS accepts PNG, JPEG, and WebP screenshots up to 10 MiB each, with at most 5 retained screenshots per report. The hosted object remains private; callers fetch a short-lived access URL after normal issue-reporting authorization succeeds. SPAPS does not redact screenshot contents, so host apps should warn users when a capture may include sensitive data.

### Skill Evals

Skill eval helpers wrap the SPAPS blind review endpoints for agent-skill logs. Paid case creation accepts a `PAYMENT-SIGNATURE` through `payment_signature`. Reviewers submit `valuable` and `not_valuable` marks, and submitters read those marks through an insight inbox before applying skill changes.

```python
from spaps_client import SpapsClient

client = SpapsClient(
    base_url="https://api.example.test",
    api_key="spaps_pub_example",
)

try:
    created = client.skill_evals.create_case(
        {
            "title": "Docs skill comparison",
            "task_claim": "Compare both implementations.",
            "success_criteria": ["Finds repo boundaries"],
            "candidates": [
                {
                    "candidate_id": "A",
                    "output_ref": "spaps-artifact://case/a",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/a.jsonl",
                    "jsonl_log_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.0",
                    "skill_version_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/a",
                },
                {
                    "candidate_id": "B",
                    "output_ref": "spaps-artifact://case/b",
                    "evidence_summary": "Validation passed",
                    "artifact_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "artifact_mime": "text/markdown",
                    "jsonl_log_ref": "spaps-artifact://logs/b.jsonl",
                    "jsonl_log_hash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
                    "skill_ref": "skill://docs-review",
                    "skill_version_ref": "skill://docs-review/v2.1",
                    "skill_version_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
                    "model_id": "openai/gpt-5.4",
                    "effort_level": "medium",
                    "provenance_ref": "skill://private/b",
                },
            ],
            "case_policy": {
                "access_mode": "team_private",
                "allowed_model_efforts": [
                    {"model_id": "openai/gpt-5.4", "effort_level": "medium"}
                ],
                "participant_allowlist": ["reviewer-actor-id"],
            },
            "idempotency_key": "eval-create-001",
        },
        payment_signature=signed_payment,
    )
    room = client.skill_evals.get_review_room(created["case_id"])
    print(room["reviewer_state"])
    review = client.skill_evals.submit_review(
        created["case_id"],
        {
            "review_marks": [
                {
                    "candidate_id": "B",
                    "kind": "valuable",
                    "note": "B checks the configured docs path before recommending an edit.",
                    "reason_code": "prevents_wrong_repo_patch",
                    "confidence": "high",
                    "criterion": "Finds repo boundaries",
                }
            ]
        },
    )
    inbox = client.skill_evals.get_insights(created["case_id"])
    print(inbox["valuable"][0]["jsonl_log_ref"], review["review_mark_counts"])
    client.skill_evals.respond_to_review(
        created["case_id"],
        inbox["valuable"][0]["source_review_id"],
        {
            "response": "applied",
            "reason": "Updated the skill from the concrete log-backed insight.",
            "applied_insight_ref": inbox["valuable"][0]["insight_ref"],
            "skill_change_ref": "skill://docs-review/v2.1",
            "skill_version_before": "skill://docs-review/v2.0",
            "skill_version_after": "skill://docs-review/v2.1",
            "jsonl_log_ref": "spaps-artifact://logs/apply.jsonl",
            "jsonl_log_hash": (
                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
            ),
            "model_id": "openai/gpt-5.4",
            "effort_level": "medium",
        },
    )
finally:
    client.close()
```

### Webhook Verification

```python
from spaps_client import verify_spaps_webhook

payload = verify_spaps_webhook(
    body=request_body_bytes,
    signature=request_headers["X-SPAPS-Signature"],
    secret="whsec_example",
)

print(payload.type)
```

## Validation

From the repository root:

```bash
npm run lint:python-client
npm run typecheck:python-client
npm run test:python-client
```

For local package work:

```bash
cd packages/python-client
pip install -e '.[dev]'
```

## Troubleshooting

### `ValueError: Access token not found`

Authenticate first with `client.auth...` helpers, or seed tokens with your configured token storage.

### `401` or `403` responses

Check the API key, access token, and endpoint role requirements for the target environment.

### Hosted examples fail against localhost

Set `base_url="http://localhost:3301"` and use a local development key or test credentials.

### I need more control over HTTP behavior

Inject a custom `httpx` client or configure `RetryConfig` and logging hooks.

### Which client should I start with?

Use `SpapsClient` unless your service is already async end to end.

## Limitations

- Some specialty integrations live as standalone helpers instead of methods on the main client.
- Endpoint coverage tracks the active SPAPS backend surface; new backend features may appear here incrementally.
- You still need to provide environment-specific API keys, tokens, and deployment URLs.

## FAQ

### Is the package name different from the import path?

Yes. Install `spaps`; import from `spaps_client`.

### Does it support async codebases?

Yes. Use `AsyncSpapsClient` and the async helper classes.

### Is webhook verification included?

Yes. Use `verify_spaps_webhook`.

### Can I replace the default token storage?

Yes. Pass a custom `token_storage` implementation.

The default is `InMemoryTokenStorage`. For persistence, pass
`FileTokenStorage(path=...)`: it replaces the token file only after a complete
write, keeps file permissions owner-only on POSIX, and rejects symlinked storage
directories. Loading ignores symlinked or malformed token files.

### Does it retry requests automatically?

It can. Pass `RetryConfig` if you want retry and backoff behavior.

## Metadata

The metadata below describes this checkout. `latest_version` is the source
package version; it does not establish registry publication or availability.

- `package_name`: `spaps`
- `latest_version`: `0.8.2`
- `minimum_runtime`: `Python >=3.9`
- `api_base_url`: `https://api.sweetpotato.dev`

## About Contributions

> *About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.

## License

MIT

## CFO: server decision and browser enrollment

`client.cfo.decide_access(...)` and `client.cfo.start_browser_relay(...)` are server-only. The async client exposes the same methods with `await`. Decision requires the CFO application's secret and the current human bearer; `allowed=False` is a valid HTTP 200 denial. Start requires a trusted `CfoBrowserSession` (or `AsyncCfoBrowserSession`), current human bearer, and the real browser's refresh cookie. Start does not send the application secret. No connection inventory, provider exchange, callback, or delegation-freshen helper is exposed.

These calls use dedicated one-shot HTTPX transports. Shared SDK retries, logging hooks, automatic refresh, and injected aggregate HTTP clients do not apply. Configure the host so `httpcore` DEBUG/wire logging is disabled or redacted; the facade cannot prevent secrets appearing in arbitrary host instrumentation.

```python
# CFO decision example
from spaps_client import CfoClient, CfoDecision


def decide_cfo_report(cfo: CfoClient, company_id: str) -> CfoDecision:
    return cfo.decide_access(
        company_id=company_id,
        effect="write",
        action="report.read",
        correlation_id="test:1",
    )
```

Construct a dedicated client with `CfoClient(base_url=spaps_origin, api_key=application_secret, access_token_provider=current_access_token)`, or use an authenticated aggregate's `client.cfo`. The provider is called for each decision; it must return the current token, not a captured login token.

```python
# CFO browser example
from spaps_client import CfoBrowserSession, CfoClient


def begin_cfo_enrollment(
    cfo: CfoClient, bridge: CfoBrowserSession, company_id: str
) -> str:
    return cfo.start_browser_relay(
        company_id=company_id, action="enroll", browser_session=bridge
    )
```

The bridge constructor requires `spaps_origin`, allowed application `origin`, browser `user_agent`, `credentials_provider`, `invalidate_access`, and `response_handler`. The credentials provider returns `CfoBrowserCredentials(access_token=..., cookie_header=...)` from the current authenticated browser request after host CSRF validation. Only the configured refresh cookie and `spaps_cfo_context` are forwarded. Use the same bridge for overlapping attempts in one browser context; concurrent attempts fail closed. Different browser contexts require different bridges.

`response_handler(status, cookies, location)` receives ordered raw allowlisted `Set-Cookie` values, including deletions and failure responses. It must apply them at the actual SPAPS browser origin before forwarding an optional validated Intuit navigation. A BFF on an unrelated hostname cannot set SPAPS host-only cookies. The callback URLs and cookies are sensitive trusted bridge inputs: do not serialize them into ordinary application results or logs. A local cookie sink does not provision this deployed bridge.

On refresh-cookie rotation, `invalidate_access` must invalidate the cached bearer. Before another deliberate call, the host must reacquire current access through shared SPAPS auth, apply any further cookie updates, and expose that new bearer. Neither adapter refreshes or replays the operation. `CfoError` exposes only bounded `code`, `status`, and optional `request_id`; it does not retain the raw HTTP error. Deployment still requires the real origin/CSRF/cookie bridge, safe host logging, external CFO executor adoption, and the human retirement gate. These checkout APIs do not establish published package availability.
