shkor.com/account: кабинет — вход по ссылке из письма, уровень 2 (договор подряда, проект с 7 решениями и долей студии ≥10%, генерация продукта с тестовой ссылкой и шерингом, заявка на боевые платежи, трекшн), заработок

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simbiot Harness 2026-10-18 19:41:44 +00:00
parent e96525e01a
commit 123e300fec
6 changed files with 544 additions and 3 deletions

View file

@ -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 <labs@cil.by>", "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"<p>Hi! Here's your sign-in link to your Shkor Labs account:</p>"
f"<p><a href='{link}'>Sign in to Shkor Labs</a></p>"
f"<p>The link works once and expires in 30 minutes. If you didn't ask for it, ignore this email.</p>")
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 "{}")

View file

@ -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)

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contractor Agreement (contractor-v1) — Shkor Labs</title>
<meta name="robots" content="noindex">
<link rel="stylesheet" href="../../styles.css">
<style>
.doc{max-width:760px;margin:0 auto;padding:120px 24px 96px;color:var(--fg-2);font-size:16px;line-height:1.7}
.doc h1{font:700 40px/1.1 var(--display);color:var(--fg);letter-spacing:-.03em;margin-bottom:8px}
.doc h2{font:600 20px/1.3 var(--display);color:var(--fg);margin:32px 0 8px}
.doc p,.doc li{margin:8px 0}.doc ol,.doc ul{padding-left:22px}.doc .meta{font:13px var(--mono);color:var(--fg-3)}
</style>
</head>
<body>
<main class="doc">
<p class="meta">Version contractor-v1 · effective 18 October 2026</p>
<h1>Contractor Agreement</h1>
<p>This agreement is between <b>Shkor Labs LLC</b>, 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”).</p>
<h2>1. What this is</h2>
<p>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.</p>
<h2>2. Who owns what</h2>
<ol>
<li>The Builder owns the Project and everything the Builder creates for it, including code, content and brand.</li>
<li>The Studio owns its platform, tools, agents, templates and know-how. Using them does not transfer any rights in them.</li>
<li>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.</li>
</ol>
<h2>3. Studio share</h2>
<ol>
<li>The Studio receives the share of revenue the Builder sets for the Project in their account (the “Studio share”), not less than <b>10%</b>.</li>
<li>The Studio share applies to Project revenue collected through the Studio’s payment infrastructure and is withheld automatically at each payment.</li>
<li>The Builder may raise the Studio share at any time; lowering it requires the Studio’s written consent.</li>
</ol>
<h2>4. Payouts</h2>
<p>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.</p>
<h2>5. Live payments</h2>
<p>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.</p>
<h2>6. Level 2 tools</h2>
<p>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.</p>
<h2>7. Independent contractor</h2>
<p>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.</p>
<h2>8. Rules</h2>
<ul>
<li>Use the platform lawfully; no fraud, spam, malware, attacks on other systems or content that infringes others’ rights.</li>
<li>Do not resell or share access to Studio tools or accounts.</li>
<li>Keep non-public information about the Studio, other builders and their projects confidential.</li>
</ul>
<h2>9. Term and ending</h2>
<p>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.</p>
<h2>10. Other terms</h2>
<p>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: <a href="mailto:hello@shkor.com">hello@shkor.com</a>.</p>
</main>
</body>
</html>

View file

