Skip to content
Draft
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
68 changes: 65 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,50 @@ jobs:

echo "Version $VERSION verified in both SDKs"

# Run tests before publishing
test:
name: Run Tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ts/package-lock.json

- name: Install TypeScript dependencies
run: npm ci
working-directory: ts

- name: Run TypeScript tests
run: npm test
working-directory: ts

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"

- name: Install Python dependencies
run: pip install -e ".[dev]"
working-directory: python

- name: Run Python tests
run: pytest -v
working-directory: python

# Publish TypeScript to npm
publish-npm:
name: Publish to npm
needs: validate
needs: [validate, test]
runs-on: ubuntu-latest
outputs:
checksum: ${{ steps.checksum.outputs.value }}
defaults:
run:
working-directory: ts
Expand All @@ -73,6 +112,14 @@ jobs:
- name: Build
run: npm run build

- name: Compute package checksum
id: checksum
run: |
npm pack
CHECKSUM=$(sha256sum *.tgz | awk '{print $1}')
echo "value=$CHECKSUM" >> $GITHUB_OUTPUT
echo "npm package SHA256: $CHECKSUM"

- name: Publish to npm
run: npm publish --access public
env:
Expand All @@ -81,8 +128,10 @@ jobs:
# Publish Python to PyPI
publish-pypi:
name: Publish to PyPI
needs: validate
needs: [validate, test]
runs-on: ubuntu-latest
outputs:
checksum: ${{ steps.checksum.outputs.value }}
defaults:
run:
working-directory: python
Expand All @@ -102,6 +151,13 @@ jobs:
- name: Build package
run: python -m build

