#!/usr/bin/env python3 """Reconcile ONE prepared mailing; never create/fund/authorize a new one. See docs/developer/CRON_EXAMPLE.md. Python standard library, macOS/Linux. The HTTPS /api/v1 URL, its PONY_ENVIRONMENT and a current secret-store-managed credential are required. Exit 0: accepted/recovered (not mailed); 2: human action; 75: retry lookup later. """ import fcntl import hashlib import json import os from pathlib import Path import re import stat import sys import urllib.error import urllib.parse import urllib.request class UnknownResponse(Exception): pass class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None # Never forward a bearer credential to a redirect target. def save(path, value): """Durable local intent/result; do not discard uncertainty on interruption.""" temporary = path.with_suffix(path.suffix + '.tmp') with temporary.open('w', encoding='utf-8') as stream: json.dump(value, stream, sort_keys=True, separators=(',', ':')) stream.flush(); os.fsync(stream.fileno()) os.replace(temporary, path) directory = os.open(path.parent, os.O_RDONLY) try: os.fsync(directory) finally: os.close(directory) def run(work, base, token_file, opener, environment): parsed = urllib.parse.urlparse(base) if (parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path != '/api/v1'): raise ValueError('Use the HTTPS API base ending exactly in /api/v1') if not work.is_dir(): raise ValueError('Use the existing private work directory from the quickstart') if stat.S_IMODE(work.stat().st_mode) & 0o077: raise ValueError('Work directory must be private to its owner (chmod 700)') with (work / 'worker.lock').open('a') as lock: try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: print('Another worker owns this work item; retry lookup later.'); return 75 body = (work / 'send-request.json').read_bytes() payload = json.loads(body) key = (work / 'send.key').read_text().strip() if not re.fullmatch(r'[A-Za-z0-9_.:-]{8,150}', key): raise ValueError('Keep a persisted 8–150 character key using letters, digits, _, ., :, -') required = {'send_intent_id', 'quote_id', 'proof_id', 'expected_total_minor', 'currency'} # grant_id is optional: the credential's own active permission applies. if not isinstance(payload, dict) or not required <= set(payload) <= required | {'grant_id'} or payload['currency'] != 'USD': raise ValueError('Use the exact prepared USD send request from the quickstart') if type(payload['expected_total_minor']) is not int or not 1 <= payload['expected_total_minor'] <= 100_000_000: raise ValueError('Expected total must be a positive integer in USD minor units') fingerprint = {'key': key, 'body_sha256': hashlib.sha256(body).hexdigest()} attempt = work / 'attempt.json' if attempt.exists(): if json.loads(attempt.read_text()) != fingerprint: raise ValueError('Saved request changed; reconcile the original key/body first') else: save(attempt, fingerprint) # Before the first possible economic request. def request(method, path, data=None, authenticated=True): headers = {'Accept': 'application/json'} if authenticated: permission = stat.S_IMODE(token_file.stat().st_mode) if permission & 0o077: raise ValueError('Credential file must be readable only by its owner (chmod 600)') token = token_file.read_text().strip() if not token or len(token) > 16384 or any(c.isspace() for c in token): raise ValueError('Supply a current credential through the private token file') headers['Authorization'] = 'Bearer ' + token if data is not None: headers.update({'Content-Type': 'application/json', 'Idempotency-Key': key}) req = urllib.request.Request(base + path, data=data, headers=headers, method=method) try: with opener.open(req, timeout=30) as response: raw = response.read(2_000_001) if len(raw) > 2_000_000: raise UnknownResponse() try: value = json.loads(raw) except (ValueError, UnicodeError): raise UnknownResponse() return response.status, value except urllib.error.HTTPError as error: try: raw = error.read(2_000_001) finally: error.close() try: value = json.loads(raw) if len(raw) <= 2_000_000 else {} except (ValueError, UnicodeError): value = {} return error.code, value # Public catalog is checked BEFORE sending a credential. This supplements # verified configuration; environment isolation is still server-enforced. status, catalog = request('GET', '/catalog', authenticated=False) prepaid = catalog.get('prepaid', {}) if isinstance(catalog, dict) else {} if status != 200 or prepaid.get('environment') != environment: raise ValueError('Server environment does not match PONY_ENVIRONMENT; no credential sent') def committed(result): if not isinstance(result, dict) or not isinstance(result.get('order_id'), str) or not result['order_id']: print('No valid order receipt; keep this request and reconcile.'); return 75 if result.get('environment') not in (None, environment): print('Unexpected receipt environment; retain state for review.'); return 2 try: save(work / 'order-result.json', result) except OSError: print('Receipt could not be saved; retain the request and look up the same key.'); return 75 print('Accepted order ' + result['order_id'] + '; not proof of printing, mailing or delivery.') return 0 status, outcome = request('GET', '/requests/send_with_credits/' + urllib.parse.quote(key, safe='')) if not isinstance(outcome, dict): print('Unrecognized lookup response; retain request and reconcile.'); return 75 if status == 200: if outcome.get('state') == 'committed': return committed(outcome.get('result')) if outcome.get('state') == 'rejected': print('Attempt rejected: ' + str(outcome.get('error', {}).get('code', 'UNKNOWN'))) print('Repair deliberately; keep this intent and do not create an automatic new attempt.'); return 2 print('Unrecognized lookup outcome; keep original key/body.'); return 75 if status in (202, 429) or status >= 500: print('Outcome pending or unavailable; retain key/body and retry lookup later.'); return 75 if status != 404 or outcome.get('error', {}).get('code') != 'REQUEST_NOT_FOUND': print('Lookup blocked (HTTP ' + str(status) + '); check authentication and authority.'); return 2 status, outcome = request('POST', '/orders', body) if status in (200, 201) and isinstance(outcome, dict) and outcome.get('order_id'): return committed(outcome) if status in (202, 429) or status >= 500: print('Send outcome pending or uncertain; next run looks up the SAME key.'); return 75 error = outcome.get('error', {}) if isinstance(outcome, dict) else {} print('Send needs attention (HTTP ' + str(status) + '): ' + str(error.get('code', 'UNKNOWN'))) if error.get('code') == 'INSUFFICIENT_FUNDS' and isinstance(error.get('action_url'), str): print('Add funds in Account → Credits: ' + error['action_url']) print('Keep the saved request; resolve it before any new attempt.'); return 2 def main(argv=None, environ=None, opener=None): # Dependencies are injectable for the checked-in offline API integration # tests. The CLI always uses verified TLS and the no-redirect opener. args = list(sys.argv[1:] if argv is None else argv) env = os.environ if environ is None else environ os.umask(0o077) try: if len(args) != 1: raise ValueError('Usage: cron_send_example.py PRIVATE_WORK_DIRECTORY') environment = env.get('PONY_ENVIRONMENT') if environment not in ('live', 'sandbox'): raise ValueError('Set PONY_ENVIRONMENT to live or sandbox to match the API base') return run(Path(args[0]).resolve(strict=True), env['PONY_API_BASE'].rstrip('/'), Path(env['PONY_ACCESS_TOKEN_FILE']), opener or urllib.request.build_opener(NoRedirect()), environment) except (urllib.error.URLError, TimeoutError, ConnectionError, UnknownResponse): print('Network outcome uncertain; keep key/body and rerun lookup later.', file=sys.stderr); return 75 except (ValueError, KeyError, OSError) as error: # All raised validation messages are local safe constants. Never print # headers, bearer credentials, correspondence or raw provider errors. print('Stopped safely: ' + str(error), file=sys.stderr); return 2 if __name__ == '__main__': sys.exit(main())