@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Your account — Shkor Labs</title>
<meta name="robots" content="noindex">
<meta name="referrer" content="no-referrer">
<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>">
<link rel="stylesheet" href="../styles.css">
<link rel="stylesheet" href="../apply/apply.css">
<style>
.acc{position:relative;max-width:900px}
.acc h1{margin-bottom:6px}
.lvl{display:inline-flex;gap:8px;align-items:center;font:12px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;padding:8px 12px;border-radius:999px;color:#0a0d02;background:var(--acid);vertical-align:middle}
.panel{margin-top:28px;padding:28px;border-radius:20px;background:linear-gradient(180deg,var(--bg-2),var(--bg-1));box-shadow:inset 0 0 0 1px var(--line-2)}
.panel h2{font:700 24px/1.2 var(--display);color:var(--fg);letter-spacing:-.02em;margin-bottom:8px}
.panel>p{color:var(--fg-3);font-size:15px}
.creds{margin-top:14px;padding:14px 16px;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);word-break:break-all}
.creds b{color:var(--fg);font-weight:400}
.gives{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));margin:16px 0 4px}
.gives div{padding:14px 16px;border-radius:12px;background:rgba(198,255,77,.05);box-shadow:inset 0 0 0 1px rgba(198,255,77,.18);font-size:14px;color:var(--fg-2)}
.gives b{display:block;color:var(--fg);font-size:15px;margin-bottom:2px}
.step{margin-top:14px;border-radius:16px;box-shadow:inset 0 0 0 1px var(--line-2);overflow:hidden}
.step>summary{list-style:none;cursor:pointer;display:grid;grid-template-columns:28px 1fr auto;gap:14px;align-items:center;padding:16px 18px}
.step>summary::-webkit-details-marker{display:none}
.step .dot{width:24px;height:24px;border-radius:50%;box-shadow:inset 0 0 0 2px var(--line-2);display:grid;place-items:center;font:12px var(--mono);color:var(--fg-3)}
.step.done .dot{background:var(--acid);box-shadow:none;color:#0a0d02}
.step summary b{color:var(--fg);font:600 17px/1.3 var(--display)}
.step summary small{display:block;color:var(--fg-3);font-size:13px;margin-top:2px}
.step .body{padding:4px 18px 20px 60px}
@media(max-width:600px){.step .body{padding:4px 16px 18px}}
.bar{height:8px;border-radius:8px;background:var(--line-2);overflow:hidden;margin:8px 0 14px}.bar i{display:block;height:100%;background:var(--acid)}
.prod{padding:14px 16px;border-radius:12px;background:rgba(255,255,255,.03);box-shadow:inset 0 0 0 1px var(--line-2);margin:10px 0}
.prod b{color:var(--fg)}
.share{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.share a,.share button{font:13px var(--body);padding:8px 12px;border-radius:10px;border:0;cursor:pointer;color:var(--fg);background:rgba(255,255,255,.06);text-decoration:none}
.share a:hover,.share button:hover{background:rgba(198,255,77,.15)}
.row3{display:grid;gap:0 12px;grid-template-columns:1fr}
@media(min-width:640px){.row3{grid-template-columns:2fr 1fr 1fr}}
.ok-msg{color:var(--acid);font-size:14px;min-height:20px;margin-top:10px}
.muted{color:var(--fg-3);font-size:14px}
.topbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
</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>
<nav aria-label="Account"><button class="btn btn-sm btn-ghost" id="logout" hidden type="button">Sign out</button></nav>
</div>
</header>
<main id="main" class="apply">
<div class="grid-bg" aria-hidden="true"></div>
<div class="wrap acc" id="app"><p class="kicker">Your account</p><h1>Loading…</h1></div>
</main>
<script>
(function () {
var app = document.getElementById('app'), q = new URLSearchParams(location.search), S = null;
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 DEC = [['d1_mission', 'Mission', 'Why this project exists.'], ['d2_vision', 'Vision', 'What the world looks like when you win.'],
['d3_market', 'Market', 'Who exactly you sell to.'], ['d4_need', 'Need', 'The pain or job they hire you for.'],
['d5_principle', 'Principle', 'The rule you won’t break.'], ['d6_product', 'Product', 'What you sell and how it solves the need.'],
['d7_tech', 'Technology', 'How it is built and run (agents, stack, data).']];
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
function money(v) { return '$' + Number(v || 0).toFixed(2); }
function api(path, body) {
return fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), credentials: 'same-origin' } : { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); });
}
function loginView(msg) {
document.getElementById('logout').hidden = true;
app.innerHTML = '<p class="kicker">Your account</p><h1>Sign in</h1><p class="lead">We’ll email you a one-time sign-in link. Use the email you started with.</p>'
+ '<form id="lf" class="panel" style="max-width:520px" novalidate><div class="field"><label for="le">Email</label><input id="le" type="email" autocomplete="email" required maxlength="254"></div>'
+ '<button class="btn" type="submit"><span>Email me a link</span>' + ARROW + '</button><p class="form-err" id="lerr">' + esc(msg || '') + '</p></form>'
+ '<p class="muted" style="margin-top:20px">No account yet? <a href="../start/">Start your autopilot</a>.</p>';
document.getElementById('lf').addEventListener('submit', function (e) {
e.preventDefault();
var em = document.getElementById('le').value.trim(), err = document.getElementById('lerr');
if (!/^[^@\s]+@[^@\s]+\.[A-Za-z]{2,}$/.test(em)) { err.textContent = 'Please enter a valid email.'; return; }
api('/api/account/login', { email: em }).then(function (r) {
if (!r.ok) { err.textContent = r.j.error || 'Something went wrong.'; return; }
app.innerHTML = '<p class="kicker">Your account</p><h1>Check your inbox</h1><p class="lead">If ' + esc(em) + ' has a Shkor Labs account, a sign-in link is on its way. It works once, for 30 minutes.</p>';
});
});
}
function stepHTML(i, key, done, title, sub, body, open) {
return '<details class="step' + (done ? ' done' : '') + '"' + (open ? ' open' : '') + ' id="st-' + key + '"><summary><span class="dot">' + (done ? '✓' : i) + '</span><div><b>' + title + '</b><small>' + sub + '</small></div><span class="muted">' + (done ? 'Done' : '') + '</span></summary><div class="body">' + body + '</div></details>';
}
function shareHTML(url, text) {
var u = encodeURIComponent(url), t = encodeURIComponent(text);
return '<div class="share">'
+ '<a target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + t + '&url=' + u + '">Share on X</a>'
+ '<a target="_blank" rel="noopener" href="https://www.linkedin.com/sharing/share-offsite/?url=' + u + '">LinkedIn</a>'
+ '<a target="_blank" rel="noopener" href="https://t.me/share/url?url=' + u + '&text=' + t + '">Telegram</a>'
+ '<a target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + u + '">Facebook</a>'
+ '<button type="button" data-copy="' + esc(url) + '">Copy link</button></div>';
}
function render() {
var d = S, c = d.cabinet, first = (d.name || '').split(' ')[0];
document.getElementById('logout').hidden = false;
var h = '<div class="topbar"><div><p class="kicker">Your account</p><h1>Hi, ' + esc(first) + ' <span class="lvl">Level ' + (c ? c.level : 1) + (c && c.level === 2 ? ' · Studio builder' : ' · Community') + '</span></h1></div></div>';
if (d.stage !== 'ready' || !c) {
var msg = { kyc: 'Finish identity verification to continue.', pay: 'Verified — one step left: payment.', declined: 'Identity verification was not approved.', deploying: 'Your workspace is deploying — usually 15–40 minutes.' }[d.stage] || '';
h += '<div class="panel"><h2>Getting started</h2><p>' + esc(msg) + '</p><a class="btn" style="margin-top:14px" href="../start/status/?t=' + encodeURIComponent(d.token) + '"><span>Open setup status</span>' + ARROW + '</a></div>';
app.innerHTML = h; return;
}
// workspace
if (d.panel) h += '<div class="panel"><h2>Your workspace</h2><p>Claude Code (your own Claude account) and OpenCode, both connected to the lab MCP.</p><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> · Password: <b>' + esc(d.panel.password) + '</b></div></div>';
// earnings
var e = c.earnings || {};
h += '<div class="panel"><h2>Earnings <span class="pill ' + (e.available > 0 ? 'ok' : 'wip') + '">' + money(e.available) + ' available</span></h2><p>Sales through the cooperative’s payments. The studio share is withheld automatically at each payment.</p>'
+ '<div class="creds">Sales: <b>' + money(e.gross) + '</b> · Studio share: <b>' + money(e.studio_share) + '</b> · Yours: <b>' + money(e.net) + '</b> · Paid out: <b>' + money(e.paid_out) + '</b></div>'
+ '<p class="muted" style="margin-top:10px">Withdrawals are coming soon — your balance is already tracked.</p></div>';
// level 2
var st = {}; (c.steps || []).forEach(function (s) { st[s.key] = s.done; });
var firstOpen = ['agreement', 'project', 'product', 'traction'].filter(function (k) { return !st[k]; })[0];
h += '<div class="panel"><h2>' + (c.level === 2 ? 'You’re a Studio builder' : 'Next: Level 2 · Studio builder') + '</h2>'
+ '<p>Level 2 gives you:</p><div class="gives">'
+ '<div><b>Claude Code on the studio</b>A coding-agent seat paid by the studio, for your project.</div>'
+ '<div><b>Lab agents</b>Ask the studio’s AI agents for help — research, sales, content.</div>'
+ '<div><b>Studio network</b>Contacts, bookings and introductions from the cooperative.</div>'
+ '<div><b>Investment pool</b>Apply for the quarterly pool — investors vote where it goes.</div></div>'
+ '<p class="muted">And next, Level 3: a paid role in the studio — by invitation of the board.</p>';
// 1 kyc
h += stepHTML(1, 'kyc', true, 'Verify your identity', 'Done with Didit.', '', false);
// 2 agreement
var agr = c.agreement;
h += stepHTML(2, 'agreement', st.agreement, 'Sign the contractor agreement', agr ? 'Signed by ' + esc(agr.signer_name) + ' · ' + esc(agr.signed_at) + ' UTC' : 'You keep your project. The studio gets a share of revenue you set (min ' + c.min_studio_pct + '%).',
agr ? '<p class="muted">Version ' + esc(agr.version) + '. <a href="agreement/" target="_blank" rel="noopener">Read the agreement</a>.</p>'
: '<p class="muted"><a href="agreement/" target="_blank" rel="noopener">Read the agreement (' + esc(c.agreement_version) + ')</a> — about 3 minutes. Key points: you own your project; the studio share (min ' + c.min_studio_pct + '%) is withheld from payments through the studio; you’re an independent contractor; 30 days’ notice to end.</p>'
+ '<form id="fa" novalidate><div class="field"><label for="an">Type your full name as verified: <em>' + esc(d.name) + '</em></label><input id="an" autocomplete="name" maxlength="120"></div>'
+ '<label class="check"><input type="checkbox" id="ac"><span>I have read and accept the Contractor Agreement (' + esc(c.agreement_version) + ') and sign it electronically.</span></label>'
+ '<button class="btn" style="margin-top:16px" type="submit"><span>Sign agreement</span>' + ARROW + '</button><p class="form-err"></p></form>', firstOpen === 'agreement');
// 3 project
var p = c.project || { decisions: {} }, decs = '';
DEC.forEach(function (x) { decs += '<div class="field"><label for="p-' + x[0] + '">' + x[1] + ' <em>*</em></label><textarea id="p-' + x[0] + '" maxlength="600" placeholder="' + esc(x[2]) + '">' + esc((p.decisions || {})[x[0]]) + '</textarea></div>'; });
h += stepHTML(3, 'project', st.project, 'Describe your project', c.project ? esc(c.project.name) + ' · studio share ' + esc(c.project.studio_pct) + '%' : 'Name, studio share and the 7 decisions every studio project defines.',
'<form id="fp" novalidate><div class="row2"><div class="field"><label for="pn">Project name <em>*</em></label><input id="pn" maxlength="120" value="' + esc(p.name) + '"' + (c.project ? ' disabled' : '') + '></div>'
+ '<div class="field"><label for="pp">Studio share, % <em>*</em></label><input id="pp" type="number" min="' + c.min_studio_pct + '" max="100" step="1" value="' + esc(c.project ? c.project.studio_pct : c.min_studio_pct) + '"' + (c.project ? ' disabled' : '') + '><small>Min ' + c.min_studio_pct + '%. Withheld from each payment through the studio.</small></div></div>'
+ '<div class="field"><label for="pd">One-line description</label><input id="pd" maxlength="600" value="' + esc(p.description) + '"' + (c.project ? ' disabled' : '') + '></div>'
+ decs + '<button class="btn" type="submit"><span>' + (c.project ? 'Save decisions' : 'Create project') + '</span>' + ARROW + '</button><p class="form-err"></p></form>', firstOpen === 'project');
// 4 product
var prods = '';
(c.products || []).forEach(function (x) {
var live = x.mode === 'live', url = (c.project && c.project.site_url) || x.link || '';
prods += '<div class="prod"><b>' + esc(x.name) + '</b> · ' + money(x.price_usd) + (x.interval && x.interval !== 'one_time' ? '/' + esc(x.interval) : '') + ' <span class="pill ' + (live ? 'ok' : 'wip') + '">' + (live ? 'Live' : 'Test mode') + '</span>'
+ (x.link ? '<div class="creds" style="margin-top:8px">Payment link: <b><a href="' + esc(x.link) + '" target="_blank" rel="noopener">' + esc(x.link) + '</a></b></div>' : '')
+ (url ? shareHTML(url, (live ? 'Now live: ' : 'Coming soon: ') + x.name + ' — built at Shkor Labs') : '')
+ (live ? '' : x.live_request === 'pending' ? '<p class="muted" style="margin-top:10px">Live payments requested — the studio is reviewing.</p>'
: '<form class="fl" data-id="' + x.id + '" style="margin-top:12px"><div class="field"><label>What exactly will customers pay for?</label><input class="fl-note" maxlength="1000" placeholder="e.g. Monthly access to … for small clinics"></div><button class="btn btn-sm" type="submit"><span>Request live payments</span></button><p class="form-err"></p></form>')
+ '</div>';
});
h += stepHTML(4, 'product', st.product, 'Launch a product', 'Generate a product with a payment link, share it, then go live after a quick review.',
(c.project ? prods + '<form id="fr" novalidate style="margin-top:12px"><div class="row3"><div class="field"><label for="rn">Product name</label><input id="rn" maxlength="120" placeholder="e.g. Pro plan"></div>'
+ '<div class="field"><label for="rp">Price, USD</label><input id="rp" type="number" min="1" max="20000" step="1" value="29"></div>'
+ '<div class="field"><label for="ri">Billing</label><select id="ri"><option value="month">Monthly</option><option value="year">Yearly</option><option value="one_time">One-time</option></select></div></div>'
+ '<button class="btn" type="submit"><span>Generate product</span>' + ARROW + '</button><p class="form-err"></p><p class="muted">Starts in test mode: the link works, but no money moves until live payments are approved.</p></form>'
: '<p class="muted">Create your project first (step 3).</p>'), firstOpen === 'product');
// 5 traction
var th = c.thresholds || { gross_usd: 300, payments: 10 }, g = Math.min(100, (e.gross || 0) / th.gross_usd * 100), n = Math.min(100, (e.payments || 0) / th.payments * 100);
h += stepHTML(5, 'traction', st.traction, 'Show traction', 'Either ' + money(th.gross_usd) + ' in sales or ' + th.payments + ' paying customers through studio payments.',
'<p class="muted">Sales: ' + money(e.gross) + ' of ' + money(th.gross_usd) + '</p><div class="bar"><i style="width:' + g + '%"></i></div>'
+ '<p class="muted">Payments: ' + (e.payments || 0) + ' of ' + th.payments + '</p><div class="bar"><i style="width:' + n + '%"></i></div>', firstOpen === 'traction');
h += '</div>';
app.innerHTML = h;
bind();
}
function act(path, body, form) {
var err = form.querySelector('.form-err'), btn = form.querySelector('button[type=submit]');
err.textContent = ''; btn.disabled = true;
api('/api/account/' + path, body).then(function (r) {
btn.disabled = false;
if (r.status === 401) return loginView('Your session expired — sign in again.');
if (!r.ok || r.j.error) { err.textContent = r.j.error || 'Something went wrong.'; return; }
S.cabinet = r.j; render();
}).catch(function () { btn.disabled = false; err.textContent = 'Network error — please try again.'; });
}
function bind() {
var fa = document.getElementById('fa');
if (fa) fa.addEventListener('submit', function (e) { e.preventDefault(); act('agreement', { signer_name: document.getElementById('an').value.trim(), accept: document.getElementById('ac').checked }, fa); });
var fp = document.getElementById('fp');
if (fp) fp.addEventListener('submit', function (e) {
e.preventDefault(); var dec = {};
DEC.forEach(function (x) { dec[x[0]] = document.getElementById('p-' + x[0]).value.trim(); });
act('project', { name: document.getElementById('pn').value.trim(), studio_pct: Number(document.getElementById('pp').value), description: document.getElementById('pd').value.trim(), decisions: dec }, fp);
});
var fr = document.getElementById('fr');
if (fr) fr.addEventListener('submit', function (e) { e.preventDefault(); act('product', { name: document.getElementById('rn').value.trim(), price_usd: Number(document.getElementById('rp').value), interval: document.getElementById('ri').value }, fr); });
document.querySelectorAll('.fl').forEach(function (f) { f.addEventListener('submit', function (e) { e.preventDefault(); act('product-live', { product_id: Number(f.dataset.id), note: f.querySelector('.fl-note').value.trim() }, f); }); });
document.querySelectorAll('[data-copy]').forEach(function (b) { b.addEventListener('click', function () { navigator.clipboard && navigator.clipboard.writeText(b.dataset.copy); b.textContent = 'Copied'; }); });
}
function load() {
api('/api/account/me').then(function (r) {
if (r.status === 401) return loginView(q.get('expired') ? 'That link has expired or was already used — request a new one.' : '');
if (!r.ok) { app.innerHTML = '<p class="kicker">Your account</p><h1>Can’t load right now</h1><p class="lead">' + esc(r.j.error || 'Please refresh in a moment.') + '</p>'; return; }
S = r.j; render();
}).catch(function () { app.innerHTML = '<p class="kicker">Your account</p><h1>Can’t load right now</h1><p class="lead">Please refresh in a moment.</p>'; });
}
document.getElementById('logout').addEventListener('click', function () { api('/api/account/logout', {}).then(function () { loginView(''); }); });
load();
})();
</script>
</body>
</html>

View file

@ -31,6 +31,7 @@
<a href="#labs">Labs</a>
<a href="#work">Work with us</a>
<a href="apply/">Start a lab</a>
<a href="account/">Sign in</a>
<a class="btn btn-sm" href="#notes">Lab notes</a>
</nav>
</div>

View file

@ -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 = '<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>';
+ '<a class="btn" href="' + esc(d.panel.url) + '" target="_blank" rel="noopener"><span>Open my workspace</span>' + ARROW + '</a>'
+ ' <a class="btn btn-ghost" href="/api/account/session?t=' + encodeURIComponent(t) + '"><span>Open my account</span>' + ARROW + '</a>';
}
h += item(depState, 'Your workspace' + (s === 'ready' ? pill('ok', 'Live') : s === 'deploying' ? pill('wip', 'Deploying') : ''), dep, act);
if (s === 'ready' && d.earnings) {