shkor.com v2 на основном домене + Autopilot $99: KYC (Didit) → Stripe → свой сервер с панелью CloudCLI (Claude Code + OpenCode)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
45e2d73a2a
commit
3d4c0b957d
6 changed files with 401 additions and 7 deletions
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
Static page (`index.html`, `styles.css`, `assets/`) + backend `api.py` (stdlib, SQLite): email capture and lab applications with KYC/KYB.
|
Static page (`index.html`, `styles.css`, `assets/`) + backend `api.py` (stdlib, SQLite): email capture and lab applications with KYC/KYB.
|
||||||
|
|
||||||
|
- **Live: https://shkor.com** (since 2026-10-18) — v2 at the root, docroot `/var/www/shkor-com-live` (v2 + legacy team.html/screenshots), Caddy `/etc/caddy/sites/shkor.com.caddy`, apex A → 204.168.232.21 (zone `/etc/bind/zones/db.shkor.com`; www is delegated to namebright).
|
||||||
|
Old site (nginx on 204.168.138.138, `/var/www/shkor-local`) backed up: `/var/backups/shkor-com/old-shkor-com-site-20261018.tgz` (+ zone, unit, api.py backups there).
|
||||||
|
Rollback: zone `@` A back to 204.168.138.138, bump serial, `systemctl reload named`.
|
||||||
- Preview: https://new.shkor.com (noindex) — docroot `/var/www/shkor-com`, Caddy `/etc/caddy/sites/new-shkor.caddy`
|
- Preview: https://new.shkor.com (noindex) — docroot `/var/www/shkor-com`, Caddy `/etc/caddy/sites/new-shkor.caddy`
|
||||||
- Video `assets/simbiot-harness-enterprise-en.mp4` is copied from `out/` at deploy (not committed here)
|
- Video `assets/simbiot-harness-enterprise-en.mp4` is copied from `out/` at deploy (not committed here)
|
||||||
- Backend: `systemd shkor-api` (/opt/shkor-subscribe/api.py) on 127.0.0.1:8097, Caddy proxies `/api/*`; DB `/var/lib/shkor-subscribe/subs.db`
|
- Backend: `systemd shkor-api` (/opt/shkor-subscribe/api.py) on 127.0.0.1:8097, Caddy proxies `/api/*`; DB `/var/lib/shkor-subscribe/subs.db`
|
||||||
|
|
@ -12,5 +15,6 @@ Static page (`index.html`, `styles.css`, `assets/`) + backend `api.py` (stdlib,
|
||||||
After publishing v2 at the root, set `PUBLIC_BASE=https://shkor.com` (Didit callback).
|
After publishing v2 at the root, set `PUBLIC_BASE=https://shkor.com` (Didit callback).
|
||||||
export: `sqlite3 /var/lib/shkor-subscribe/subs.db "select email,source,created_at from subs"`
|
export: `sqlite3 /var/lib/shkor-subscribe/subs.db "select email,source,created_at from subs"`
|
||||||
- v2 (labs + autopilot concept): `v2/` → https://new.shkor.com/v2/ ; design direction from ui-ux-pro-max (Dark OLED + Bento + HUD, Space Grotesk)
|
- v2 (labs + autopilot concept): `v2/` → https://new.shkor.com/v2/ ; design direction from ui-ux-pro-max (Dark OLED + Bento + HUD, Space Grotesk)
|
||||||
- Update: `cp -r index.html styles.css assets /var/www/shkor-com/` (v2: `cp -r v2/* /var/www/shkor-com/v2/`)
|
- Update live: `cp -r v2/* /var/www/shkor-com-live/ && cp api.py /opt/shkor-subscribe/ && systemctl restart shkor-api`
|
||||||
- Apex shkor.com still points at 204.168.138.138 (old site) — switch A record after review.
|
- Autopilot $99 (`v2/start/`): form → Didit KYC → `/start/status/?t=` → after Approved, Stripe Checkout via portal onboarding (plan `autopilot`, return_url back here) → portal provisions tenant + Hetzner VM + agent → cloud-init callback → CloudCLI panel `<agent>.ui.shkor.com` with Claude Code + OpenCode (GLM via 9router) → status page shows panel login.
|
||||||
|
Stripe mode follows the portal's `STRIPE_MODE` (/root/coop/.env). Test: `python3 sites/shkor-com/test_autopilot.py`.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@
|
||||||
POST /api/subscribe form email -> subs
|
POST /api/subscribe form email -> subs
|
||||||
POST /api/lab/apply JSON -> portal apply pipeline (42.cil.by /api/studio/apply) + Didit sessions
|
POST /api/lab/apply JSON -> portal apply pipeline (42.cil.by /api/studio/apply) + Didit sessions
|
||||||
GET /api/lab/status?t= application + verification status (refreshed from Didit)
|
GET /api/lab/status?t= application + verification status (refreshed from Didit)
|
||||||
|
POST /api/autopilot/start JSON name/email -> Didit KYC session (Autopilot $99, shkor.com/start)
|
||||||
|
GET /api/autopilot/status?t= stage: kyc -> pay -> deploying -> ready (panel url + login)
|
||||||
|
POST /api/autopilot/checkout JSON t -> Stripe Checkout via portal onboarding (only after KYC Approved)
|
||||||
|
|
||||||
Env:
|
Env:
|
||||||
SHKOR_DB sqlite path (default /var/lib/shkor-subscribe/subs.db)
|
SHKOR_DB sqlite path (default /var/lib/shkor-subscribe/subs.db)
|
||||||
|
|
@ -31,6 +34,8 @@ TG_CHAT = os.environ.get("TG_CHAT_ID", "7569223131")
|
||||||
FINAL = {"Approved", "Declined", "Abandoned", "Expired"}
|
FINAL = {"Approved", "Declined", "Abandoned", "Expired"}
|
||||||
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[A-Za-z]{2,}$")
|
EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[A-Za-z]{2,}$")
|
||||||
RATE = {} # (bucket, ip) -> [timestamps]
|
RATE = {} # (bucket, ip) -> [timestamps]
|
||||||
|
# портал провижнит на первом опросе после оплаты — не опрашивать параллельно (двойной тенант)
|
||||||
|
AP_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def env_value(path, key):
|
def env_value(path, key):
|
||||||
|
|
@ -60,6 +65,11 @@ def db():
|
||||||
name text, email text, project_name text,
|
name text, email text, project_name text,
|
||||||
company_name text, company_country text, company_reg text,
|
company_name text, company_country text, company_reg text,
|
||||||
ip text, created_at text default (datetime('now')));
|
ip text, created_at text default (datetime('now')));
|
||||||
|
create table if not exists ap_orders (
|
||||||
|
token text primary key, name text, email text, company text, ip text,
|
||||||
|
kyc_session text, kyc_url text, kyc_status text, kyc_notified text default '',
|
||||||
|
pending_id text default '', paid_notified integer default 0,
|
||||||
|
created_at text default (datetime('now')), updated_at text default (datetime('now')));
|
||||||
create table if not exists lab_checks (
|
create table if not exists lab_checks (
|
||||||
token text, kind text, session_id text, url text, status text,
|
token text, kind text, session_id text, url text, status text,
|
||||||
notified text default '', updated_at text default (datetime('now')),
|
notified text default '', updated_at text default (datetime('now')),
|
||||||
|
|
@ -127,7 +137,7 @@ def refresh(c, token, force=False):
|
||||||
|
|
||||||
def poller():
|
def poller():
|
||||||
while True:
|
while True:
|
||||||
time.sleep(600)
|
time.sleep(300)
|
||||||
try:
|
try:
|
||||||
c = db()
|
c = db()
|
||||||
toks = [r[0] for r in c.execute(
|
toks = [r[0] for r in c.execute(
|
||||||
|
|
@ -135,7 +145,13 @@ def poller():
|
||||||
"and updated_at > datetime('now','-30 days')")]
|
"and updated_at > datetime('now','-30 days')")]
|
||||||
for t in toks:
|
for t in toks:
|
||||||
refresh(c, t)
|
refresh(c, t)
|
||||||
|
# Autopilot: KYC без открытой вкладки + провижн после оплаты, даже если вкладку закрыли
|
||||||
|
aps = [r[0] for r in c.execute(
|
||||||
|
"select token from ap_orders where paid_notified=0 and created_at > datetime('now','-7 days') "
|
||||||
|
"and (pending_id!='' or kyc_status not in ('Approved','Declined','Abandoned','Expired'))")]
|
||||||
c.close()
|
c.close()
|
||||||
|
for t in aps:
|
||||||
|
ap_status(t)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -211,6 +227,127 @@ def lab_apply(d, ip):
|
||||||
return 200, {"ok": True, "token": token, "status_url": f"{PUBLIC_BASE}/apply/status/?t={token}", "kyc_url": kyc_url}
|
return 200, {"ok": True, "token": token, "status_url": f"{PUBLIC_BASE}/apply/status/?t={token}", "kyc_url": kyc_url}
|
||||||
|
|
||||||
|
|
||||||
|
def portal(path, data=None, timeout=30):
|
||||||
|
"""Вызов портала (onboarding/Stripe/провижн) напрямую, с Host основного сайта."""
|
||||||
|
req = urllib.request.Request(PORTAL_URL + path, data=json.dumps(data).encode() if data is not None else None,
|
||||||
|
headers={"Content-Type": "application/json", "Host": "cil.by"},
|
||||||
|
method="POST" if data is not None else "GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def ap_refresh(c, o):
|
||||||
|
"""KYC-статус из Didit (пока не финальный); уведомление админу один раз."""
|
||||||
|
st = o["kyc_status"]
|
||||||
|
if o["kyc_session"] and st not in FINAL:
|
||||||
|
try:
|
||||||
|
st = didit_status(o["kyc_session"]) or st
|
||||||
|
except Exception:
|
||||||
|
return o
|
||||||
|
c.execute("update ap_orders set kyc_status=?, updated_at=datetime('now') where token=?", (st, o["token"]))
|
||||||
|
if st in FINAL and o["kyc_notified"] != st:
|
||||||
|
tg(f"{'✅' if st == 'Approved' else '⛔'} KYC {st} — Autopilot $99\n👤 {o['name']} · {o['email']}"
|
||||||
|
+ (f"\n🏢 {o['company']}" if o["company"] else ""))
|
||||||
|
c.execute("update ap_orders set kyc_notified=? where token=?", (st, o["token"]))
|
||||||
|
c.commit()
|
||||||
|
o = c.execute("select * from ap_orders where token=?", (o["token"],)).fetchone()
|
||||||
|
return o
|
||||||
|
|
||||||
|
|
||||||
|
def ap_start(d, ip):
|
||||||
|
if clean(d, "company_hp"): # honeypot
|
||||||
|
return 200, {"ok": True, "token": "x" * 16}
|
||||||
|
if limited("ap", ip, 5):
|
||||||
|
return 429, {"error": "Too many attempts from this network — try again in an hour."}
|
||||||
|
name, email, company = clean(d, "name", 120), clean(d, "email", 254).lower(), clean(d, "company", 160)
|
||||||
|
if not name:
|
||||||
|
return 400, {"error": "Please enter your full name (as in your ID)."}
|
||||||
|
if not EMAIL_RE.match(email):
|
||||||
|
return 400, {"error": "Please enter a valid email."}
|
||||||
|
if not d.get("consent"):
|
||||||
|
return 400, {"error": "Please accept the terms and identity verification."}
|
||||||
|
token = secrets.token_urlsafe(16)
|
||||||
|
try:
|
||||||
|
s = didit_session(KYC_WF, f"shkor-ap:{token}", f"{PUBLIC_BASE}/start/status/?t={token}")
|
||||||
|
except Exception:
|
||||||
|
return 502, {"error": "Identity verification is unavailable right now. Please try again in a few minutes."}
|
||||||
|
c = db()
|
||||||
|
c.execute("insert into ap_orders(token, name, email, company, ip, kyc_session, kyc_url, kyc_status) "
|
||||||
|
"values (?,?,?,?,?,?,?,?)", (token, name, email, company, ip, s.get("session_id", ""),
|
||||||
|
s.get("url", ""), s.get("status", "Not Started")))
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
tg(f"🆕 Autopilot $99 — начат KYC\n👤 {name} · {email}" + (f"\n🏢 {company}" if company else ""))
|
||||||
|
return 200, {"ok": True, "token": token, "kyc_url": s.get("url", ""),
|
||||||
|
"status_url": f"{PUBLIC_BASE}/start/status/?t={token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def ap_status(token):
|
||||||
|
c = db()
|
||||||
|
o = c.execute("select * from ap_orders where token=?", (token,)).fetchone()
|
||||||
|
if not o:
|
||||||
|
c.close()
|
||||||
|
return 404, {"error": "Order not found."}
|
||||||
|
o = ap_refresh(c, o)
|
||||||
|
out = {"ok": True, "name": o["name"], "email": o["email"], "kyc": o["kyc_status"]}
|
||||||
|
if o["kyc_status"] != "Approved":
|
||||||
|
out["stage"] = "declined" if o["kyc_status"] in FINAL else "kyc"
|
||||||
|
out["kyc_url"] = "" if o["kyc_status"] in FINAL else o["kyc_url"]
|
||||||
|
elif not o["pending_id"]:
|
||||||
|
out["stage"] = "pay"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with AP_LOCK:
|
||||||
|
r = portal(f"/api/onboarding/status?pending_id={o['pending_id']}", timeout=120)
|
||||||
|
except Exception:
|
||||||
|
r = {"status": "pending"}
|
||||||
|
if r.get("status") != "ready":
|
||||||
|
out["stage"] = "pay"
|
||||||
|
else:
|
||||||
|
if not o["paid_notified"]:
|
||||||
|
tg(f"💳 Autopilot $99 оплачен\n👤 {o['name']} · {o['email']}\n🧩 тенант {r.get('tenant_slug')}")
|
||||||
|
c.execute("update ap_orders set paid_notified=1 where token=?", (token,))
|
||||||
|
c.commit()
|
||||||
|
panel = r.get("panel")
|
||||||
|
out["stage"] = "ready" if panel else "deploying"
|
||||||
|
out["agent"] = r.get("agent")
|
||||||
|
out["portal_url"] = r.get("portal_url", "")
|
||||||
|
out["panel"] = panel
|
||||||
|
c.close()
|
||||||
|
return 200, out
|
||||||
|
|
||||||
|
|
||||||
|
def ap_checkout(d):
|
||||||
|
token = clean(d, "t", 64)
|
||||||
|
c = db()
|
||||||
|
o = c.execute("select * from ap_orders where token=?", (token,)).fetchone()
|
||||||
|
if not o:
|
||||||
|
c.close()
|
||||||
|
return 404, {"error": "Order not found."}
|
||||||
|
o = ap_refresh(c, o)
|
||||||
|
if o["kyc_status"] != "Approved":
|
||||||
|
c.close()
|
||||||
|
return 403, {"error": "Payment opens after your identity is verified."}
|
||||||
|
try:
|
||||||
|
pid = o["pending_id"]
|
||||||
|
if not pid:
|
||||||
|
pid = portal("/api/onboarding/start", {"email": o["email"], "plan": "autopilot"})["pending_id"]
|
||||||
|
c.execute("update ap_orders set pending_id=? where token=?", (pid, token))
|
||||||
|
c.commit()
|
||||||
|
label = re.sub(r"[^a-z0-9]+", "-", (o["company"] or o["name"]).lower()).strip("-")[:16] or "autopilot"
|
||||||
|
portal("/api/onboarding/save", {"pending_id": pid, "company_name": o["company"] or o["name"],
|
||||||
|
"agent_name": f"{label}-{secrets.token_hex(2)}"})
|
||||||
|
r = portal("/api/onboarding/checkout", {"pending_id": pid, "plan": "autopilot",
|
||||||
|
"return_url": f"{PUBLIC_BASE}/start/status/?t={token}"})
|
||||||
|
except Exception:
|
||||||
|
c.close()
|
||||||
|
return 502, {"error": "Couldn't open the payment page. Please try again."}
|
||||||
|
c.close()
|
||||||
|
if not r.get("url"):
|
||||||
|
return 502, {"error": r.get("error") or "Couldn't open the payment page."}
|
||||||
|
return 200, {"ok": True, "url": r["url"]}
|
||||||
|
|
||||||
|
|
||||||
def lab_status(token):
|
def lab_status(token):
|
||||||
c = db()
|
c = db()
|
||||||
app = c.execute("select * from lab_apps where token=?", (token,)).fetchone()
|
app = c.execute("select * from lab_apps where token=?", (token,)).fetchone()
|
||||||
|
|
@ -253,6 +390,11 @@ class H(BaseHTTPRequestHandler):
|
||||||
if not re.fullmatch(r"[A-Za-z0-9_-]{10,64}", t):
|
if not re.fullmatch(r"[A-Za-z0-9_-]{10,64}", t):
|
||||||
return self.reply(400, {"error": "Bad link."})
|
return self.reply(400, {"error": "Bad link."})
|
||||||
return self.reply(*lab_status(t))
|
return self.reply(*lab_status(t))
|
||||||
|
if u.path.rstrip("/") == "/api/autopilot/status":
|
||||||
|
t = (parse_qs(u.query).get("t") or [""])[0]
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_-]{10,64}", t):
|
||||||
|
return self.reply(400, {"error": "Bad link."})
|
||||||
|
return self.reply(*ap_status(t))
|
||||||
self.reply(404, {"error": "not found"})
|
self.reply(404, {"error": "not found"})
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
|
|
@ -266,6 +408,12 @@ class H(BaseHTTPRequestHandler):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return self.reply(400, {"error": "bad json"})
|
return self.reply(400, {"error": "bad json"})
|
||||||
return self.reply(*lab_apply(d, self.ip()))
|
return self.reply(*lab_apply(d, self.ip()))
|
||||||
|
if path in ("/api/autopilot/start", "/api/autopilot/checkout"):
|
||||||
|
try:
|
||||||
|
d = json.loads(raw or "{}")
|
||||||
|
except ValueError:
|
||||||
|
return self.reply(400, {"error": "bad json"})
|
||||||
|
return self.reply(*(ap_start(d, self.ip()) if path.endswith("start") else ap_checkout(d)))
|
||||||
if path != "/api/subscribe":
|
if path != "/api/subscribe":
|
||||||
return self.reply(404, {"error": "not found"})
|
return self.reply(404, {"error": "not found"})
|
||||||
form = {k: v[0] for k, v in parse_qs(raw).items()}
|
form = {k: v[0] for k, v in parse_qs(raw).items()}
|
||||||
|
|
|
||||||
62
sites/shkor-com/test_autopilot.py
Normal file
62
sites/shkor-com/test_autopilot.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Тест флоу Autopilot $99 (shkor.com/start): KYC → оплата → деплой. Портал и Didit — фейки.
|
||||||
|
python3 sites/shkor-com/test_autopilot.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from urllib.parse import urlsplit, parse_qs
|
||||||
|
S={"calls":[], "paid":False}
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def r(s,o):
|
||||||
|
b=json.dumps(o).encode(); s.send_response(200); s.send_header("Content-Type","application/json"); s.end_headers(); s.wfile.write(b)
|
||||||
|
def do_POST(s):
|
||||||
|
d=json.loads(s.rfile.read(int(s.headers["Content-Length"])))
|
||||||
|
p=urlsplit(s.path).path; S["calls"].append((p,d))
|
||||||
|
if p=="/api/onboarding/start": return s.r({"pending_id":"pid123"})
|
||||||
|
if p=="/api/onboarding/save": return s.r({"ok":True})
|
||||||
|
if p=="/api/onboarding/checkout": return s.r({"url":"https://checkout.stripe.test/x"})
|
||||||
|
if p=="/__pay": S["paid"]=True; return s.r({})
|
||||||
|
if p=="/__calls": return s.r(S["calls"])
|
||||||
|
def do_GET(s):
|
||||||
|
if S["paid"]: return s.r({"status":"ready","tenant_slug":"t1","portal_url":"https://t1.cil.by","agent":{"name":"a","status":"running"},"panel":{"url":"https://a.ui.shkor.com/","user":"a","password":"p"}})
|
||||||
|
return s.r({"status":"pending"})
|
||||||
|
def log_message(s,*a): pass
|
||||||
|
|
||||||
|
|
||||||
|
import os, sys, sqlite3, importlib.util, threading
|
||||||
|
threading.Thread(target=ThreadingHTTPServer(("127.0.0.1",18899),H).serve_forever,daemon=True).start()
|
||||||
|
import tempfile
|
||||||
|
TMP=tempfile.mkdtemp()
|
||||||
|
os.environ.update(SHKOR_DB=TMP+"/t.db", PORTAL_URL="http://127.0.0.1:18899", TG_ENV_FILE="/nonexistent")
|
||||||
|
spec=importlib.util.spec_from_file_location("api",os.path.join(os.path.dirname(os.path.abspath(__file__)),"api.py")); api=importlib.util.module_from_spec(spec); spec.loader.exec_module(api)
|
||||||
|
st={"s":"Not Started"}
|
||||||
|
api.didit_session=lambda wf,v,cb: {"session_id":"sid1","url":"https://verify.didit.me/x","status":"Not Started"}
|
||||||
|
api.didit_status=lambda sid: st["s"]
|
||||||
|
ok=0; bad=0
|
||||||
|
def eq(a,b,m):
|
||||||
|
global ok,bad
|
||||||
|
if a==b: ok+=1
|
||||||
|
else: bad+=1; print("FAIL",m,a,"!=",b)
|
||||||
|
code,r=api.ap_start({"name":"","email":"x@y.com","consent":True},"1.1.1.1"); eq(code,400,"name required")
|
||||||
|
code,r=api.ap_start({"name":"A B","email":"bad","consent":True},"1.1.1.1"); eq(code,400,"email")
|
||||||
|
code,r=api.ap_start({"name":"A B","email":"a@b.com"},"1.1.1.1"); eq(code,400,"consent")
|
||||||
|
code,r=api.ap_start({"name":"Ann Bee","email":"A@B.com","company":"Acme Co","consent":True},"1.1.1.1"); eq(code,200,"start"); t=r["token"]
|
||||||
|
eq(r["kyc_url"],"https://verify.didit.me/x","kyc url")
|
||||||
|
code,r=api.ap_status(t); eq(r["stage"],"kyc","stage kyc")
|
||||||
|
code,r=api.ap_checkout({"t":t}); eq(code,403,"checkout blocked before kyc")
|
||||||
|
st["s"]="Declined"; code,r=api.ap_status(t); eq(r["stage"],"declined","declined"); eq(r["kyc_url"],"","no url when final")
|
||||||
|
# new order approved
|
||||||
|
st["s"]="Not Started"; code,r=api.ap_start({"name":"Ann Bee","email":"a@b.com","company":"Acme Co","consent":True},"2.2.2.2"); t=r["token"]
|
||||||
|
st["s"]="Approved"; code,r=api.ap_status(t); eq(r["stage"],"pay","pay stage")
|
||||||
|
code,r=api.ap_checkout({"t":t}); eq(code,200,"checkout ok"); eq(r["url"],"https://checkout.stripe.test/x","stripe url")
|
||||||
|
code,r=api.ap_checkout({"t":t}); eq(code,200,"checkout again reuses pending")
|
||||||
|
code,r=api.ap_status(t); eq(r["stage"],"pay","still pay before payment")
|
||||||
|
import urllib.request
|
||||||
|
def call(p): return json.loads(urllib.request.urlopen(urllib.request.Request("http://127.0.0.1:18899"+p,data=b"{}",headers={"Content-Type":"application/json"})).read())
|
||||||
|
calls=call("/__calls"); starts=[c for c in calls if c[0]=="/api/onboarding/start"]; eq(len(starts),1,"one pending created")
|
||||||
|
eq(starts[0][1],{"email":"a@b.com","plan":"autopilot"},"plan autopilot")
|
||||||
|
co=[c for c in calls if c[0]=="/api/onboarding/checkout"][0][1]; eq(co["return_url"],f"https://shkor.com/start/status/?t={t}","return url")
|
||||||
|
sv=[c for c in calls if c[0]=="/api/onboarding/save"][0][1]; eq(sv["agent_name"].startswith("acme-co-"),True,"agent name")
|
||||||
|
call("/__pay"); code,r=api.ap_status(t); eq(r["stage"],"ready","ready"); eq(r["panel"]["user"],"a","panel creds")
|
||||||
|
c=sqlite3.connect(TMP+"/t.db"); eq(c.execute("select paid_notified from ap_orders where token=?",(t,)).fetchone()[0],1,"paid flag")
|
||||||
|
code,r=api.ap_status("nope-nope-nope"); eq(code,404,"404")
|
||||||
|
print(f"{ok} ok, {bad} fail"); sys.exit(1 if bad else 0)
|
||||||
|
|
@ -242,10 +242,10 @@
|
||||||
<article class="path reveal">
|
<article class="path reveal">
|
||||||
<p class="path-k">For companies</p>
|
<p class="path-k">For companies</p>
|
||||||
<h3>Put your business on autopilot</h3>
|
<h3>Put your business on autopilot</h3>
|
||||||
<p>Your own Simbiot — the same system our labs run in production, not a cut-down demo. Deployment and onboarding within the month you join; LLM access through the cooperative's router included.</p>
|
<p>Your own server with a web panel: Claude Code and OpenCode to build with, and a Simbiot agent — the same system our labs run in production. Verify, pay, and it deploys itself.</p>
|
||||||
<p class="price">$100<span>/month</span></p>
|
<p class="price">$99<span>/month</span></p>
|
||||||
<p class="muted">One price for everyone. 10 presale seats.</p>
|
<p class="muted">One price for everyone. Cancel any time.</p>
|
||||||
<a class="btn" href="https://presale.cil.by/" rel="noopener"><span>Reserve a seat</span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
|
<a class="btn" href="start/"><span>Start now</span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
|
||||||
</article>
|
</article>
|
||||||
<article class="path path-alt reveal">
|
<article class="path path-alt reveal">
|
||||||
<p class="path-k">For founders</p>
|
<p class="path-k">For founders</p>
|
||||||
|
|
|
||||||
84
sites/shkor-com/v2/start/index.html
Normal file
84
sites/shkor-com/v2/start/index.html
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#050506">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%23050506'/><circle cx='32' cy='32' r='20' fill='none' stroke='%23c6ff4d' stroke-width='3' stroke-dasharray='4 6'/><circle cx='32' cy='32' r='7' fill='%23c6ff4d'/></svg>">
|
||||||
|
<title>Start your autopilot — Shkor Labs</title>
|
||||||
|
<meta name="description" content="Your own cloud workspace with Claude Code, OpenCode and the Simbiot autopilot. $99/month.">
|
||||||
|
<link rel="stylesheet" href="../styles.css">
|
||||||
|
<link rel="stylesheet" href="../apply/apply.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href="../" aria-label="Shkor Labs — home">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="12" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 4"/><circle cx="16" cy="16" r="4.5" fill="currentColor"/></svg>
|
||||||
|
<span>Shkor<b>Labs</b></span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Main"><a class="btn btn-sm btn-ghost" href="../">Back to site</a></nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="apply">
|
||||||
|
<div class="grid-bg" aria-hidden="true"></div>
|
||||||
|
<div class="wrap apply-in">
|
||||||
|
<aside class="apply-side">
|
||||||
|
<p class="kicker">Autopilot · $99/month</p>
|
||||||
|
<h1>Your own server.<br><span class="hl">Your own builders.</span></h1>
|
||||||
|
<p class="lead">A dedicated cloud workspace with a web panel: Claude Code and OpenCode ready to build your product, plus a Simbiot agent — the same stack our labs run on.</p>
|
||||||
|
<ol class="flow" aria-label="How it works">
|
||||||
|
<li><span>1</span><div><b>Verify your identity</b><small>ID + selfie with Didit. About 2 minutes. You get a whole server — we need to know who you are.</small></div></li>
|
||||||
|
<li><span>2</span><div><b>Pay $99/month</b><small>Secure card payment by Stripe. Cancel any time.</small></div></li>
|
||||||
|
<li><span>3</span><div><b>We deploy your workspace</b><small>Your server, web panel and agent come up automatically — usually within the hour.</small></div></li>
|
||||||
|
<li><span>4</span><div><b>Build</b><small>Open the panel, sign in to Claude with your own account or use OpenCode right away.</small></div></li>
|
||||||
|
</ol>
|
||||||
|
<div class="why">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3Z M9 12l2 2 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<p><b>Building a business on it?</b> Tell us what share of revenue you'd give back to the community — it's voluntary, and it's how the best builders get noticed by the studio.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="apply-card" aria-labelledby="form-h">
|
||||||
|
<form id="start" novalidate>
|
||||||
|
<fieldset>
|
||||||
|
<legend><span class="kicker">Step 1 of 3</span>About you</legend>
|
||||||
|
<div class="field"><label for="f-name">Full name, as in your ID <em>*</em></label><input id="f-name" name="name" autocomplete="name" required maxlength="120"></div>
|
||||||
|
<div class="field"><label for="f-email">Email <em>*</em></label><input id="f-email" name="email" type="email" autocomplete="email" required maxlength="254"><small>Your workspace login and receipts go here.</small></div>
|
||||||
|
<div class="field"><label for="f-company">Company or project</label><input id="f-company" name="company" maxlength="160" placeholder="Optional"></div>
|
||||||
|
<input name="company_hp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">
|
||||||
|
<div class="consent">
|
||||||
|
<label class="check"><input type="checkbox" name="consent" required><span>I agree that Shkor Labs and its verification partner <a href="https://didit.me" target="_blank" rel="noopener">Didit</a> verify my identity (ID document and selfie), and that the workspace may not be used for anything illegal, abusive or for attacking other systems.</span></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<p class="form-err" role="alert" aria-live="assertive"></p>
|
||||||
|
<div class="actions"><button class="btn" id="go" type="submit"><span>Verify identity</span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
|
||||||
|
<p class="note" style="margin-top:20px">Payment opens right after verification. Nothing is charged before that.</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var f = document.getElementById('start'), go = document.getElementById('go'), err = f.querySelector('.form-err');
|
||||||
|
f.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var bad = null;
|
||||||
|
f.querySelectorAll('input[required]').forEach(function (el) { var ok = el.checkValidity(); el.setAttribute('aria-invalid', ok ? 'false' : 'true'); if (!ok && !bad) bad = el; });
|
||||||
|
if (bad) { err.textContent = bad.type === 'checkbox' ? 'Please accept the terms and identity verification.' : bad.type === 'email' ? 'Please enter a valid email.' : 'Please enter your full name.'; bad.focus(); return; }
|
||||||
|
var d = { name: f.name.value, email: f.email.value, company: f.company.value, company_hp: f.company_hp.value, consent: f.consent.checked };
|
||||||
|
go.disabled = true; go.querySelector('span').textContent = 'Starting verification…';
|
||||||
|
fetch('/api/autopilot/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error(res.j.error || 'Something went wrong.');
|
||||||
|
try { localStorage.setItem('shkor_ap', res.j.token); } catch (x) {}
|
||||||
|
location.href = res.j.kyc_url || ('status/?t=' + encodeURIComponent(res.j.token));
|
||||||
|
})
|
||||||
|
.catch(function (x) { err.textContent = x.message || 'Network error — please try again.'; go.disabled = false; go.querySelector('span').textContent = 'Verify identity'; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
96
sites/shkor-com/v2/start/status/index.html
Normal file
96
sites/shkor-com/v2/start/status/index.html
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#050506">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%23050506'/><circle cx='32' cy='32' r='20' fill='none' stroke='%23c6ff4d' stroke-width='3' stroke-dasharray='4 6'/><circle cx='32' cy='32' r='7' fill='%23c6ff4d'/></svg>">
|
||||||
|
<title>Your workspace — Shkor Labs</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<link rel="stylesheet" href="../../styles.css">
|
||||||
|
<link rel="stylesheet" href="../../apply/apply.css">
|
||||||
|
<style>.creds{margin-top:16px;padding:16px 18px;border-radius:12px;background:rgba(255,255,255,.035);box-shadow:inset 0 0 0 1px var(--line-2);font:14px/1.9 var(--mono);color:var(--fg-2)}.creds b{display:inline;font:inherit;color:var(--fg)}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href="../../" aria-label="Shkor Labs — home">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="12" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 4"/><circle cx="16" cy="16" r="4.5" fill="currentColor"/></svg>
|
||||||
|
<span>Shkor<b>Labs</b></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="main" class="apply">
|
||||||
|
<div class="grid-bg" aria-hidden="true"></div>
|
||||||
|
<div class="wrap status-in">
|
||||||
|
<p class="kicker">Autopilot · $99/month</p>
|
||||||
|
<h1 id="title">Loading…</h1>
|
||||||
|
<p class="lead" id="sub"></p>
|
||||||
|
<ol class="timeline" id="tl" aria-live="polite"></ol>
|
||||||
|
<div class="bookmark">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4V3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||||
|
<p><b>Bookmark this page.</b> It's your private link: come back any time to finish a step or find your workspace login. Don't share it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var q = new URLSearchParams(location.search), t = q.get('t') || '';
|
||||||
|
try { if (!t) t = localStorage.getItem('shkor_ap') || ''; } catch (x) {}
|
||||||
|
var tl = document.getElementById('tl'), title = document.getElementById('title'), sub = document.getElementById('sub'), busy = false;
|
||||||
|
var ARROW = '<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
var KYC = { 'Not Started': 'Not started', 'In Progress': 'In progress', 'In Review': 'Under review', 'Approved': 'Verified', 'Declined': 'Not approved', 'Abandoned': 'Abandoned', 'Expired': 'Link expired' };
|
||||||
|
function esc(s) { return String(s || '').replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
||||||
|
function item(state, head, body, action) { return '<li class="tl ' + state + '"><span class="tl-dot" aria-hidden="true"></span><div><b>' + head + '</b><p>' + body + '</p>' + (action || '') + '</div></li>'; }
|
||||||
|
function pill(state, text) { return ' <span class="pill ' + state + '">' + esc(text) + '</span>'; }
|
||||||
|
function render(d) {
|
||||||
|
var first = (d.name || '').split(' ')[0], s = d.stage, h = '';
|
||||||
|
var kycState = s === 'kyc' ? 'wip' : s === 'declined' ? 'bad' : 'ok';
|
||||||
|
h += item(kycState, 'Identity verification' + pill(kycState, KYC[d.kyc] || d.kyc),
|
||||||
|
s === 'declined' ? 'We couldn’t verify your identity, so we can’t open a workspace. If you think this is a mistake, write to hello@shkor.com.' : 'ID document and a selfie, handled by Didit. About 2 minutes.',
|
||||||
|
d.kyc_url ? '<a class="btn" href="' + esc(d.kyc_url) + '"><span>' + (d.kyc === 'Not Started' ? 'Verify now' : 'Continue verification') + '</span>' + ARROW + '</a>' : '');
|
||||||
|
var payState = s === 'pay' ? 'wip' : (s === 'deploying' || s === 'ready') ? 'ok' : 'todo';
|
||||||
|
var cancelled = q.get('cancelled') && s === 'pay';
|
||||||
|
h += item(payState, 'Payment · $99/month' + (payState === 'ok' ? pill('ok', 'Paid') : ''),
|
||||||
|
s === 'pay' ? (q.get('paid') ? 'Confirming your payment…' : cancelled ? 'Payment was cancelled — you can try again.' : 'Secure card payment by Stripe. Monthly, cancel any time.') : payState === 'ok' ? 'Subscription active. Receipts go to ' + esc(d.email) + '.' : 'Opens after verification.',
|
||||||
|
s === 'pay' && !q.get('paid') ? '<button class="btn" id="pay" type="button"><span>Pay $99 and deploy</span>' + ARROW + '</button><p class="form-err" id="payerr"></p>' : '');
|
||||||
|
var depState = s === 'deploying' ? 'wip' : s === 'ready' ? 'ok' : 'todo';
|
||||||
|
var dep = 'Your own server, web panel with Claude Code and OpenCode, and a Simbiot agent.';
|
||||||
|
var act = '';
|
||||||
|
if (s === 'deploying') dep = 'Creating your server and installing the tools. This usually takes 15–40 minutes — this page updates by itself, and you can close it.';
|
||||||
|
if (s === 'ready' && d.panel) {
|
||||||
|
dep = 'Your workspace is live. Sign in to the panel, then pick Claude Code (sign in with your own Claude account) or OpenCode (works right away).';
|
||||||
|
act = '<div class="creds">Panel: <b><a href="' + esc(d.panel.url) + '" target="_blank" rel="noopener">' + esc(d.panel.url) + '</a></b><br>Login: <b>' + esc(d.panel.user) + '</b><br>Password: <b>' + esc(d.panel.password) + '</b></div>'
|
||||||
|
+ '<a class="btn" href="' + esc(d.panel.url) + '" target="_blank" rel="noopener"><span>Open my workspace</span>' + ARROW + '</a>';
|
||||||
|
}
|
||||||
|
h += item(depState, 'Your workspace' + (s === 'ready' ? pill('ok', 'Live') : s === 'deploying' ? pill('wip', 'Deploying') : ''), dep, act);
|
||||||
|
tl.innerHTML = h;
|
||||||
|
title.textContent = s === 'ready' ? 'Welcome aboard, ' + first + '.' : s === 'deploying' ? 'Deploying your workspace…' : s === 'declined' ? 'Verification not approved' : s === 'pay' ? 'Verified. One step left.' : 'Hi ' + first + ', let’s verify you.';
|
||||||
|
sub.textContent = s === 'kyc' ? 'Payment opens as soon as your identity is verified. If you’ve just finished, the result usually arrives within a minute.' : '';
|
||||||
|
var pay = document.getElementById('pay');
|
||||||
|
if (pay) pay.addEventListener('click', function () {
|
||||||
|
pay.disabled = true; pay.querySelector('span').textContent = 'Opening Stripe…';
|
||||||
|
fetch('/api/autopilot/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ t: t }) })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) { if (!res.ok || !res.j.url) throw new Error(res.j.error || 'Couldn’t open the payment page.'); location.href = res.j.url; })
|
||||||
|
.catch(function (x) { document.getElementById('payerr').textContent = x.message; pay.disabled = false; pay.querySelector('span').textContent = 'Pay $99 and deploy'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function load() {
|
||||||
|
if (busy) return; busy = true;
|
||||||
|
fetch('/api/autopilot/status?t=' + encodeURIComponent(t), { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) { title.textContent = 'Order not found'; sub.innerHTML = esc(res.j.error || 'Check your link.') + ' <a href="../">Start again</a>.'; return; }
|
||||||
|
render(res.j);
|
||||||
|
})
|
||||||
|
.catch(function () { title.textContent = 'Can’t load right now'; sub.textContent = 'Please refresh in a moment.'; })
|
||||||
|
.then(function () { busy = false; });
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
setInterval(function () { if (!document.hidden && !document.getElementById('pay')) load(); }, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue