diff --git a/sites/shkor-com/api.py b/sites/shkor-com/api.py index 20eefeb..60b0bdc 100644 --- a/sites/shkor-com/api.py +++ b/sites/shkor-com/api.py @@ -7,6 +7,10 @@ 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) + POST /api/account/login JSON email -> magic link by email (only for existing orders) + GET /api/account/session?k=|t= -> session cookie (30 days) -> redirect /account/ + GET /api/account/me cabinet: order status, workspace, level ladder, project, products, earnings + POST /api/account/{agreement,project,product,product-live,logout} Env: SHKOR_DB sqlite path (default /var/lib/shkor-subscribe/subs.db) @@ -19,7 +23,7 @@ Env: TG_ENV_FILE file with APPLY42_TOKEN= (default /root/coop/social/.env) TG_CHAT_ID default 7569223131 """ -import json, os, re, secrets, sqlite3, threading, time, urllib.error, urllib.request +import hashlib, json, os, re, secrets, sqlite3, threading, time, urllib.error, urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlsplit @@ -70,6 +74,10 @@ def db(): 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 acc_links ( + k text primary key, email text, created_at real, used integer default 0); + create table if not exists acc_sessions ( + sid text primary key, email text, created_at real, ip text); create table if not exists lab_checks ( token text, kind text, session_id text, url text, status text, notified text default '', updated_at text default (datetime('now')), @@ -349,6 +357,144 @@ def ap_checkout(d): return 200, {"ok": True, "url": r["url"]} +# ── Кабинет (shkor.com/account) ── +SESSION_DAYS = 30 +AGREEMENT_FILE = os.environ.get("AGREEMENT_FILE", "/var/www/shkor-com-live/account/agreement/index.html") +AGREEMENT_VERSION = "contractor-v1" + + +def send_email(to, subject, html): + key = env_value(os.environ.get("DIDIT_ENV_FILE", "/root/coop/.env"), "RESEND_API_KEY") + if not key: + return False + try: + http_json("https://api.resend.com/emails", {"from": "Shkor Labs ", "reply_to": "hello@shkor.com", + "to": [to], "subject": subject, "html": html}, + {"Authorization": f"Bearer {key}", "User-Agent": "shkor-api/1.0"}, timeout=15) + return True + except Exception: + return False + + +def acc_login(d, ip): + email = clean(d, "email", 254).lower() + if limited("login", ip, 8): + return 429, {"error": "Too many attempts — try again later."} + if not EMAIL_RE.match(email): + return 400, {"error": "Please enter a valid email."} + c = db() + has = c.execute("select 1 from ap_orders where email=?", (email,)).fetchone() + if has: + k = secrets.token_urlsafe(24) + c.execute("insert into acc_links(k, email, created_at) values (?,?,?)", (k, email, time.time())) + c.commit() + link = f"{PUBLIC_BASE}/api/account/session?k={k}" + send_email(email, "Your Shkor Labs sign-in link", + f"

Hi! Here's your sign-in link to your Shkor Labs account:

" + f"

Sign in to Shkor Labs

" + f"

The link works once and expires in 30 minutes. If you didn't ask for it, ignore this email.

") + c.close() + # одинаковый ответ — не раскрываем, есть ли такой email + return 200, {"ok": True} + + +def acc_session(q, ip): + """Обмен одноразовой ссылки (k) или ссылки заказа (t) на сессию. -> (sid|None)""" + c = db() + email = None + k, t = (q.get("k") or [""])[0], (q.get("t") or [""])[0] + if k: + r = c.execute("select email, created_at, used from acc_links where k=?", (k,)).fetchone() + if r and not r["used"] and time.time() - r["created_at"] < 1800: + email = r["email"] + c.execute("update acc_links set used=1 where k=?", (k,)) + elif re.fullmatch(r"[A-Za-z0-9_-]{10,64}", t or ""): + r = c.execute("select email from ap_orders where token=?", (t,)).fetchone() + email = r["email"] if r else None + sid = None + if email: + sid = secrets.token_urlsafe(32) + c.execute("insert into acc_sessions(sid, email, created_at, ip) values (?,?,?,?)", (sid, email, time.time(), ip)) + c.commit() + c.close() + return sid + + +def acc_email(sid): + if not sid: + return None + c = db() + r = c.execute("select email, created_at from acc_sessions where sid=?", (sid,)).fetchone() + c.close() + if r and time.time() - r["created_at"] < SESSION_DAYS * 86400: + return r["email"] + return None + + +def acc_order(email): + c = db() + o = c.execute("select * from ap_orders where email=? order by (pending_id!='') desc, " + "(kyc_status='Approved') desc, created_at desc limit 1", (email,)).fetchone() + c.close() + return o + + +def agreement_sha(): + try: + return hashlib.sha256(open(AGREEMENT_FILE, "rb").read()).hexdigest() + except OSError: + return "" + + +def acc_me(email): + o = acc_order(email) + if not o: + return 404, {"error": "No account for this email."} + code, st = ap_status(o["token"]) + st["token"] = o["token"] + st["agreement_sha256"] = agreement_sha() + if st.get("stage") in ("ready", "deploying") and o["pending_id"]: + try: + st["cabinet"] = portal("/api/onboarding/cabinet", {"pending_id": o["pending_id"]}) + except Exception: + st["cabinet"] = None + return code, st + + +def acc_action(email, action, d, ip): + o = acc_order(email) + if not o or not o["pending_id"]: + return 409, {"error": "Finish verification and payment first."} + body = {"pending_id": o["pending_id"]} + if action == "agreement": + body.update(signer_name=clean(d, "signer_name", 120), accept=bool(d.get("accept")), version=AGREEMENT_VERSION, + text_sha256=agreement_sha(), ip=ip, kyc_session=o["kyc_session"]) + if body["signer_name"].lower() != (o["name"] or "").strip().lower(): + return 400, {"error": f"Type your full name exactly as verified: {o['name']}"} + elif action == "project": + body.update(name=clean(d, "name", 120), description=clean(d, "description", 600), + studio_pct=d.get("studio_pct"), + decisions={k: clean(d.get("decisions") or {}, k, 600) for k in + ("d1_mission", "d2_vision", "d3_market", "d4_need", "d5_principle", "d6_product", "d7_tech")}) + elif action == "product": + body.update(name=clean(d, "name", 120), price_usd=d.get("price_usd"), interval=clean(d, "interval", 12), + description=clean(d, "description", 300)) + elif action == "product-live": + body.update(product_id=d.get("product_id"), note=clean(d, "note", 1000)) + else: + return 404, {"error": "not found"} + path = {"product-live": "/api/onboarding/product/live"}.get(action, f"/api/onboarding/{action}") + try: + return 200, portal(path, body) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read().decode()) + except Exception: + return 502, {"error": "Something went wrong. Please try again."} + except Exception: + return 502, {"error": "Something went wrong. Please try again."} + + def lab_status(token): c = db() app = c.execute("select * from lab_apps where token=?", (token,)).fetchone() @@ -366,9 +512,23 @@ def lab_status(token): class H(BaseHTTPRequestHandler): - def reply(self, code, obj): + def sid(self): + m = re.search(r"(?:^|;\s*)shk_s=([A-Za-z0-9_-]{20,80})", self.headers.get("Cookie", "")) + return m.group(1) if m else None + + def redirect(self, url, cookie=None): + self.send_response(303) + self.send_header("Location", url) + if cookie is not None: + self.send_header("Set-Cookie", cookie) + self.send_header("Cache-Control", "no-store") + self.end_headers() + + def reply(self, code, obj, cookie=None): body = json.dumps(obj).encode() self.send_response(code) + if cookie is not None: + self.send_header("Set-Cookie", cookie) self.send_header("Content-Type", "application/json") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(body))) @@ -391,6 +551,16 @@ class H(BaseHTTPRequestHandler): if not re.fullmatch(r"[A-Za-z0-9_-]{10,64}", t): return self.reply(400, {"error": "Bad link."}) return self.reply(*lab_status(t)) + if u.path.rstrip("/") == "/api/account/session": + sid = acc_session(parse_qs(u.query), self.ip()) + if not sid: + return self.redirect("/account/?expired=1") + return self.redirect("/account/", f"shk_s={sid}; Path=/; Max-Age={SESSION_DAYS * 86400}; HttpOnly; Secure; SameSite=Lax") + if u.path.rstrip("/") == "/api/account/me": + email = acc_email(self.sid()) + if not email: + return self.reply(401, {"error": "Please sign in."}) + return self.reply(*acc_me(email)) 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): @@ -409,6 +579,22 @@ class H(BaseHTTPRequestHandler): except ValueError: return self.reply(400, {"error": "bad json"}) return self.reply(*lab_apply(d, self.ip())) + if path.startswith("/api/account/"): + try: + d = json.loads(raw or "{}") + except ValueError: + return self.reply(400, {"error": "bad json"}) + action = path.rsplit("/", 1)[1] + if action == "login": + return self.reply(*acc_login(d, self.ip())) + if action == "logout": + return self.reply(200, {"ok": True}, "shk_s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax") + email = acc_email(self.sid()) + if not email: + return self.reply(401, {"error": "Please sign in."}) + if limited("acc", self.ip(), 120): + return self.reply(429, {"error": "Too many requests — slow down a little."}) + return self.reply(*acc_action(email, action, d, self.ip())) if path in ("/api/autopilot/start", "/api/autopilot/checkout"): try: d = json.loads(raw or "{}") diff --git a/sites/shkor-com/test_account_flow.py b/sites/shkor-com/test_account_flow.py new file mode 100644 index 0000000..521c055 --- /dev/null +++ b/sites/shkor-com/test_account_flow.py @@ -0,0 +1,70 @@ +"""Тест кабинета shkor.com/account против ПЕСОЧНИЦЫ портала (регистрация проекта пишет в журнал событий). + bash /root/coop/scripts/portal_sandbox.sh && python3 sites/shkor-com/test_account_flow.py; pkill -f "[r]un_sandbox.py" +""" +import os, subprocess, tempfile, time, sqlite3 +SB = os.environ.get("SANDBOX_DIR", "/tmp/coopcopy") +HERE = os.path.dirname(os.path.abspath(__file__)) +sb = sqlite3.connect(SB + "/coop.db") +sb.executescript(""" +insert or ignore into users (username,name,password,email,role,tenant_id) values ('cabtest','Cab Tester','x','cabtest@shkor.com','Владелец',0); +insert into tenants (slug,name,owner_username,status) values ('cabtest-0001','Cab Co','cabtest','active'); +insert into agents (agent_name,tg_bot_token,status,created_by,harness_type,tenant_slug,mcp_scope,server_ip) values ('cabtest-agent','','running','cabtest','simbiot','cabtest-0001','community','127.0.0.9'); +insert or replace into onboarding_pending (pending_id,email,plan,status) values ('cabpid000000000000000000000000001','cabtest@shkor.com','autopilot','provisioned'); +""") +sb.commit() +TMP = tempfile.mkdtemp() +env = dict(os.environ, SHKOR_DB=TMP + "/s.db", PORT="18097", PORTAL_URL="http://127.0.0.1:18898", + AGREEMENT_FILE=HERE + "/v2/account/agreement/index.html", TG_ENV_FILE="/nonexistent", DIDIT_ENV_FILE="/nonexistent") +srv = subprocess.Popen(["python3", HERE + "/api.py"], env=env) +time.sleep(1.5) +sqlite3.connect(TMP + "/s.db").execute("insert into ap_orders(token,name,email,company,kyc_session,kyc_url,kyc_status,kyc_notified,pending_id,paid_notified) values ('cabtok000000000000aa','Cab Tester','cabtest@shkor.com','Cab Co','','','Approved','Approved','cabpid000000000000000000000000001',1)").connection.commit() +try: + import json, urllib.request, http.cookiejar, sqlite3, sys + B="http://127.0.0.1:18097" + cj=http.cookiejar.CookieJar() + class NoRedir(urllib.request.HTTPRedirectHandler): + def redirect_request(self,*a,**k): return None + op=urllib.request.build_opener(NoRedir, urllib.request.HTTPCookieProcessor(cj)) + ok=bad=0 + def eq(c,m): + global ok,bad + if c: ok+=1 + else: bad+=1; print("FAIL",m) + def req(path, body=None, cookie=None): + h={"Content-Type":"application/json","X-Forwarded-For":"9.9.9.9"} + if cookie: h["Cookie"]=cookie + r=urllib.request.Request(B+path, data=json.dumps(body).encode() if body is not None else None, headers=h, method="POST" if body is not None else "GET") + try: + x=op.open(r); return x.status, x.headers, x.read() + except urllib.error.HTTPError as e: return e.code, e.headers, e.read() + # session via t + c,h,_=req("/api/account/session?t=cabtok000000000000aa"); eq(c==303 and "/account/" in h["Location"],"redirect") + ck=h["Set-Cookie"]; eq("HttpOnly" in ck and "Secure" in ck,"cookie flags"); C=ck.split(";")[0] + c,_,_=req("/api/account/session?t=nope-nope-nope-nope"); eq(c==303,"bad t redirect") + c,_,b=req("/api/account/me"); eq(c==401,"no cookie 401") + c,_,b=req("/api/account/me",cookie=C); d=json.loads(b); eq(c==200,"me 200 "+b[:200].decode()) + cab=d.get("cabinet") or {}; eq(cab.get("level")==1,"level 1"); eq([s["done"] for s in cab.get("steps",[])]==[True,False,False,False,False],"steps "+str(cab.get("steps"))) + # agreement + c,_,b=req("/api/account/agreement",{"signer_name":"Someone Else","accept":True},C); eq(c==400,"name must match") + c,_,b=req("/api/account/agreement",{"signer_name":"cab tester","accept":False},C); eq(c==400,"must accept "+b.decode()[:100]) + c,_,b=req("/api/account/agreement",{"signer_name":"Cab Tester","accept":True},C); r=json.loads(b); eq(c==200 and r.get("agreement"),"signed "+b.decode()[:200]) + # product before project + c,_,b=req("/api/account/product",{"name":"Pro","price_usd":29,"interval":"month"},C); eq(c==409,"product needs project") + # project + dec={k:"text "+k for k in ("d1_mission","d2_vision","d3_market","d4_need","d5_principle","d6_product","d7_tech")} + c,_,b=req("/api/account/project",{"name":"Cab Project","studio_pct":5,"decisions":dec},C); eq(c==400,"min 10%") + c,_,b=req("/api/account/project",{"name":"Cab Project","studio_pct":15,"description":"test","decisions":dec},C); r=json.loads(b); eq(c==200 and r.get("project",{}).get("studio_pct")==15,"project "+b.decode()[:300]) + eq(r["steps"][2]["done"],"project step done (7 decisions, 15%)") + pid=r["project"]["id"]; print("project", pid, r["project"].get("site_url")) + # product + c,_,b=req("/api/account/product",{"name":"Pro plan","price_usd":29,"interval":"month"},C); r=json.loads(b); eq(c==200 and r.get("products"),"product "+b.decode()[:300]) + p=r["products"][0]; eq(p["mode"]=="test","test mode"); eq(bool(p.get("link")) and "test" in (p.get("link") or "")+"test","has link "+str(p.get("link"))) + print("link", p.get("link")) + c,_,b=req("/api/account/product-live",{"product_id":p["id"],"note":"Monthly access"},C); r=json.loads(b); eq(c==200 and r["products"][0]["live_request"]=="pending","live requested "+b.decode()[:200]) + c,_,b=req("/api/account/product-live",{"product_id":999999},C); eq(c==404,"foreign product 404") + c,_,b=req("/api/account/me",cookie=C); d=json.loads(b); eq(d["cabinet"]["level"]==1 and d["cabinet"]["steps"][3]["done"] is False,"still level1 until live") + c,_,b=req("/api/account/logout",{},C); eq(c==200,"logout") + print(f"{ok} ok, {bad} fail") +finally: + srv.terminate() + sys.exit(1 if bad else 0) diff --git a/sites/shkor-com/v2/account/agreement/index.html b/sites/shkor-com/v2/account/agreement/index.html new file mode 100644 index 0000000..25c0cde --- /dev/null +++ b/sites/shkor-com/v2/account/agreement/index.html @@ -0,0 +1,65 @@ + + + + + +Contractor Agreement (contractor-v1) — Shkor Labs + + + + + +
+

Version contractor-v1 · effective 18 October 2026

+

Contractor Agreement

+

This agreement is between Shkor Labs LLC, a Wyoming limited liability company (the “Studio”), and the person who signs it electronically in their Shkor Labs account after verifying their identity (the “Builder”).

+ +

1. What this is

+

The Builder develops their own product or business (the “Project”) on the Studio’s platform — workspace, lab MCP, payment infrastructure and community — as an independent contractor in the Studio’s builder program. Signing is the first step toward Level 2 of the program.

+ +

2. Who owns what

+
    +
  1. The Builder owns the Project and everything the Builder creates for it, including code, content and brand.
  2. +
  3. The Studio owns its platform, tools, agents, templates and know-how. Using them does not transfer any rights in them.
  4. +
  5. The Builder grants the Studio a non-exclusive, royalty-free right to name and show the Project (name, logo, screenshots, public metrics) as part of the Studio’s portfolio.
  6. +
+ +

3. Studio share

+
    +
  1. The Studio receives the share of revenue the Builder sets for the Project in their account (the “Studio share”), not less than 10%.
  2. +
  3. The Studio share applies to Project revenue collected through the Studio’s payment infrastructure and is withheld automatically at each payment.
  4. +
  5. The Builder may raise the Studio share at any time; lowering it requires the Studio’s written consent.
  6. +
+ +

4. Payouts

+

The Builder’s balance (revenue minus the Studio share, payment processor fees, refunds and chargebacks) is shown in the account and paid out on request to a payout method in the Builder’s verified name, after a 14-day hold for each payment.

+ +

5. Live payments

+

New products start in test mode. Live payments are switched on after the Studio reviews what is being sold. The Studio may refuse or switch off live payments for products that are illegal, deceptive, high-risk or in breach of its payment providers’ rules.

+ +

6. Level 2 tools

+

Builders who meet the Level 2 conditions may receive additional tools, such as a coding-agent seat paid by the Studio, help from lab agents, studio contacts and the quarterly investment pool. These tools are provided for work on the Project, are personal and non-transferable, and may be limited or withdrawn at the Studio’s discretion.

+ +

7. Independent contractor

+

The Builder is an independent contractor, not an employee, partner or agent of the Studio, and is responsible for their own taxes, registrations and compliance. Nothing here obliges the Studio to pay a salary; any paid role in the Studio is a separate agreement.

+ +

8. Rules

+ + +

9. Term and ending

+

This agreement runs until either party ends it with 30 days’ notice through the account or by email, or immediately for a serious breach. The Studio share stays due on payments made before the end date; sections 2, 4 and 8 survive.

+ +

10. Other terms

+

This agreement is governed by the laws of the State of Wyoming, USA. The Builder signs electronically by typing their verified full name and accepting it in their account; the Studio records the version, a fingerprint of this text, the time and the identity verification reference. Questions: hello@shkor.com.

+
+ + diff --git a/sites/shkor-com/v2/account/index.html b/sites/shkor-com/v2/account/index.html new file mode 100644 index 0000000..7ccd3db --- /dev/null +++ b/sites/shkor-com/v2/account/index.html @@ -0,0 +1,218 @@ + + + + + +Your account — Shkor Labs + + + + + + + + + + + +
+ +

Your account

Loading…

+
+ + + + diff --git a/sites/shkor-com/v2/index.html b/sites/shkor-com/v2/index.html index 8f57ee2..bd6da3d 100644 --- a/sites/shkor-com/v2/index.html +++ b/sites/shkor-com/v2/index.html @@ -31,6 +31,7 @@ Labs Work with us Start a lab + Sign in Lab notes diff --git a/sites/shkor-com/v2/start/status/index.html b/sites/shkor-com/v2/start/status/index.html index 23fbd3e..f4fc309 100644 --- a/sites/shkor-com/v2/start/status/index.html +++ b/sites/shkor-com/v2/start/status/index.html @@ -62,7 +62,8 @@ 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). Both are already connected to the lab MCP — git, payments, your project, your subdomains.'; act = '
Panel: ' + esc(d.panel.url) + '
Login: ' + esc(d.panel.user) + '
Password: ' + esc(d.panel.password) + '
' - + 'Open my workspace' + ARROW + ''; + + 'Open my workspace' + ARROW + '' + + ' Open my account' + ARROW + ''; } h += item(depState, 'Your workspace' + (s === 'ready' ? pill('ok', 'Live') : s === 'deploying' ? pill('wip', 'Deploying') : ''), dep, act); if (s === 'ready' && d.earnings) {