stkit 17.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stk/__init__.py +19 -0
- stk/__main__.py +6 -0
- stk/agent_login.py +78 -0
- stk/app.py +323 -0
- stk/checks.py +634 -0
- stk/cli/__init__.py +49 -0
- stk/cli/agent.py +267 -0
- stk/cli/base.py +79 -0
- stk/cli/database.py +199 -0
- stk/cli/develop.py +63 -0
- stk/cli/doctor.py +262 -0
- stk/cli/main.py +101 -0
- stk/cli/reports.py +296 -0
- stk/cli/smoke.py +399 -0
- stk/cli/users.py +259 -0
- stk/cli/watch.py +68 -0
- stk/extensions.py +29 -0
- stk/migrations/__init__.py +130 -0
- stk/migrations/env.py +58 -0
- stk/migrations/script.py.mako +17 -0
- stk/migrations/versions/stk_0001_initial_schema.py +213 -0
- stk/portal/views.py +34 -0
- stk/public/__init__.py +0 -0
- stk/public/models.py +0 -0
- stk/public/views.py +344 -0
- stk/scaffold/__init__.py +1 -0
- stk/scaffold/app/CLAUDE.md +65 -0
- stk/scaffold/app/Dockerfile +41 -0
- stk/scaffold/app/README.md +16 -0
- stk/scaffold/app/__app__/__init__.py +1 -0
- stk/scaffold/app/__app__/app.py +8 -0
- stk/scaffold/app/__app__/settings.py +7 -0
- stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
- stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
- stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
- stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
- stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
- stk/scaffold/app/docker-compose.yml +88 -0
- stk/scaffold/app/dockerignore +13 -0
- stk/scaffold/app/env-sample +78 -0
- stk/scaffold/app/gitignore +8 -0
- stk/scaffold/app/nginx/nginx.conf +46 -0
- stk/scaffold/app/nginx/stk.conf +36 -0
- stk/scaffold/app/postgres/init-db.sh +8 -0
- stk/scaffold/app/pyproject.toml +23 -0
- stk/scaffold/app/tests/__init__.py +0 -0
- stk/scaffold/app/tests/test_app.py +23 -0
- stk/scaffold/generator.py +209 -0
- stk/scaffold/templates.py +437 -0
- stk/security.py +145 -0
- stk/settings.py +147 -0
- stk/shell.py +284 -0
- stk/static/VERSIONS.txt +4 -0
- stk/static/css/app.css +229 -0
- stk/static/css/layout.css +330 -0
- stk/static/css/vuetify.min.css +6 -0
- stk/static/icons/fonts/tabler-icons.woff2 +0 -0
- stk/static/icons/tabler-icons.min.css +4 -0
- stk/static/img/auth-bg.webp +0 -0
- stk/static/img/favicon.ico +0 -0
- stk/static/img/stk.svg +1664 -0
- stk/static/js/axios.min.js +5 -0
- stk/static/js/components/NotificationDropdown.js +156 -0
- stk/static/js/components/ThemeSwitcher.js +68 -0
- stk/static/js/components/TransitionExpand.js +64 -0
- stk/static/js/components/VerticalNavGroup.js +119 -0
- stk/static/js/components/VerticalNavLink.js +36 -0
- stk/static/js/components/VerticalNavSectionTitle.js +21 -0
- stk/static/js/components/index.js +47 -0
- stk/static/js/config.js +150 -0
- stk/static/js/vue.min.js +13 -0
- stk/static/js/vuetify.min.js +3148 -0
- stk/static/robots.txt +1 -0
- stk/tasks/__init__.py +78 -0
- stk/templates/401.html +14 -0
- stk/templates/404.html +12 -0
- stk/templates/500.html +13 -0
- stk/templates/_brand.html +1 -0
- stk/templates/auth_layout.html +26 -0
- stk/templates/cms/activities.html +135 -0
- stk/templates/cms/roles.html +215 -0
- stk/templates/cms/users.html +312 -0
- stk/templates/dashboard.html +132 -0
- stk/templates/index.html +52 -0
- stk/templates/layout.html +328 -0
- stk/templates/login-layout.html +54 -0
- stk/templates/security/_macros.html +39 -0
- stk/templates/security/_messages.html +12 -0
- stk/templates/security/change_password.html +100 -0
- stk/templates/security/confirm_email.html +44 -0
- stk/templates/security/email/change_notice.html +4 -0
- stk/templates/security/email/change_notice.txt +5 -0
- stk/templates/security/email/confirmation_instructions.html +3 -0
- stk/templates/security/email/confirmation_instructions.txt +3 -0
- stk/templates/security/email/login_instructions.html +5 -0
- stk/templates/security/email/login_instructions.txt +5 -0
- stk/templates/security/email/reset_instructions.html +1 -0
- stk/templates/security/email/reset_instructions.txt +3 -0
- stk/templates/security/email/reset_notice.html +1 -0
- stk/templates/security/email/reset_notice.txt +1 -0
- stk/templates/security/email/welcome.html +7 -0
- stk/templates/security/email/welcome.txt +7 -0
- stk/templates/security/forgot_password.html +63 -0
- stk/templates/security/login_user.html +206 -0
- stk/templates/security/mf_recovery.html +67 -0
- stk/templates/security/mf_recovery_codes.html +119 -0
- stk/templates/security/register_user.html +263 -0
- stk/templates/security/reset_password.html +53 -0
- stk/templates/security/send_confirmation.html +48 -0
- stk/templates/security/send_login.html +39 -0
- stk/templates/security/two_factor_select.html +78 -0
- stk/templates/security/two_factor_setup.html +166 -0
- stk/templates/security/two_factor_verify_code.html +71 -0
- stk/templates/security/wan_register.html +179 -0
- stk/templates/security/wan_signin.html +123 -0
- stk/templates/security/wan_verify.html +109 -0
- stk/user/__init__.py +0 -0
- stk/user/forms.py +10 -0
- stk/user/models.py +308 -0
- stk/user/views.py +339 -0
- stk/utils/email.py +32 -0
- stk/utils/ratelimit.py +67 -0
- stk/websocket.py +133 -0
- stkit-17.0.0.dist-info/METADATA +135 -0
- stkit-17.0.0.dist-info/RECORD +128 -0
- stkit-17.0.0.dist-info/WHEEL +4 -0
- stkit-17.0.0.dist-info/entry_points.txt +2 -0
- stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/public/views.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import secrets
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
from authlib.integrations.httpx_client import AsyncOAuth2Client
|
|
6
|
+
from authlib.oidc.core import CodeIDToken
|
|
7
|
+
from joserfc import jwt
|
|
8
|
+
from joserfc.jwk import KeySet
|
|
9
|
+
from quart import (
|
|
10
|
+
Blueprint,
|
|
11
|
+
current_app,
|
|
12
|
+
flash,
|
|
13
|
+
g,
|
|
14
|
+
redirect,
|
|
15
|
+
render_template,
|
|
16
|
+
request,
|
|
17
|
+
send_from_directory,
|
|
18
|
+
session,
|
|
19
|
+
url_for,
|
|
20
|
+
)
|
|
21
|
+
from quart_security import current_user
|
|
22
|
+
from quart_security.proxies import _security
|
|
23
|
+
from sqlalchemy import select
|
|
24
|
+
|
|
25
|
+
from stk.user.models import OAuth, User
|
|
26
|
+
|
|
27
|
+
public = Blueprint("public", __name__, static_folder="../static")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_real_ip():
|
|
31
|
+
return request.remote_addr
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_oauth_user(provider_data, ip_address):
|
|
35
|
+
now = datetime.datetime.now()
|
|
36
|
+
user = User(
|
|
37
|
+
email=provider_data.get("email"),
|
|
38
|
+
username=provider_data.get("email"),
|
|
39
|
+
name=provider_data.get("name", ""),
|
|
40
|
+
password=User.random_password(),
|
|
41
|
+
password_set=False,
|
|
42
|
+
active=True,
|
|
43
|
+
confirmed_at=now,
|
|
44
|
+
current_login_at=now,
|
|
45
|
+
current_login_ip=ip_address,
|
|
46
|
+
login_count=1,
|
|
47
|
+
)
|
|
48
|
+
return user
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@public.route("/health")
|
|
52
|
+
async def health():
|
|
53
|
+
import stk.extensions as ext
|
|
54
|
+
|
|
55
|
+
status = {"status": "ok", "checks": {}}
|
|
56
|
+
|
|
57
|
+
# DB check
|
|
58
|
+
try:
|
|
59
|
+
from sqlalchemy import text
|
|
60
|
+
|
|
61
|
+
async with ext.engine.connect() as conn:
|
|
62
|
+
await conn.execute(text("SELECT 1"))
|
|
63
|
+
status["checks"]["database"] = "ok"
|
|
64
|
+
except Exception:
|
|
65
|
+
status["checks"]["database"] = "error"
|
|
66
|
+
status["status"] = "degraded"
|
|
67
|
+
|
|
68
|
+
# Redis check (if configured)
|
|
69
|
+
session_interface = getattr(current_app, "session_interface", None)
|
|
70
|
+
backend = getattr(session_interface, "backend", None)
|
|
71
|
+
if backend is not None:
|
|
72
|
+
try:
|
|
73
|
+
await backend.ping()
|
|
74
|
+
status["checks"]["redis"] = "ok"
|
|
75
|
+
except Exception:
|
|
76
|
+
status["checks"]["redis"] = "error"
|
|
77
|
+
status["status"] = "degraded"
|
|
78
|
+
|
|
79
|
+
code = 200 if status["status"] == "ok" else 503
|
|
80
|
+
return status, code
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@public.route("/")
|
|
84
|
+
async def index():
|
|
85
|
+
return await render_template("index.html")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@public.route("/robots.txt")
|
|
89
|
+
async def static_from_root():
|
|
90
|
+
return await send_from_directory(public.static_folder, request.path[1:])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# OAuth transactions use single-use server state and PKCE.
|
|
94
|
+
GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration"
|
|
95
|
+
GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
|
|
96
|
+
GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
97
|
+
GITHUB_API_URL = "https://api.github.com"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def authentication_failed():
|
|
101
|
+
await flash(
|
|
102
|
+
"Authentication failed. Use your existing sign-in method.", category="error"
|
|
103
|
+
)
|
|
104
|
+
return redirect(url_for("security.login"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def handle_oauth_callback(provider_name, token, user_info):
|
|
108
|
+
provider_user_id = (
|
|
109
|
+
user_info.get("sub") if provider_name == "google" else user_info.get("id")
|
|
110
|
+
)
|
|
111
|
+
email = user_info.get("email")
|
|
112
|
+
if (
|
|
113
|
+
not token
|
|
114
|
+
or not provider_user_id
|
|
115
|
+
or not email
|
|
116
|
+
or user_info.get("email_verified") is not True
|
|
117
|
+
):
|
|
118
|
+
return await authentication_failed()
|
|
119
|
+
account = await g.db_session.scalar(
|
|
120
|
+
select(OAuth).where(
|
|
121
|
+
OAuth.provider == provider_name,
|
|
122
|
+
OAuth.provider_user_id == str(provider_user_id),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
if account:
|
|
126
|
+
user = account.user
|
|
127
|
+
else:
|
|
128
|
+
# Email ownership at an IdP does not authorize linking local credentials.
|
|
129
|
+
existing = await g.db_session.scalar(select(User).where(User.email == email))
|
|
130
|
+
if existing:
|
|
131
|
+
return await authentication_failed()
|
|
132
|
+
user = create_oauth_user(
|
|
133
|
+
{
|
|
134
|
+
"email": email,
|
|
135
|
+
"name": user_info.get("name") or user_info.get("login", ""),
|
|
136
|
+
},
|
|
137
|
+
get_real_ip(),
|
|
138
|
+
)
|
|
139
|
+
g.db_session.add_all(
|
|
140
|
+
[
|
|
141
|
+
user,
|
|
142
|
+
OAuth(
|
|
143
|
+
provider=provider_name,
|
|
144
|
+
provider_user_id=str(provider_user_id),
|
|
145
|
+
user=user,
|
|
146
|
+
),
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
await g.db_session.commit()
|
|
150
|
+
if not user.active or (
|
|
151
|
+
user.locked_until
|
|
152
|
+
and user.locked_until > datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
|
153
|
+
):
|
|
154
|
+
return await authentication_failed()
|
|
155
|
+
if current_user.is_authenticated:
|
|
156
|
+
await _security.logout_user()
|
|
157
|
+
if current_app.config.get("SECURITY_TWO_FACTOR") and user.tf_primary_method:
|
|
158
|
+
session["tf_user_id"] = await _security.state_store.put(
|
|
159
|
+
{"user_id": user.get_id()}, ttl=300
|
|
160
|
+
)
|
|
161
|
+
return redirect(url_for("security.two_factor_token_validation"))
|
|
162
|
+
await _security.login_user(user)
|
|
163
|
+
return redirect(url_for("portal.dashboard"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_google_client():
|
|
167
|
+
return AsyncOAuth2Client(
|
|
168
|
+
client_id=current_app.config.get("GOOGLE_OAUTH_CLIENT_ID"),
|
|
169
|
+
client_secret=current_app.config.get("GOOGLE_OAUTH_CLIENT_SECRET"),
|
|
170
|
+
code_challenge_method="S256",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def get_github_client():
|
|
175
|
+
return AsyncOAuth2Client(
|
|
176
|
+
client_id=current_app.config.get("GITHUB_OAUTH_CLIENT_ID"),
|
|
177
|
+
client_secret=current_app.config.get("GITHUB_OAUTH_CLIENT_SECRET"),
|
|
178
|
+
code_challenge_method="S256",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def google_discovery():
|
|
183
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
184
|
+
response = await client.get(GOOGLE_DISCOVERY_URL)
|
|
185
|
+
response.raise_for_status()
|
|
186
|
+
return response.json()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def start_oauth(provider):
|
|
190
|
+
if current_user.is_authenticated or not current_app.config.get(
|
|
191
|
+
f"{provider.upper()}_AUTH_ENABLED"
|
|
192
|
+
):
|
|
193
|
+
return await authentication_failed()
|
|
194
|
+
verifier, nonce = secrets.token_urlsafe(48), secrets.token_urlsafe(32)
|
|
195
|
+
public_url = current_app.config.get("STK_PUBLIC_URL")
|
|
196
|
+
redirect_uri = (
|
|
197
|
+
(public_url.rstrip("/") + url_for(f"public.{provider}_callback"))
|
|
198
|
+
if public_url
|
|
199
|
+
else url_for(f"public.{provider}_callback", _external=True)
|
|
200
|
+
)
|
|
201
|
+
client = get_google_client() if provider == "google" else get_github_client()
|
|
202
|
+
try:
|
|
203
|
+
endpoint = (
|
|
204
|
+
(await google_discovery())["authorization_endpoint"]
|
|
205
|
+
if provider == "google"
|
|
206
|
+
else GITHUB_AUTHORIZE_URL
|
|
207
|
+
)
|
|
208
|
+
auth_url, state = client.create_authorization_url(
|
|
209
|
+
endpoint,
|
|
210
|
+
redirect_uri=redirect_uri,
|
|
211
|
+
scope="openid profile email"
|
|
212
|
+
if provider == "google"
|
|
213
|
+
else "read:user user:email",
|
|
214
|
+
code_verifier=verifier,
|
|
215
|
+
nonce=nonce,
|
|
216
|
+
)
|
|
217
|
+
session[f"oauth_{provider}"] = await _security.state_store.put(
|
|
218
|
+
{
|
|
219
|
+
"state": state,
|
|
220
|
+
"verifier": verifier,
|
|
221
|
+
"nonce": nonce,
|
|
222
|
+
"redirect_uri": redirect_uri,
|
|
223
|
+
},
|
|
224
|
+
ttl=300,
|
|
225
|
+
)
|
|
226
|
+
return redirect(auth_url)
|
|
227
|
+
finally:
|
|
228
|
+
await client.aclose()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def consume_oauth(provider):
|
|
232
|
+
if not current_app.config.get(f"{provider.upper()}_AUTH_ENABLED"):
|
|
233
|
+
return None
|
|
234
|
+
token = session.pop(f"oauth_{provider}", None)
|
|
235
|
+
state, code = request.args.get("state"), request.args.get("code")
|
|
236
|
+
if not token or not state or not code:
|
|
237
|
+
return None
|
|
238
|
+
transaction = await _security.state_store.pop(token)
|
|
239
|
+
if not transaction or not secrets.compare_digest(state, transaction["state"]):
|
|
240
|
+
return None
|
|
241
|
+
return transaction
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@public.route("/login/google")
|
|
245
|
+
async def google_login():
|
|
246
|
+
return await start_oauth("google")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@public.route("/login/github")
|
|
250
|
+
async def github_login():
|
|
251
|
+
return await start_oauth("github")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@public.route("/login/google/callback")
|
|
255
|
+
async def google_callback():
|
|
256
|
+
try:
|
|
257
|
+
transaction = await consume_oauth("google")
|
|
258
|
+
if transaction is None:
|
|
259
|
+
return await authentication_failed()
|
|
260
|
+
discovery = await google_discovery()
|
|
261
|
+
async with get_google_client() as client:
|
|
262
|
+
token = await client.fetch_token(
|
|
263
|
+
discovery["token_endpoint"],
|
|
264
|
+
code=request.args["code"],
|
|
265
|
+
redirect_uri=transaction["redirect_uri"],
|
|
266
|
+
code_verifier=transaction["verifier"],
|
|
267
|
+
)
|
|
268
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
269
|
+
response = await client.get(discovery["jwks_uri"])
|
|
270
|
+
response.raise_for_status()
|
|
271
|
+
decoded = jwt.decode(
|
|
272
|
+
token["id_token"],
|
|
273
|
+
KeySet.import_key_set(response.json()),
|
|
274
|
+
algorithms=["RS256"],
|
|
275
|
+
)
|
|
276
|
+
claims = CodeIDToken(
|
|
277
|
+
decoded.claims,
|
|
278
|
+
decoded.header,
|
|
279
|
+
options={
|
|
280
|
+
"iss": {
|
|
281
|
+
"essential": True,
|
|
282
|
+
"values": [
|
|
283
|
+
"https://accounts.google.com",
|
|
284
|
+
"accounts.google.com",
|
|
285
|
+
],
|
|
286
|
+
},
|
|
287
|
+
"aud": {
|
|
288
|
+
"essential": True,
|
|
289
|
+
"value": current_app.config["GOOGLE_OAUTH_CLIENT_ID"],
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
params={
|
|
293
|
+
"client_id": current_app.config["GOOGLE_OAUTH_CLIENT_ID"],
|
|
294
|
+
"nonce": transaction["nonce"],
|
|
295
|
+
"access_token": token["access_token"],
|
|
296
|
+
},
|
|
297
|
+
)
|
|
298
|
+
claims.validate(leeway=60)
|
|
299
|
+
return await handle_oauth_callback("google", token, claims)
|
|
300
|
+
except Exception:
|
|
301
|
+
current_app.logger.exception("Google OAuth failed")
|
|
302
|
+
return await authentication_failed()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@public.route("/login/github/callback")
|
|
306
|
+
async def github_callback():
|
|
307
|
+
try:
|
|
308
|
+
transaction = await consume_oauth("github")
|
|
309
|
+
if transaction is None:
|
|
310
|
+
return await authentication_failed()
|
|
311
|
+
async with get_github_client() as client:
|
|
312
|
+
token = await client.fetch_token(
|
|
313
|
+
GITHUB_TOKEN_URL,
|
|
314
|
+
code=request.args["code"],
|
|
315
|
+
redirect_uri=transaction["redirect_uri"],
|
|
316
|
+
code_verifier=transaction["verifier"],
|
|
317
|
+
)
|
|
318
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
319
|
+
headers = {
|
|
320
|
+
"Authorization": f"Bearer {token['access_token']}",
|
|
321
|
+
"Accept": "application/json",
|
|
322
|
+
}
|
|
323
|
+
response = await client.get(f"{GITHUB_API_URL}/user", headers=headers)
|
|
324
|
+
response.raise_for_status()
|
|
325
|
+
user_info = response.json()
|
|
326
|
+
response = await client.get(
|
|
327
|
+
f"{GITHUB_API_URL}/user/emails", headers=headers
|
|
328
|
+
)
|
|
329
|
+
response.raise_for_status()
|
|
330
|
+
primary = next(
|
|
331
|
+
(
|
|
332
|
+
email
|
|
333
|
+
for email in response.json()
|
|
334
|
+
if email.get("primary") and email.get("verified")
|
|
335
|
+
),
|
|
336
|
+
None,
|
|
337
|
+
)
|
|
338
|
+
if not primary:
|
|
339
|
+
return await authentication_failed()
|
|
340
|
+
user_info.update(email=primary["email"], email_verified=True)
|
|
341
|
+
return await handle_oauth_callback("github", token, user_info)
|
|
342
|
+
except Exception:
|
|
343
|
+
current_app.logger.exception("GitHub OAuth failed")
|
|
344
|
+
return await authentication_failed()
|
stk/scaffold/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Module scaffolder for stk -- generates deterministic blueprint structure."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Guidance for coding agents working on __App__. `AGENTS.md` is a symlink to this file.
|
|
4
|
+
|
|
5
|
+
__App__ is built on stk, installed as the `stkit` package and imported as `stk`.
|
|
6
|
+
`__app__/app.py` composes it: `Stk(__name__, config=Config, extensions=[...])`, where each
|
|
7
|
+
extension is a package in `__app__/` with `init_app(app)`. Never edit stk's files: they
|
|
8
|
+
are replaced on every upgrade. Override a template or static file by putting one at the
|
|
9
|
+
same path in `__app__/templates/` or `__app__/static/`; `stk/<name>` is the original.
|
|
10
|
+
|
|
11
|
+
## Where truth lives
|
|
12
|
+
|
|
13
|
+
1. **The running app.** Ask it before reading files: `uv run stk inspect context --json` (routes with auth, models), `uv run stk inspect commands --json` (every command and option), `uv run stk doctor` (app state, each problem with its fix). `uv run stk new <module>` writes the canonical CRUD extension; copy its patterns.
|
|
14
|
+
2. **stk's docs** at https://docs.stk.dev, indexed for agents at https://docs.stk.dev/llms.txt. Read the page for your task before writing code.
|
|
15
|
+
3. **Skills** in `.claude/skills/` (mirrored at `.agents/skills/`): `stk-blueprint`, `stk-api`, `stk-migrate`, `stk-deploy`, and `stk-lessons` (when a gotcha the docs missed costs you time, log one line in `.stk/lessons.md`).
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv run stk run # Dev server at localhost:5000 (--port 5001 on macOS)
|
|
21
|
+
uv run stk db upgrade # stk's migrations, then the app's
|
|
22
|
+
uv run stk install # Create admin user
|
|
23
|
+
uv run stk doctor # What is wrong, and the command that fixes it
|
|
24
|
+
uv run stk new <module> # Extension + migration, ends at a URL
|
|
25
|
+
uv run stk verify --watch # Re-run affected checks on every save
|
|
26
|
+
uv run stk smoke --json # Real-browser behavioral check
|
|
27
|
+
uv run stk shell -c "await count(User)" # One-shot query against the real app
|
|
28
|
+
uv run stk db revision -m "desc" # Autogenerate in the app's lineage, then: uv run stk db upgrade
|
|
29
|
+
uv run ruff check --fix . && uv run ruff format .
|
|
30
|
+
uv run python -m unittest discover -s tests
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Rules that fail silently
|
|
34
|
+
|
|
35
|
+
Backend:
|
|
36
|
+
- Async SQLAlchemy, no `db` object. Models inherit `Base` from `stk/extensions.py`. Requests use `g.db_session`; CLI code and tasks use `ext.async_session_factory()`.
|
|
37
|
+
- Every handler is `async def`; every DB call is awaited. Import from `quart` and `quart_security`, never `flask*`.
|
|
38
|
+
- Every relationship uses `lazy="selectin"`. Never read a relationship on a row you just `add()`ed: load it with `await session.get(...)` or a query, or you get `MissingGreenlet`.
|
|
39
|
+
- `User.from_dict()`, `Activity.register()`, `Session.create_session()` and the auth signal handlers are async.
|
|
40
|
+
- Pagination is manual: `offset().limit()` plus `select(func.count())`. List endpoints return `orjson` via `Response(json.dumps(data), content_type="application/json")`.
|
|
41
|
+
- Mutations arrive as `{item: {...}}`: read `json_data.get("item", {})`.
|
|
42
|
+
- `Activity.register()` stores audit events and does not broadcast. `broadcast()` needs an explicit recipient and reaches only sockets on the same worker.
|
|
43
|
+
- SQLite migrations: DDL is not transactional; a new NOT NULL column needs `server_default` in migration and model; keep the constraint naming convention on `Base.metadata`.
|
|
44
|
+
- `stk verify` blocks drops, renames, retypes, and NOT NULL adds without a default in `upgrade()`. A rename is usually a mistaken drop + add from autogenerate; mark a deliberate one with `# destructive: <reason>`.
|
|
45
|
+
- Tests: subclass `TestingConfig` (`stk/settings.py`), never `Config`; `.env` loads at import. Pin any other env-driven switch a test depends on.
|
|
46
|
+
- Apps attach tables to core ones by foreign key (`user.id`); core models are fixed. App revisions depend on stk's head, so never merge heads by hand.
|
|
47
|
+
- An app overrides a template or static file by putting one at the same path; `stk/<name>` is the original. Override a block, do not copy the layout. Python is never overridden.
|
|
48
|
+
|
|
49
|
+
Frontend:
|
|
50
|
+
- Vue Options API (`data()`, `methods`, `mounted()`), never Composition API. Delimiters are `${}`. Every page uses `mixins: [layoutMixin]` and calls `registerStkComponents(app)` before mount.
|
|
51
|
+
- Server data JSON `<script>` tags go in `{% block js %}`, never `{% block content %}`: Vue strips scripts inside `#app`.
|
|
52
|
+
- Template expressions cannot see page globals: `app.config.globalProperties.name = value` before mount. The production build fails silently, so look at the rendered page.
|
|
53
|
+
- Vuetify 4 uses MD3 type classes (`text-title-large`, not `text-h6`); MD2 classes do nothing. Icons are Tabler (`ti ti-*`), never MDI.
|
|
54
|
+
- Logout is POST-only (`layoutMixin.logout()`).
|
|
55
|
+
|
|
56
|
+
Security:
|
|
57
|
+
- stk owns authentication in-process. Do not swap in a third-party auth service unless that is the task.
|
|
58
|
+
- All mutations require CSRF. A sessionless webhook opts out with `@csrf_exempt` from `stk.security` under the route decorator and must authenticate the caller itself (e.g. HMAC).
|
|
59
|
+
- Public password registration stays off by default. OAuth never auto-links emails and still enforces local MFA.
|
|
60
|
+
- Agent browser login (`STK_ENABLE_AGENT_LOGIN`) is development/testing only; startup fails elsewhere.
|
|
61
|
+
- Never commit secrets, databases, reviews, or machine paths: `stk verify` fails on them.
|
|
62
|
+
|
|
63
|
+
Product:
|
|
64
|
+
- Rename an app with `APP_NAME` / `APP_TAGLINE` / `APP_DESCRIPTION` and its own `templates/_brand.html` and favicon. Never edit names across templates.
|
|
65
|
+
- Before reporting UI work done, run `uv run stk smoke` and look at a screenshot with real rows. A 200 and green tests do not prove a page renders.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1.4
|
|
2
|
+
FROM python:3.12-slim AS builder
|
|
3
|
+
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
|
|
6
|
+
# Install build deps + uv in one layer (git, for dependencies pinned to a commit)
|
|
7
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
8
|
+
build-essential python3-dev git \
|
|
9
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
10
|
+
&& pip install --no-cache-dir uv
|
|
11
|
+
|
|
12
|
+
# Install dependencies
|
|
13
|
+
COPY pyproject.toml uv.lock ./
|
|
14
|
+
RUN uv sync --extra full --frozen --no-dev --no-install-project
|
|
15
|
+
|
|
16
|
+
# Runtime
|
|
17
|
+
FROM python:3.12-slim
|
|
18
|
+
|
|
19
|
+
WORKDIR /app
|
|
20
|
+
ENV PATH="/app/.venv/bin:$PATH" \
|
|
21
|
+
PYTHONDONTWRITEBYTECODE=1 \
|
|
22
|
+
PYTHONUNBUFFERED=1
|
|
23
|
+
|
|
24
|
+
# Install runtime deps and create user
|
|
25
|
+
RUN apt-get update && apt-get install -y --no-install-recommends curl libexpat1 \
|
|
26
|
+
&& rm -rf /var/lib/apt/lists/* \
|
|
27
|
+
&& useradd -m -u 1000 stk
|
|
28
|
+
|
|
29
|
+
# Copy venv and app
|
|
30
|
+
COPY --from=builder --chown=stk:stk /app/.venv ./.venv
|
|
31
|
+
COPY --chown=stk:stk . .
|
|
32
|
+
|
|
33
|
+
# Create instance dir with correct ownership
|
|
34
|
+
RUN mkdir -p /app/instance && chown stk:stk /app/instance
|
|
35
|
+
|
|
36
|
+
USER stk
|
|
37
|
+
|
|
38
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
39
|
+
CMD curl -f http://localhost:5000/ || exit 1
|
|
40
|
+
|
|
41
|
+
CMD ["uvicorn", "__app__.app:app", "--host", "0.0.0.0", "--port", "5000"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# __App__
|
|
2
|
+
|
|
3
|
+
Built on [stk](https://github.com/level09/stk) __stk_version__. The app composes stk
|
|
4
|
+
from `__app__/app.py` and never edits stk's files.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
uv run stk db upgrade # stk's migrations, then the app's
|
|
8
|
+
uv run stk install # admin user
|
|
9
|
+
uv run stk run # http://localhost:5000
|
|
10
|
+
uv run stk new <module> # an extension: blueprint, model, page, migration
|
|
11
|
+
uv run stk verify && uv run stk smoke
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Upgrade stk by reading its release note, raising the `stkit` range in
|
|
15
|
+
`dependencies` if the major version changed, then `uv lock --upgrade-package stkit`,
|
|
16
|
+
`uv sync`, `uv run stk db upgrade`, `uv run stk verify`, and `uv run stk smoke`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""__App__."""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stk-api
|
|
3
|
+
description: |
|
|
4
|
+
Generate async CRUD API endpoints for an existing stk model. Use when adding API routes, REST endpoints, or CRUD operations to an stk blueprint.
|
|
5
|
+
argument-hint: "[ModelName]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Generate CRUD API for $ARGUMENTS
|
|
9
|
+
|
|
10
|
+
## Current state
|
|
11
|
+
|
|
12
|
+
Models available:
|
|
13
|
+
!`grep -rn "class.*Base" */*/models.py 2>/dev/null`
|
|
14
|
+
|
|
15
|
+
Existing API routes:
|
|
16
|
+
!`grep -rn "@bp_[a-z_]*\.\(get\|post\|put\|delete\|patch\|route\)" */*/views.py 2>/dev/null | head -30`
|
|
17
|
+
|
|
18
|
+
## Steps
|
|
19
|
+
|
|
20
|
+
1. **Find the model** named `$ARGUMENTS` in the codebase. Read its full definition to understand fields, relationships, and existing `to_dict()`/`from_dict()`.
|
|
21
|
+
|
|
22
|
+
2. **Generate these endpoints** in the model's blueprint views.py:
|
|
23
|
+
|
|
24
|
+
- `GET /api/<plural>` — List with pagination, search, filtering
|
|
25
|
+
- `POST /api/<singular>/` — Create (note trailing slash)
|
|
26
|
+
- `POST /api/<singular>/<id>` — Update (POST, not PUT — stk convention)
|
|
27
|
+
- `DELETE /api/<singular>/<id>` — Delete
|
|
28
|
+
|
|
29
|
+
3. **Follow the conventions** in https://docs.stk.dev/api/: blueprint-level auth, `{item: {...}}`
|
|
30
|
+
payloads, `{items, total, perPage}` list responses via `orjson`, `Activity.register` on
|
|
31
|
+
every mutation, rollback plus `log.exception` on failure, and `{"message": ...}` with
|
|
32
|
+
400/404/412. The reference implementation is what `uv run stk new` writes
|
|
33
|
+
(`render_views` in `stk/scaffold/templates.py`); match it.
|
|
34
|
+
|
|
35
|
+
4. **List endpoint:** `PER_PAGE = 25`, `page`/`per_page` params, an optional `search` param
|
|
36
|
+
with `ilike` on the text fields, and the total counted from `query.subquery()` so filters
|
|
37
|
+
apply to it.
|
|
38
|
+
|
|
39
|
+
5. **Create/Update:** create calls `await Model().from_dict(data)` and `flush()`es before the
|
|
40
|
+
audit entry so the ID exists; update stores `to_dict()` before changing the row and logs
|
|
41
|
+
old and new.
|
|
42
|
+
|
|
43
|
+
6. **If a page route doesn't exist yet**, add one:
|
|
44
|
+
```python
|
|
45
|
+
@bp.get("/<plural>/")
|
|
46
|
+
async def <plural>_page():
|
|
47
|
+
return await render_template("<name>/index.html")
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
7. **Verify:**
|
|
51
|
+
- Run `uv run ruff check --fix . && uv run ruff format .`
|
|
52
|
+
- Run `uv run stk verify`
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stk-blueprint
|
|
3
|
+
description: |
|
|
4
|
+
Scaffold a new extension in an stk app: blueprint, model, page, sidebar entry, and Alembic migration. Use when creating a new feature module, adding a new section to the app, or scaffolding a blueprint.
|
|
5
|
+
argument-hint: "[blueprint-name]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Scaffold stk Blueprint
|
|
9
|
+
|
|
10
|
+
Create a complete extension for `$ARGUMENTS` in the app package (run from the app's directory).
|
|
11
|
+
|
|
12
|
+
## Workflow
|
|
13
|
+
|
|
14
|
+
The scaffolder generates the full module structure. Run it first, then customize domain fields and logic.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv run stk new <name>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`<name>` must be lowercase snake_case (e.g. `blog_post`, `invoice_line`). Reserved names (`user`, `role`, `portal`, `public`, `session`, `admin`, etc.) are rejected with a clear error.
|
|
21
|
+
|
|
22
|
+
The command generates, inside the app package (`myapp/`):
|
|
23
|
+
- `<name>/__init__.py` with `init_app(app)`: registers the blueprint and appends the sidebar entry to `app.navigation`
|
|
24
|
+
- `<name>/models.py`, `<name>/views.py`, `<name>/templates/<name>/index.html`
|
|
25
|
+
- Adds `<name>` to `extensions` in the app module (import + list entry)
|
|
26
|
+
- Generates AND applies the migration in the app's lineage, then prints the page URL
|
|
27
|
+
|
|
28
|
+
It never edits a stk file. Then:
|
|
29
|
+
1. Customize `<name>/models.py` -- add/rename fields to fit your domain; point at core tables by foreign key (`ForeignKey("user.id")`)
|
|
30
|
+
2. If you changed the model after scaffolding: `uv run stk db revision -m "update <name>"` and review, then `uv run stk db upgrade`
|
|
31
|
+
(only needed for post-scaffold model edits; the initial migration is already applied. With `--no-migrate`, the first revision is manual)
|
|
32
|
+
3. Verify: `uv run stk verify && uv run stk smoke`
|
|
33
|
+
|
|
34
|
+
## Conventions
|
|
35
|
+
|
|
36
|
+
The generated files already follow every stk convention. When you change them, the rules
|
|
37
|
+
are in `CLAUDE.md` and the details in https://docs.stk.dev/models/, https://docs.stk.dev/api/, and
|
|
38
|
+
https://docs.stk.dev/frontend/. Read the page before editing that layer.
|
|
39
|
+
|
|
40
|
+
## Before you report done
|
|
41
|
+
|
|
42
|
+
Each line is a mistake an agent made while building a real app on stk.
|
|
43
|
+
|
|
44
|
+
- Lint and format only the files you changed (`uv run ruff format path/a.py path/b.py`), never a whole package another person owns.
|
|
45
|
+
- Tests: give test-only blueprints names an app cannot already use.
|
|
46
|
+
- Migrations: check each command's exit code, never behind a pipe to `tail`. Never delete a revision file before `stk db downgrade <target>` has succeeded.
|
|
47
|
+
- Tables inside Vue's in-DOM templates: never put `<tbody>`/`<tr>` inside `<v-table>` or another custom element; the browser parser moves the rows out before Vue mounts and they never render. Use `v-data-table`, a native `<table>`, or flex rows.
|
|
48
|
+
- Verify UI in a browser: `uv run stk smoke`, then look at a screenshot of the page with real rows. A 200 and green tests do not prove columns, labels or buttons render.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: stk-deploy
|
|
3
|
+
description: Deploy an stk app to production on a fresh VPS with HTTPS, systemd, and a hardened OS. Use when taking an stk app live, deploying to a server, or setting up production hosting for a domain.
|
|
4
|
+
argument-hint: "[domain]"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Deploy stk App to Production
|
|
8
|
+
|
|
9
|
+
One-shot production install via `deploy.sh` (framework root). Installs Caddy
|
|
10
|
+
(auto-TLS, security headers, static caching), a systemd service, a non-root app
|
|
11
|
+
user, generated secrets, and hardens the box (SSH key-only, fail2ban, ufw).
|
|
12
|
+
SQLite by default, PostgreSQL+Redis optional.
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
1. Fresh Ubuntu/Debian VPS with root SSH access.
|
|
17
|
+
2. DNS A record for the domain pointing at the server (Caddy needs it for TLS).
|
|
18
|
+
3. App repo pushed to GitHub **and public**, or see the private-repo note below.
|
|
19
|
+
|
|
20
|
+
## Install (non-interactive)
|
|
21
|
+
|
|
22
|
+
Run from your machine against the server, or on the server as root:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
wget -qO /tmp/deploy.sh https://raw.githubusercontent.com/level09/stk/master/deploy.sh
|
|
26
|
+
sudo DOMAIN=example.com REPO=<github-user>/<repo> bash /tmp/deploy.sh
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every variable and what the script installs: https://docs.stk.dev/deployment/.
|
|
30
|
+
|
|
31
|
+
Ends at a live `https://<domain>` with an admin login. Credentials are saved
|
|
32
|
+
on the server at `/home/<app-user>/.credentials`.
|
|
33
|
+
|
|
34
|
+
## Hard warnings
|
|
35
|
+
|
|
36
|
+
- **Never re-run deploy.sh on a live app.** It is install-only: it deletes and
|
|
37
|
+
re-clones the app directory. With SQLite the database is
|
|
38
|
+
`instance/stk.db` *inside* that directory, so a re-run destroys production
|
|
39
|
+
data. Use the update flow below.
|
|
40
|
+
- **Private repos fail silently at the clone step**: the script clones over
|
|
41
|
+
plain HTTPS. Either make the repo public, or pre-seed a read-only GitHub
|
|
42
|
+
token on the server and clone via `git@github.com:` manually first.
|
|
43
|
+
|
|
44
|
+
## Update flow (every deploy after the first)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ssh <app-user>@<domain>
|
|
48
|
+
cd ~/<domain>
|
|
49
|
+
git pull
|
|
50
|
+
uv sync --frozen --no-dev
|
|
51
|
+
uv run --no-sync stk db upgrade # stk's migrations and the app's, if any are new
|
|
52
|
+
sudo systemctl restart <domain>.service
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The app user's sudoers allows managing only its own service.
|
|
56
|
+
|
|
57
|
+
## Verify
|
|
58
|
+
|
|
59
|
+
- `curl -I https://<domain>` returns 200/302 with the security headers.
|
|
60
|
+
- Log in as admin, create a throwaway record, restart the service, confirm it
|
|
61
|
+
survives (proves the DB path).
|