- name: Compute package checksums
id: checksum
run: |
CHECKSUM=$(sha256sum dist/* | awk '{print $1 " " $2}' | tr '\n' '; ')
echo "value=$CHECKSUM" >> $GITHUB_OUTPUT
echo "Python package SHA256: $CHECKSUM"

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
Expand All @@ -119,7 +175,8 @@ jobs:
- uses: actions/checkout@v4

- name: Create Release
uses: softprops/action-gh-release@v1
# Pinned to commit SHA for supply-chain security (tag: v1)
uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844
with:
name: v${{ needs.validate.outputs.version }}
body: |
Expand All @@ -137,7 +194,12 @@ jobs:
pip install numbersprotocol-capture-sdk==${{ needs.validate.outputs.version }}
```

### Artifact Checksums (SHA256)
- npm: `${{ needs.publish-npm.outputs.checksum }}`
- Python: `${{ needs.publish-pypi.outputs.checksum }}`

### Links
- [npm package](https://www.npmjs.com/package/@numbersprotocol/capture-sdk)
- [PyPI package](https://pypi.org/project/numbersprotocol-capture-sdk/)
generate_release_notes: true

2 changes: 2 additions & 0 deletions python/numbersprotocol_capture/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .errors import (
AuthenticationError,
CaptureError,
ForbiddenError,
InsufficientFundsError,
NetworkError,
NotFoundError,
Expand Down Expand Up @@ -63,6 +64,7 @@
# Errors
"CaptureError",
"AuthenticationError",
"ForbiddenError",
"PermissionError",
"NotFoundError",
"InsufficientFundsError",
Expand Down
66 changes: 62 additions & 4 deletions python/numbersprotocol_capture/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

import json
import mimetypes
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlencode, urlparse

import httpx

Expand All @@ -31,6 +32,7 @@
)

DEFAULT_BASE_URL = "https://api.numbersprotocol.io/api/v3"
DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB
HISTORY_API_URL = "https://e23hi68y55.execute-api.us-east-1.amazonaws.com/default/get-commits-storage-backend-jade-near"
MERGE_TREE_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/get-full-asset-tree"
ASSET_SEARCH_API_URL = "https://us-central1-numbers-protocol-api.cloudfunctions.net/asset-search"
Expand Down Expand Up @@ -65,9 +67,38 @@ def _get_mime_type(filename: str) -> str:
return mime_type or "application/octet-stream"


def _is_private_or_localhost(hostname: str) -> bool:
"""Returns True if the hostname is localhost or a private/link-local address."""
if hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
return True
private_ranges = [
r"^10\.\d+\.\d+\.\d+$",
r"^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$",
r"^192\.168\.\d+\.\d+$",
r"^169\.254\.\d+\.\d+$", # Link-local (e.g., AWS metadata service)
]
return any(re.match(pattern, hostname) for pattern in private_ranges)


def _validate_base_url(url: str) -> None:
"""Validates that a custom base_url is safe (HTTPS, not localhost/private)."""
try:
parsed = urlparse(url)
except Exception as e:
raise ValidationError(f"Invalid base_url: {url}") from e
if parsed.scheme != "https":
raise ValidationError("base_url must use HTTPS protocol")
hostname = parsed.hostname or ""
if _is_private_or_localhost(hostname):
raise ValidationError(
"base_url must not point to localhost or private network addresses"
)


def _normalize_file(
file_input: FileInput,
options: RegisterOptions | None = None,
max_file_size: int | None = None,
) -> tuple[bytes, str, str]:
"""
Normalizes various file input types to a common format.
Expand All @@ -80,6 +111,13 @@ def _normalize_file(
path = Path(file_input)
if not path.exists():
raise ValidationError(f"File not found: {file_input}")
if max_file_size and max_file_size > 0:
file_size = path.stat().st_size
if file_size > max_file_size:
raise ValidationError(
f"File size ({file_size} bytes) exceeds maximum allowed size "
f"({max_file_size} bytes)"
)
data = path.read_bytes()
filename = path.name
mime_type = _get_mime_type(filename)
Expand All @@ -89,6 +127,13 @@ def _normalize_file(
if isinstance(file_input, Path):
if not file_input.exists():
raise ValidationError(f"File not found: {file_input}")
if max_file_size and max_file_size > 0:
file_size = file_input.stat().st_size
if file_size > max_file_size:
raise ValidationError(
f"File size ({file_size} bytes) exceeds maximum allowed size "
f"({max_file_size} bytes)"
)
data = file_input.read_bytes()
filename = file_input.name
mime_type = _get_mime_type(filename)
Expand All @@ -98,6 +143,11 @@ def _normalize_file(
if isinstance(file_input, bytes | bytearray):
if not options or not options.filename:
raise ValidationError("filename is required for binary input")
if max_file_size and max_file_size > 0 and len(file_input) > max_file_size:
raise ValidationError(
f"File size ({len(file_input)} bytes) exceeds maximum allowed size "
f"({max_file_size} bytes)"
)
data = bytes(file_input)
filename = options.filename
mime_type = _get_mime_type(filename)
Expand Down Expand Up @@ -134,6 +184,7 @@ def __init__(
*,
testnet: bool = False,
base_url: str | None = None,
max_file_size: int | None = None,
options: CaptureOptions | None = None,
):
"""
Expand All @@ -142,20 +193,27 @@ def __init__(
Args:
token: Authentication token for API access.
testnet: Use testnet environment (default: False).
base_url: Custom base URL (overrides testnet setting).
base_url: Custom base URL (overrides testnet setting). Must use HTTPS
and must not point to localhost or private network addresses.
max_file_size: Maximum file size in bytes (default: 100 MB). Set to 0 to disable.
options: CaptureOptions object (alternative to individual args).
"""
if options:
token = options.token
testnet = options.testnet
base_url = options.base_url
max_file_size = options.max_file_size

if not token:
raise ValidationError("token is required")

if base_url:
_validate_base_url(base_url)

self._token = token
self._testnet = testnet
self._base_url = base_url or DEFAULT_BASE_URL
self._max_file_size = max_file_size if max_file_size is not None else DEFAULT_MAX_FILE_SIZE
self._client = httpx.Client(timeout=30.0)

def __enter__(self) -> Capture:
Expand Down Expand Up @@ -281,7 +339,7 @@ def register(
raise ValidationError("headline must be 25 characters or less")

# Normalize file input
data, file_name, mime_type = _normalize_file(file, options)
data, file_name, mime_type = _normalize_file(file, options, self._max_file_size)

if len(data) == 0:
raise ValidationError("file cannot be empty")
Expand Down Expand Up @@ -665,7 +723,7 @@ def search_asset(
elif options.nid:
form_data["nid"] = options.nid
elif options.file:
data, filename, mime_type = _normalize_file(options.file)
data, filename, mime_type = _normalize_file(options.file, max_file_size=self._max_file_size)
files_data = {"file": (filename, data, mime_type)}

# Add optional parameters
Expand Down
9 changes: 7 additions & 2 deletions python/numbersprotocol_capture/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,18 @@ def __init__(self, message: str = "Invalid or missing authentication token"):
super().__init__(message, "AUTHENTICATION_ERROR", 401)


class PermissionError(CaptureError):
class ForbiddenError(CaptureError):
"""Thrown when user lacks permission for the requested operation."""

def __init__(self, message: str = "Insufficient permissions for this operation"):
super().__init__(message, "PERMISSION_ERROR", 403)


# Backwards-compatibility alias. New code should use ForbiddenError.
# This alias avoids shadowing Python's built-in PermissionError (an OSError subclass).
PermissionError = ForbiddenError


class NotFoundError(CaptureError):
"""Thrown when the requested asset is not found."""

Expand Down Expand Up @@ -82,7 +87,7 @@ def create_api_error(
elif status_code == 401:
return AuthenticationError(message)
elif status_code == 403:
return PermissionError(message)
return ForbiddenError(message)
elif status_code == 404:
return NotFoundError(nid)
else:
Expand Down
5 changes: 4 additions & 1 deletion python/numbersprotocol_capture/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ class CaptureOptions:
"""Use testnet environment (default: False)."""

base_url: str | None = None
"""Custom base URL (overrides testnet setting)."""
"""Custom base URL (overrides testnet setting). Must use HTTPS and must not point to localhost or private network addresses."""

max_file_size: int | None = None
"""Maximum file size in bytes for asset registration (default: 100 MB). Set to 0 to disable."""


@dataclass
Expand Down
Loading