feat: site WIP — downloads, explorer, hasher, TestFlight enroll
Commit restored mcp.verae.ai site pages (app, register, macos, windows, explorer), hasher/download artifacts, index/sample/hasher updates, ledger and global_client improvements, NATS stamp helper, and TestFlight enroll service. Ignore debug-trace/ and editor backups. Standard Readme for download and enroll docs.
6
.gitignore
vendored
|
|
@ -21,3 +21,9 @@ build/
|
||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Local stamp debug-trace runs and editor backups
|
||||||
|
debug-trace/
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
index.html.bak*
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ Scope rank: local < shared < global.
|
||||||
| `org_id` | empty | Filter when set |
|
| `org_id` | empty | Filter when set |
|
||||||
| `want_scope` | empty | Drop receipts below this rank |
|
| `want_scope` | empty | Drop receipts below this rank |
|
||||||
|
|
||||||
Returns `{ "receipts": [ ... ] }` from the local file. Global-only certificates are not listed.
|
Returns `{ "receipts": [ ... ] }`. `want_scope=global` queries VeraeTime `GET /api/hash/{sha256}` (Carfax). Empty `want_scope` merges the local file ledger with the central history.
|
||||||
|
|
||||||
## login
|
## login
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ This process is an MCP server (Model Context Protocol). Clients are assistants a
|
||||||
|
|
||||||
## Global history
|
## Global history
|
||||||
|
|
||||||
`hash_report` reads the local file only. VeraeTime is not queried for a digest history. A global stamp can verify by certificate id and still be absent from `hash_report`. An empty list means "not in the local file," not "VeraeTime has no record."
|
`hash_report` with `want_scope=global` calls VeraeTime `GET /api/hash/{digest}` (Carfax). Without that scope it reads the local file and also merges central history when available. An empty list means no receipts were found for that digest in the queried ledgers.
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
|
|
|
||||||
158
scripts/nats_stamp.py
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""Stamp digests over core NATS. Subscribe to verae.ts.completed before requesting.
|
||||||
|
|
||||||
|
No status poll and no retry sleep. A known digest returns its certificate on the
|
||||||
|
request reply. A new digest returns a jobId, then verae.ts.completed carries the
|
||||||
|
certificate.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/nats_stamp.py <64-hex-or-text> [...]
|
||||||
|
VERAE_NATS_URL=nats://127.0.0.1:4222 python scripts/nats_stamp.py "Hello World!"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
SUBJECT_REQUEST = "verae.ts.request"
|
||||||
|
SUBJECT_COMPLETED = "verae.ts.completed"
|
||||||
|
|
||||||
|
|
||||||
|
def digest_of(text: str) -> str:
|
||||||
|
raw = text.strip().lower()
|
||||||
|
if raw.startswith("sha256:"):
|
||||||
|
raw = raw[7:]
|
||||||
|
if len(raw) == 64 and all(c in "0123456789abcdef" for c in raw):
|
||||||
|
return raw
|
||||||
|
return hashlib.sha256(text.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class Conn:
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
host_port = url.split("://", 1)[-1]
|
||||||
|
host, port_s = host_port.rsplit(":", 1)
|
||||||
|
self.sock = socket.create_connection((host, int(port_s)), timeout=5)
|
||||||
|
self.buf = b""
|
||||||
|
info = self._read_line()
|
||||||
|
if not info.startswith("INFO "):
|
||||||
|
raise RuntimeError(info)
|
||||||
|
self.queued: dict[str, list[dict]] = {}
|
||||||
|
self._send(
|
||||||
|
'CONNECT {"verbose":false,"pedantic":false,"lang":"verae-stamp","version":"1","protocol":1}\r\n'
|
||||||
|
"PING\r\n"
|
||||||
|
)
|
||||||
|
self._expect_pong()
|
||||||
|
|
||||||
|
def _send(self, text: str) -> None:
|
||||||
|
self.sock.sendall(text.encode())
|
||||||
|
|
||||||
|
def _read_line(self) -> str:
|
||||||
|
while b"\r\n" not in self.buf:
|
||||||
|
chunk = self.sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("nats closed")
|
||||||
|
self.buf += chunk
|
||||||
|
line, self.buf = self.buf.split(b"\r\n", 1)
|
||||||
|
return line.decode()
|
||||||
|
|
||||||
|
def _expect_pong(self) -> None:
|
||||||
|
while True:
|
||||||
|
line = self._read_line()
|
||||||
|
if line == "PONG" or line.startswith("+OK"):
|
||||||
|
if line == "PONG":
|
||||||
|
return
|
||||||
|
continue
|
||||||
|
if line.startswith("-ERR"):
|
||||||
|
raise RuntimeError(line)
|
||||||
|
|
||||||
|
def subscribe(self, subject: str, sid: str) -> None:
|
||||||
|
self._send(f"SUB {subject} {sid}\r\n")
|
||||||
|
|
||||||
|
def request(self, subject: str, payload: dict, inbox: str, sid: str) -> dict:
|
||||||
|
raw = json.dumps(payload).encode()
|
||||||
|
self.subscribe(inbox, sid)
|
||||||
|
self._send(f"PUB {subject} {inbox} {len(raw)}\r\n")
|
||||||
|
self._send(raw.decode() + "\r\n")
|
||||||
|
return self._wait_sid(sid, 30.0)
|
||||||
|
|
||||||
|
def _read_msg(self) -> tuple[str, dict]:
|
||||||
|
while True:
|
||||||
|
line = self._read_line()
|
||||||
|
if line.startswith("PING"):
|
||||||
|
self._send("PONG\r\n")
|
||||||
|
continue
|
||||||
|
if line.startswith("PONG") or line.startswith("+OK"):
|
||||||
|
continue
|
||||||
|
if not line.startswith("MSG "):
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
size = int(parts[-1])
|
||||||
|
msg_sid = parts[2]
|
||||||
|
while len(self.buf) < size + 2:
|
||||||
|
chunk = self.sock.recv(65536)
|
||||||
|
if not chunk:
|
||||||
|
raise RuntimeError("nats closed")
|
||||||
|
self.buf += chunk
|
||||||
|
body = self.buf[:size]
|
||||||
|
self.buf = self.buf[size + 2 :]
|
||||||
|
return msg_sid, json.loads(body.decode())
|
||||||
|
|
||||||
|
def _wait_sid(self, sid: str, timeout: float) -> dict:
|
||||||
|
queued = self.queued.get(sid) or []
|
||||||
|
if queued:
|
||||||
|
return queued.pop(0)
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
self.sock.settimeout(max(0.1, deadline - time.time()))
|
||||||
|
msg_sid, body = self._read_msg()
|
||||||
|
if msg_sid == sid:
|
||||||
|
return body
|
||||||
|
self.queued.setdefault(msg_sid, []).append(body)
|
||||||
|
raise TimeoutError(sid)
|
||||||
|
|
||||||
|
def next_completed(self, timeout: float) -> dict:
|
||||||
|
return self._wait_sid("done", timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if not argv:
|
||||||
|
print("usage: nats_stamp.py <text-or-digest> [...]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
url = os.environ.get("VERAE_NATS_URL", "nats://127.0.0.1:4222")
|
||||||
|
digests = [digest_of(item) for item in argv]
|
||||||
|
conn = Conn(url)
|
||||||
|
conn.subscribe(SUBJECT_COMPLETED, "done")
|
||||||
|
pending: dict[str, str] = {}
|
||||||
|
receipts: dict[str, str] = {}
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for i, digest in enumerate(digests):
|
||||||
|
started = time.perf_counter()
|
||||||
|
body = conn.request(SUBJECT_REQUEST, {"hash": digest}, f"_INBOX.{i}", f"inbox{i}")
|
||||||
|
if body.get("error"):
|
||||||
|
print(json.dumps({"hash": digest, "error": body["error"]}))
|
||||||
|
return 1
|
||||||
|
elapsed = (time.perf_counter() - started) * 1000
|
||||||
|
if body.get("receipt_id"):
|
||||||
|
receipts[digest] = body["receipt_id"]
|
||||||
|
print(json.dumps({"hash": digest, "receipt_id": body["receipt_id"], "existing": body.get("existing"), "ms": round(elapsed, 3)}))
|
||||||
|
else:
|
||||||
|
pending[body["jobId"]] = digest
|
||||||
|
print(json.dumps({"hash": digest, "jobId": body.get("jobId"), "existing": False, "ms": round(elapsed, 3)}))
|
||||||
|
while pending:
|
||||||
|
msg = conn.next_completed(30)
|
||||||
|
job_id = msg.get("jobId")
|
||||||
|
digest = pending.pop(job_id, None)
|
||||||
|
if digest is None:
|
||||||
|
continue
|
||||||
|
receipts[digest] = msg.get("receipt_id") or ""
|
||||||
|
print(json.dumps({"hash": digest, "jobId": job_id, "receipt_id": receipts[digest], "via": "verae.ts.completed"}))
|
||||||
|
print(json.dumps({"n": len(digests), "receipts": len(receipts), "wall_ms": round((time.perf_counter() - t0) * 1000, 3)}))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
30
scripts/testflight-enroll/README.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# TestFlight enroll service
|
||||||
|
|
||||||
|
HTTP helper that adds an Apple ID email to the Verae Timestamping TestFlight group via App Store Connect.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# On ns1: secrets at /SSD2/secrets/asc/ (AuthKey_*.p8 + testflight-enroll.env, mode 600)
|
||||||
|
# Service unit: verae-testflight-enroll.service (venv under /SSD2/services/verae-testflight-enroll)
|
||||||
|
cd scripts/testflight-enroll
|
||||||
|
python -m venv .venv && .venv/bin/pip install -r requirements.txt 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Public invite endpoint (behind Caddy): `POST https://mcp.verae.ai/api/testflight-invite` with JSON `{"email":"you@apple-id.example","note":"..."}`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo -u marchon bash -lc 'set -a; source /SSD2/secrets/asc/testflight-enroll.env; set +a; /SSD2/services/verae-testflight-enroll/venv/bin/python enroll_cli.py you@example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
Env keys: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_KEY_PATH`, optional app/group ids. Do not commit `.p8` keys or env files.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Questions: marchon@gmail.com or the Verae research workspace. PRs coordinated in-workspace first.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
UNLICENSED © George Lambert
|
||||||
29
scripts/testflight-enroll/enroll_cli.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CLI for Grok Bot / operators: enroll one Apple ID into external TestFlight."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from enroll_lib import enroll
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="Enroll macOS TestFlight external tester via ASC API")
|
||||||
|
p.add_argument("email", help="Apple ID email")
|
||||||
|
p.add_argument("--note", default="", help="optional note (ignored by ASC; logged locally only if you redirect)")
|
||||||
|
args = p.parse_args()
|
||||||
|
try:
|
||||||
|
result = enroll(args.email, note=args.note)
|
||||||
|
except Exception as e:
|
||||||
|
print(json.dumps({"ok": False, "error": str(e)}))
|
||||||
|
sys.exit(1)
|
||||||
|
print(json.dumps(result))
|
||||||
|
sys.exit(0 if result.get("ok") else 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
225
scripts/testflight-enroll/enroll_lib.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""App Store Connect TestFlight external-tester enrollment helpers.
|
||||||
|
|
||||||
|
Secrets come from env (never hard-coded):
|
||||||
|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH
|
||||||
|
Optional:
|
||||||
|
ASC_APP_ID (default 6817018890)
|
||||||
|
ASC_BETA_GROUP_ID (default fcfa572e-6494-4781-97b2-728f73e5b3ae)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$")
|
||||||
|
|
||||||
|
DEFAULT_APP_ID = "6817018890"
|
||||||
|
DEFAULT_GROUP_ID = "fcfa572e-6494-4781-97b2-728f73e5b3ae"
|
||||||
|
ASC_BASE = "https://api.appstoreconnect.apple.com"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email(email: str) -> str:
|
||||||
|
email = (email or "").strip()
|
||||||
|
if not email or len(email) > 254 or not EMAIL_RE.match(email):
|
||||||
|
raise ValueError("invalid_email")
|
||||||
|
return email.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_env() -> tuple[str, str, str]:
|
||||||
|
key_id = os.environ.get("ASC_KEY_ID", "").strip()
|
||||||
|
issuer = os.environ.get("ASC_ISSUER_ID", "").strip()
|
||||||
|
path = os.environ.get("ASC_KEY_PATH", "").strip()
|
||||||
|
if not key_id or not issuer or not path:
|
||||||
|
raise RuntimeError("missing_asc_env")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise RuntimeError("missing_asc_key_file")
|
||||||
|
return key_id, issuer, path
|
||||||
|
|
||||||
|
|
||||||
|
def make_token() -> str:
|
||||||
|
"""ES256 JWT for App Store Connect API (max ~20 min)."""
|
||||||
|
import jwt # PyJWT
|
||||||
|
|
||||||
|
key_id, issuer, path = _require_env()
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
private_key = f.read()
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"iss": issuer,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + 15 * 60,
|
||||||
|
"aud": "appstoreconnect-v1",
|
||||||
|
}
|
||||||
|
return jwt.encode(
|
||||||
|
payload,
|
||||||
|
private_key,
|
||||||
|
algorithm="ES256",
|
||||||
|
headers={"kid": key_id, "typ": "JWT"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(method: str, path: str, body: dict | None = None, token: str | None = None) -> tuple[int, Any]:
|
||||||
|
token = token or make_token()
|
||||||
|
url = path if path.startswith("http") else ASC_BASE + path
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("Authorization", "Bearer " + token)
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
if body is not None:
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||||
|
raw = resp.read().decode("utf-8") or "{}"
|
||||||
|
return resp.status, json.loads(raw) if raw.strip() else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw) if raw.strip() else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = {"raw": raw}
|
||||||
|
return e.code, parsed
|
||||||
|
|
||||||
|
|
||||||
|
def find_tester_by_email(email: str, token: str) -> dict | None:
|
||||||
|
q = urllib.parse.urlencode({
|
||||||
|
"filter[email]": email,
|
||||||
|
"filter[apps]": os.environ.get("ASC_APP_ID", DEFAULT_APP_ID),
|
||||||
|
"limit": "5",
|
||||||
|
})
|
||||||
|
status, data = _request("GET", f"/v1/betaTesters?{q}", token=token)
|
||||||
|
if status != 200:
|
||||||
|
return None
|
||||||
|
items = data.get("data") or []
|
||||||
|
return items[0] if items else None
|
||||||
|
|
||||||
|
|
||||||
|
def create_tester(email: str, token: str, first: str = "Verae", last: str = "Tester") -> tuple[int, Any]:
|
||||||
|
app_id = os.environ.get("ASC_APP_ID", DEFAULT_APP_ID)
|
||||||
|
group_id = os.environ.get("ASC_BETA_GROUP_ID", DEFAULT_GROUP_ID)
|
||||||
|
body = {
|
||||||
|
"data": {
|
||||||
|
"type": "betaTesters",
|
||||||
|
"attributes": {
|
||||||
|
"email": email,
|
||||||
|
"firstName": first[:30] or "Verae",
|
||||||
|
"lastName": last[:30] or "Tester",
|
||||||
|
},
|
||||||
|
"relationships": {
|
||||||
|
"betaGroups": {
|
||||||
|
"data": [{"type": "betaGroups", "id": group_id}]
|
||||||
|
},
|
||||||
|
"apps": {
|
||||||
|
"data": [{"type": "apps", "id": app_id}]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _request("POST", "/v1/betaTesters", body=body, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def add_tester_to_group(tester_id: str, token: str) -> tuple[int, Any]:
|
||||||
|
group_id = os.environ.get("ASC_BETA_GROUP_ID", DEFAULT_GROUP_ID)
|
||||||
|
body = {
|
||||||
|
"data": [{"type": "betaTesters", "id": tester_id}]
|
||||||
|
}
|
||||||
|
return _request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/betaGroups/{group_id}/relationships/betaTesters",
|
||||||
|
body=body,
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resend_invitation(tester_id: str, token: str) -> tuple[int, Any]:
|
||||||
|
# App Store Connect: POST /v1/betaTesterInvitations
|
||||||
|
app_id = os.environ.get("ASC_APP_ID", DEFAULT_APP_ID)
|
||||||
|
body = {
|
||||||
|
"data": {
|
||||||
|
"type": "betaTesterInvitations",
|
||||||
|
"relationships": {
|
||||||
|
"betaTester": {"data": {"type": "betaTesters", "id": tester_id}},
|
||||||
|
"app": {"data": {"type": "apps", "id": app_id}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _request("POST", "/v1/betaTesterInvitations", body=body, token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def enroll(email: str, note: str = "") -> dict:
|
||||||
|
"""Validate + create/add external tester + resend invite. Returns public result dict."""
|
||||||
|
email = validate_email(email)
|
||||||
|
token = make_token()
|
||||||
|
existing = find_tester_by_email(email, token)
|
||||||
|
created = False
|
||||||
|
tester_id = None
|
||||||
|
steps: list[str] = []
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
tester_id = existing["id"]
|
||||||
|
steps.append("found_existing")
|
||||||
|
st, _ = add_tester_to_group(tester_id, token)
|
||||||
|
# 204 success; 409 often already in group
|
||||||
|
if st in (204, 409):
|
||||||
|
steps.append("group_ok" if st == 204 else "already_in_group")
|
||||||
|
else:
|
||||||
|
steps.append(f"group_status_{st}")
|
||||||
|
else:
|
||||||
|
st, data = create_tester(email, token)
|
||||||
|
if st in (201, 200):
|
||||||
|
created = True
|
||||||
|
tester_id = (data.get("data") or {}).get("id")
|
||||||
|
steps.append("created")
|
||||||
|
elif st == 409:
|
||||||
|
# race / already exists — look up again
|
||||||
|
steps.append("create_conflict")
|
||||||
|
existing = find_tester_by_email(email, token)
|
||||||
|
if not existing:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "tester_conflict",
|
||||||
|
"message": "Apple reported a conflict creating this tester. Try again or email marchon@gmail.com.",
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
tester_id = existing["id"]
|
||||||
|
st2, _ = add_tester_to_group(tester_id, token)
|
||||||
|
steps.append("group_ok" if st2 in (204, 409) else f"group_status_{st2}")
|
||||||
|
else:
|
||||||
|
detail = ""
|
||||||
|
errs = (data or {}).get("errors") or []
|
||||||
|
if errs:
|
||||||
|
detail = errs[0].get("detail") or errs[0].get("title") or ""
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "asc_create_failed",
|
||||||
|
"message": detail or f"App Store Connect create failed ({st}).",
|
||||||
|
"steps": steps,
|
||||||
|
"status": st,
|
||||||
|
}
|
||||||
|
|
||||||
|
if tester_id:
|
||||||
|
rst, _ = resend_invitation(tester_id, token)
|
||||||
|
if rst in (201, 200, 204):
|
||||||
|
steps.append("invite_resent")
|
||||||
|
else:
|
||||||
|
steps.append(f"invite_status_{rst}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"email": email,
|
||||||
|
"created": created,
|
||||||
|
"tester_id": tester_id,
|
||||||
|
"steps": steps,
|
||||||
|
"join_url": "https://testflight.apple.com/join/dsHsyx2Z",
|
||||||
|
"beta_review_note": (
|
||||||
|
"External builds may still be WAITING_FOR_BETA_REVIEW. "
|
||||||
|
"Enrollment succeeded; you may not see the build until Apple approves the beta."
|
||||||
|
),
|
||||||
|
"note_received": bool((note or "").strip()),
|
||||||
|
}
|
||||||
115
scripts/testflight-enroll/enroll_server.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal HTTP service: POST /api/testflight-invite → ASC external TestFlight enroll.
|
||||||
|
|
||||||
|
Bind 127.0.0.1 only; Caddy reverse_proxies https://mcp.verae.ai/api/testflight-invite
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
# Allow running from installed dir
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from enroll_lib import enroll, validate_email # noqa: E402
|
||||||
|
|
||||||
|
HOST = os.environ.get("ENROLL_BIND", "127.0.0.1")
|
||||||
|
PORT = int(os.environ.get("ENROLL_PORT", "8011"))
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "verae-testflight-enroll/1.0"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
# Avoid logging emails/bodies
|
||||||
|
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
|
||||||
|
|
||||||
|
def _cors(self):
|
||||||
|
origin = self.headers.get("Origin") or ""
|
||||||
|
allowed = {
|
||||||
|
"https://mcp.verae.ai",
|
||||||
|
"https://mcp.georgelambert.org",
|
||||||
|
"http://127.0.0.1:8000",
|
||||||
|
"http://localhost:8000",
|
||||||
|
}
|
||||||
|
if origin in allowed:
|
||||||
|
self.send_header("Access-Control-Allow-Origin", origin)
|
||||||
|
self.send_header("Vary", "Origin")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
def _json(self, code: int, obj: dict):
|
||||||
|
raw = json.dumps(obj).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self._cors()
|
||||||
|
self.send_header("Content-Length", str(len(raw)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(raw)
|
||||||
|
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
self.send_response(204)
|
||||||
|
self._cors()
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split("?")[0] in ("/healthz", "/api/testflight-invite/healthz"):
|
||||||
|
self._json(200, {"ok": True, "service": "verae-testflight-enroll"})
|
||||||
|
return
|
||||||
|
self._json(404, {"ok": False, "error": "not_found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
path = self.path.split("?")[0]
|
||||||
|
if path not in ("/api/testflight-invite", "/"):
|
||||||
|
self._json(404, {"ok": False, "error": "not_found"})
|
||||||
|
return
|
||||||
|
length = int(self.headers.get("Content-Length") or "0")
|
||||||
|
if length > 8192:
|
||||||
|
self._json(413, {"ok": False, "error": "too_large", "message": "Request too large."})
|
||||||
|
return
|
||||||
|
raw = self.rfile.read(length) if length else b"{}"
|
||||||
|
try:
|
||||||
|
body = json.loads(raw.decode("utf-8") or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid_json", "message": "Expected JSON body."})
|
||||||
|
return
|
||||||
|
email = body.get("email") or body.get("apple_id") or ""
|
||||||
|
note = body.get("note") or ""
|
||||||
|
try:
|
||||||
|
validate_email(email)
|
||||||
|
except ValueError:
|
||||||
|
self._json(400, {
|
||||||
|
"ok": False,
|
||||||
|
"error": "invalid_email",
|
||||||
|
"message": "Enter a valid Apple ID email address.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = enroll(email, note=str(note)[:500])
|
||||||
|
except RuntimeError as e:
|
||||||
|
code = str(e)
|
||||||
|
self._json(503, {
|
||||||
|
"ok": False,
|
||||||
|
"error": code,
|
||||||
|
"message": "Enrollment service is not configured. Email marchon@gmail.com with your Apple ID.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
self._json(500, {
|
||||||
|
"ok": False,
|
||||||
|
"error": "internal",
|
||||||
|
"message": "Enrollment failed unexpectedly. Email marchon@gmail.com with your Apple ID.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
self._json(200 if result.get("ok") else 502, result)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
httpd = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
print(f"verae-testflight-enroll listening on {HOST}:{PORT}", flush=True)
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
19
scripts/testflight-enroll/verae-testflight-enroll.service
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Verae TestFlight invite enrollment (ASC API)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=marchon
|
||||||
|
Group=marchon
|
||||||
|
WorkingDirectory=/SSD2/services/verae-testflight-enroll
|
||||||
|
EnvironmentFile=-/SSD2/secrets/asc/testflight-enroll.env
|
||||||
|
ExecStart=/SSD2/services/verae-testflight-enroll/venv/bin/python /SSD2/services/verae-testflight-enroll/enroll_server.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# Do not leak secrets into journal via argv
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
1151
site/app.html
Normal file
BIN
site/assets/windows/gui-hash-web.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
site/assets/windows/gui-hash.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
site/assets/windows/gui-ready-web.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
site/assets/windows/gui-ready.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
site/assets/windows/gui-register-web.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
site/assets/windows/gui-register.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
site/assets/windows/gui-verify-web.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
site/assets/windows/gui-verify.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
1
site/assets/windows/samples/hash-only.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c /tmp/verae-windows-docs-45782/sample/sample-contract.txt
|
||||||
2
site/assets/windows/samples/verae-register.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
path,sha256,scope,receipt_id,created_at,merkle,status
|
||||||
|
/tmp/verae-windows-docs-45782/sample/sample-contract.txt,c4c2489ebe491a110c26fb66cdfb3545785d4746453a46d21cb6eb2f2972d60c,global,QmTV7gB2vb3ALTCKe1DZ1qxBvawF6h6a4ajYax2chNp6Nc,,,ok
|
||||||
|