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.
Files changed (128) hide show
  1. stk/__init__.py +19 -0
  2. stk/__main__.py +6 -0
  3. stk/agent_login.py +78 -0
  4. stk/app.py +323 -0
  5. stk/checks.py +634 -0
  6. stk/cli/__init__.py +49 -0
  7. stk/cli/agent.py +267 -0
  8. stk/cli/base.py +79 -0
  9. stk/cli/database.py +199 -0
  10. stk/cli/develop.py +63 -0
  11. stk/cli/doctor.py +262 -0
  12. stk/cli/main.py +101 -0
  13. stk/cli/reports.py +296 -0
  14. stk/cli/smoke.py +399 -0
  15. stk/cli/users.py +259 -0
  16. stk/cli/watch.py +68 -0
  17. stk/extensions.py +29 -0
  18. stk/migrations/__init__.py +130 -0
  19. stk/migrations/env.py +58 -0
  20. stk/migrations/script.py.mako +17 -0
  21. stk/migrations/versions/stk_0001_initial_schema.py +213 -0
  22. stk/portal/views.py +34 -0
  23. stk/public/__init__.py +0 -0
  24. stk/public/models.py +0 -0
  25. stk/public/views.py +344 -0
  26. stk/scaffold/__init__.py +1 -0
  27. stk/scaffold/app/CLAUDE.md +65 -0
  28. stk/scaffold/app/Dockerfile +41 -0
  29. stk/scaffold/app/README.md +16 -0
  30. stk/scaffold/app/__app__/__init__.py +1 -0
  31. stk/scaffold/app/__app__/app.py +8 -0
  32. stk/scaffold/app/__app__/settings.py +7 -0
  33. stk/scaffold/app/claude/skills/stk-api/SKILL.md +52 -0
  34. stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +48 -0
  35. stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +61 -0
  36. stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +58 -0
  37. stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +96 -0
  38. stk/scaffold/app/docker-compose.yml +88 -0
  39. stk/scaffold/app/dockerignore +13 -0
  40. stk/scaffold/app/env-sample +78 -0
  41. stk/scaffold/app/gitignore +8 -0
  42. stk/scaffold/app/nginx/nginx.conf +46 -0
  43. stk/scaffold/app/nginx/stk.conf +36 -0
  44. stk/scaffold/app/postgres/init-db.sh +8 -0
  45. stk/scaffold/app/pyproject.toml +23 -0
  46. stk/scaffold/app/tests/__init__.py +0 -0
  47. stk/scaffold/app/tests/test_app.py +23 -0
  48. stk/scaffold/generator.py +209 -0
  49. stk/scaffold/templates.py +437 -0
  50. stk/security.py +145 -0
  51. stk/settings.py +147 -0
  52. stk/shell.py +284 -0
  53. stk/static/VERSIONS.txt +4 -0
  54. stk/static/css/app.css +229 -0
  55. stk/static/css/layout.css +330 -0
  56. stk/static/css/vuetify.min.css +6 -0
  57. stk/static/icons/fonts/tabler-icons.woff2 +0 -0
  58. stk/static/icons/tabler-icons.min.css +4 -0
  59. stk/static/img/auth-bg.webp +0 -0
  60. stk/static/img/favicon.ico +0 -0
  61. stk/static/img/stk.svg +1664 -0
  62. stk/static/js/axios.min.js +5 -0
  63. stk/static/js/components/NotificationDropdown.js +156 -0
  64. stk/static/js/components/ThemeSwitcher.js +68 -0
  65. stk/static/js/components/TransitionExpand.js +64 -0
  66. stk/static/js/components/VerticalNavGroup.js +119 -0
  67. stk/static/js/components/VerticalNavLink.js +36 -0
  68. stk/static/js/components/VerticalNavSectionTitle.js +21 -0
  69. stk/static/js/components/index.js +47 -0
  70. stk/static/js/config.js +150 -0
  71. stk/static/js/vue.min.js +13 -0
  72. stk/static/js/vuetify.min.js +3148 -0
  73. stk/static/robots.txt +1 -0
  74. stk/tasks/__init__.py +78 -0
  75. stk/templates/401.html +14 -0
  76. stk/templates/404.html +12 -0
  77. stk/templates/500.html +13 -0
  78. stk/templates/_brand.html +1 -0
  79. stk/templates/auth_layout.html +26 -0
  80. stk/templates/cms/activities.html +135 -0
  81. stk/templates/cms/roles.html +215 -0
  82. stk/templates/cms/users.html +312 -0
  83. stk/templates/dashboard.html +132 -0
  84. stk/templates/index.html +52 -0
  85. stk/templates/layout.html +328 -0
  86. stk/templates/login-layout.html +54 -0
  87. stk/templates/security/_macros.html +39 -0
  88. stk/templates/security/_messages.html +12 -0
  89. stk/templates/security/change_password.html +100 -0
  90. stk/templates/security/confirm_email.html +44 -0
  91. stk/templates/security/email/change_notice.html +4 -0
  92. stk/templates/security/email/change_notice.txt +5 -0
  93. stk/templates/security/email/confirmation_instructions.html +3 -0
  94. stk/templates/security/email/confirmation_instructions.txt +3 -0
  95. stk/templates/security/email/login_instructions.html +5 -0
  96. stk/templates/security/email/login_instructions.txt +5 -0
  97. stk/templates/security/email/reset_instructions.html +1 -0
  98. stk/templates/security/email/reset_instructions.txt +3 -0
  99. stk/templates/security/email/reset_notice.html +1 -0
  100. stk/templates/security/email/reset_notice.txt +1 -0
  101. stk/templates/security/email/welcome.html +7 -0
  102. stk/templates/security/email/welcome.txt +7 -0
  103. stk/templates/security/forgot_password.html +63 -0
  104. stk/templates/security/login_user.html +206 -0
  105. stk/templates/security/mf_recovery.html +67 -0
  106. stk/templates/security/mf_recovery_codes.html +119 -0
  107. stk/templates/security/register_user.html +263 -0
  108. stk/templates/security/reset_password.html +53 -0
  109. stk/templates/security/send_confirmation.html +48 -0
  110. stk/templates/security/send_login.html +39 -0
  111. stk/templates/security/two_factor_select.html +78 -0
  112. stk/templates/security/two_factor_setup.html +166 -0
  113. stk/templates/security/two_factor_verify_code.html +71 -0
  114. stk/templates/security/wan_register.html +179 -0
  115. stk/templates/security/wan_signin.html +123 -0
  116. stk/templates/security/wan_verify.html +109 -0
  117. stk/user/__init__.py +0 -0
  118. stk/user/forms.py +10 -0
  119. stk/user/models.py +308 -0
  120. stk/user/views.py +339 -0
  121. stk/utils/email.py +32 -0
  122. stk/utils/ratelimit.py +67 -0
  123. stk/websocket.py +133 -0
  124. stkit-17.0.0.dist-info/METADATA +135 -0
  125. stkit-17.0.0.dist-info/RECORD +128 -0
  126. stkit-17.0.0.dist-info/WHEEL +4 -0
  127. stkit-17.0.0.dist-info/entry_points.txt +2 -0
  128. stkit-17.0.0.dist-info/licenses/LICENSE +21 -0
stk/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """
2
+ stk Framework
3
+ A modern Python web framework optimized for AI-assisted development.
4
+ """
5
+
6
+ __version__ = "17.0.0"
7
+
8
+
9
+ def __getattr__(name):
10
+ # Lazy, so `import stk` loads no app code until a name is used.
11
+ if name == "Stk":
12
+ from stk.app import Stk
13
+
14
+ return Stk
15
+ if name in ("Config", "TestingConfig"):
16
+ from stk import settings
17
+
18
+ return getattr(settings, name)
19
+ raise AttributeError(f"module 'stk' has no attribute {name!r}")
stk/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow `python -m stk` to run the stk CLI."""
2
+
3
+ from stk.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
stk/agent_login.py ADDED
@@ -0,0 +1,78 @@
1
+ from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
2
+ from quart import Blueprint, current_app, g, redirect, request
3
+ from quart_security.proxies import _security
4
+ from sqlalchemy import select
5
+
6
+ from stk.user.models import User
7
+
8
+ bp_agent_login = Blueprint("agent_login", __name__, url_prefix="/_test")
9
+ TOKEN_SALT = "stk-agent-login"
10
+
11
+
12
+ def agent_login_enabled(app):
13
+ enabled = app.config.get("STK_ENABLE_AGENT_LOGIN", False)
14
+ if not enabled:
15
+ return False
16
+
17
+ is_safe_env = app.testing or app.config.get("STK_ENV") == "development"
18
+ if not is_safe_env:
19
+ raise RuntimeError("agent login cannot be enabled outside development/testing")
20
+
21
+ if not app.config.get("SECRET_KEY"):
22
+ raise RuntimeError("agent login cannot be enabled without SECRET_KEY")
23
+
24
+ return True
25
+
26
+
27
+ def _serializer():
28
+ return URLSafeTimedSerializer(current_app.config["SECRET_KEY"])
29
+
30
+
31
+ def _validate_local_next(next_path):
32
+ if not next_path.startswith("/") or next_path.startswith("//"):
33
+ raise ValueError("next path must be local")
34
+ return next_path
35
+
36
+
37
+ def create_agent_login_token(email, next_path="/dashboard/"):
38
+ return _serializer().dumps(
39
+ {"email": email, "next": _validate_local_next(next_path)},
40
+ salt=TOKEN_SALT,
41
+ )
42
+
43
+
44
+ def read_agent_login_token(token, max_age):
45
+ try:
46
+ return _serializer().loads(token, salt=TOKEN_SALT, max_age=max_age)
47
+ except SignatureExpired as exc:
48
+ raise ValueError("agent login token expired") from exc
49
+ except BadSignature as exc:
50
+ raise ValueError("invalid agent login token") from exc
51
+
52
+
53
+ @bp_agent_login.get("/login")
54
+ async def agent_login():
55
+ token = request.args.get("token")
56
+ if not token:
57
+ return "", 400
58
+
59
+ try:
60
+ payload = read_agent_login_token(
61
+ token,
62
+ max_age=current_app.config["STK_AGENT_LOGIN_MAX_TTL_SECONDS"],
63
+ )
64
+ except ValueError:
65
+ return "", 400
66
+
67
+ email = payload["email"]
68
+ if not email.endswith("@example.com"):
69
+ return "", 403
70
+
71
+ user = (
72
+ await g.db_session.execute(select(User).where(User.email == email))
73
+ ).scalar_one_or_none()
74
+ if user is None or not user.active:
75
+ return "", 403
76
+
77
+ await _security.login_user(user)
78
+ return redirect(payload["next"])
stk/app.py ADDED
@@ -0,0 +1,323 @@
1
+ import asyncio
2
+ import functools
3
+ import hashlib
4
+ import importlib
5
+ import importlib.util
6
+ import inspect
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from jinja2 import ChoiceLoader, FileSystemLoader, PrefixLoader
11
+ from quart import Quart, g, render_template, request, send_from_directory
12
+ from quart_security import Security, SQLAlchemyUserDatastore
13
+ from quart_security.views import _ensure_csrf_token
14
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
15
+ from werkzeug.exceptions import HTTPException, NotFound
16
+ from werkzeug.security import safe_join
17
+
18
+ import stk.extensions as ext
19
+ from stk.agent_login import agent_login_enabled, bp_agent_login
20
+ from stk.extensions import session
21
+ from stk.portal.views import portal
22
+ from stk.public.views import public
23
+ from stk.settings import Config
24
+ from stk.user.forms import ExtendedRegisterForm, OAuthAwareChangePasswordForm
25
+ from stk.user.models import Role, User, WebAuthn
26
+ from stk.user.views import bp_user
27
+ from stk.websocket import ws_bp
28
+
29
+ CORE_TEMPLATES = Path(__file__).parent / "templates"
30
+ CORE_STATIC = Path(__file__).parent / "static"
31
+
32
+
33
+ class Stk(Quart):
34
+ """A Quart app with stk's core, then the app's extensions in list order.
35
+
36
+ Each extension is a package exposing `init_app(app)`; its `models` module, if
37
+ any, is imported first so migrations see its tables. Templates and static
38
+ files resolve app first, then each extension's, then stk's. Core templates
39
+ stay reachable as `stk/<name>`, so an override can extend the original.
40
+ """
41
+
42
+ def __init__(self, import_name, *, config=Config, extensions=()):
43
+ super().__init__(
44
+ import_name, static_folder=str(CORE_STATIC), template_folder=None
45
+ )
46
+ self.config.from_object(config)
47
+ for key in ("SECRET_KEY", "SECURITY_PASSWORD_SALT"):
48
+ if not self.config.get(key):
49
+ raise ValueError(f"{key} is required: set it in .env")
50
+ if not self.config.get("SQLALCHEMY_DATABASE_URI"):
51
+ os.makedirs(self.instance_path, exist_ok=True)
52
+ database = Path(self.instance_path) / "stk.db"
53
+ self.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+aiosqlite:///{database}"
54
+
55
+ roots = [Path(self.root_path), *(Path(m.__path__[0]) for m in extensions)]
56
+ self.template_folders = [
57
+ *(path for root in roots if (path := root / "templates").is_dir()),
58
+ CORE_TEMPLATES,
59
+ ]
60
+ self.static_folders = [
61
+ *(path for root in roots if (path := root / "static").is_dir()),
62
+ CORE_STATIC,
63
+ ]
64
+ # Sidebar pages the app adds; the layout places them under Dashboard.
65
+ self.navigation = []
66
+
67
+ register_blueprints(self)
68
+ register_extensions(self)
69
+ register_errorhandlers(self)
70
+ register_static_versioning(self)
71
+ register_navigation(self)
72
+
73
+ for module in extensions:
74
+ if importlib.util.find_spec(f"{module.__name__}.models"):
75
+ importlib.import_module(f"{module.__name__}.models")
76
+ module.init_app(self)
77
+
78
+ @functools.cached_property
79
+ def jinja_loader(self):
80
+ return ChoiceLoader(
81
+ [
82
+ PrefixLoader({"stk": FileSystemLoader(CORE_TEMPLATES)}),
83
+ *(FileSystemLoader(folder) for folder in self.template_folders),
84
+ ]
85
+ )
86
+
87
+ def static_file(self, filename):
88
+ """Return the first static file at `filename`, app first, or None."""
89
+ for folder in self.static_folders:
90
+ path = safe_join(str(folder), filename)
91
+ if path is not None and os.path.isfile(path):
92
+ return Path(path)
93
+ return None
94
+
95
+ async def send_static_file(self, filename):
96
+ path = self.static_file(filename)
97
+ if path is None:
98
+ raise NotFound()
99
+ return await send_from_directory(path.parent, path.name)
100
+
101
+
102
+ def register_extensions(app):
103
+ # Async SQLAlchemy engine + session factory
104
+ ext.engine = create_async_engine(app.config["SQLALCHEMY_DATABASE_URI"])
105
+ ext.async_session_factory = async_sessionmaker(ext.engine, expire_on_commit=False)
106
+
107
+ user_datastore = SQLAlchemyUserDatastore(
108
+ ext.async_session_factory, User, Role, webauthn_model=WebAuthn
109
+ )
110
+
111
+ async def _open_session():
112
+ g.db_session = user_datastore.session
113
+
114
+ @app.after_request
115
+ async def _close_session(response):
116
+ db_session = g.pop("db_session", None)
117
+ if db_session is not None:
118
+ await db_session.close()
119
+ return response
120
+
121
+ async def _open_ws_session():
122
+ g.db_session = user_datastore.session
123
+
124
+ @app.after_websocket
125
+ async def _close_ws_session(response):
126
+ db_session = g.pop("db_session", None)
127
+ if db_session is not None:
128
+ await db_session.close()
129
+ return response
130
+
131
+ @app.while_serving
132
+ async def _lifespan():
133
+ yield
134
+ # quart-session initializes backend connections in before_serving,
135
+ # but doesn't close them on shutdown. Ensure we always release them
136
+ # so Ctrl+C exits cleanly in dev.
137
+ session_interface = getattr(app, "session_interface", None)
138
+ backend = getattr(session_interface, "backend", None)
139
+ if backend is not None:
140
+ close_method = getattr(backend, "aclose", None) or getattr(
141
+ backend, "close", None
142
+ )
143
+ if close_method is not None:
144
+ try:
145
+ close_result = close_method()
146
+ if inspect.isawaitable(close_result):
147
+ await close_result
148
+ except Exception:
149
+ app.logger.exception("Error while closing session backend")
150
+
151
+ pool = getattr(backend, "connection_pool", None)
152
+ disconnect = getattr(pool, "disconnect", None) if pool else None
153
+ if disconnect is not None:
154
+ try:
155
+ disconnect_result = disconnect()
156
+ if inspect.isawaitable(disconnect_result):
157
+ await disconnect_result
158
+ except Exception:
159
+ app.logger.exception("Error while disconnecting session pool")
160
+
161
+ try:
162
+ await asyncio.wait_for(ext.engine.dispose(), timeout=5)
163
+ except TimeoutError:
164
+ app.logger.warning("Timed out while disposing SQLAlchemy engine")
165
+
166
+ from stk.security import register_security_controls
167
+ from stk.utils.ratelimit import register_rate_limits
168
+
169
+ register_rate_limits(app)
170
+ register_security_controls(app)
171
+
172
+ Security(
173
+ app,
174
+ user_datastore,
175
+ register_form=ExtendedRegisterForm,
176
+ change_password_form=OAuthAwareChangePasswordForm,
177
+ )
178
+ # The library resets its session before loading the user. Share that session
179
+ # so signals and application routes participate in the same transaction.
180
+ app.before_request(_open_session)
181
+ app.before_websocket(_open_ws_session)
182
+
183
+ # Session initialization
184
+ if app.config.get("SESSION_TYPE") == "redis":
185
+ session.init_app(app)
186
+ # For non-redis, fall back to Quart's built-in cookie sessions
187
+
188
+ # CSRF token for POSTs outside quart-security templates (e.g. logout form).
189
+ # Uses the library's own get-or-create so tokens stay in sync.
190
+ app.jinja_env.globals["csrf_token"] = _ensure_csrf_token
191
+
192
+ return None
193
+
194
+
195
+ def register_blueprints(app):
196
+ app.register_blueprint(bp_user)
197
+ app.register_blueprint(public)
198
+ app.register_blueprint(portal)
199
+ app.register_blueprint(ws_bp)
200
+ if agent_login_enabled(app):
201
+ app.register_blueprint(bp_agent_login)
202
+ return None
203
+
204
+
205
+ @functools.cache
206
+ def _static_digest(path, _mtime_ns):
207
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
208
+
209
+
210
+ def register_static_versioning(app):
211
+ """Version `url_for('static', ...)` by content so a deploy never serves stale JS.
212
+
213
+ Unversioned files carry no cache lifetime, so browsers keep them heuristically
214
+ and an old component can blank every page until a hard reload.
215
+ """
216
+
217
+ @app.url_defaults
218
+ def _version_static(endpoint, values):
219
+ if endpoint == "static" and "filename" in values:
220
+ path = app.static_file(values["filename"])
221
+ if path is None:
222
+ raise FileNotFoundError(f"no static file {values['filename']}")
223
+ values["v"] = _static_digest(path, path.stat().st_mtime_ns)
224
+
225
+ @app.after_request
226
+ async def _cache_static(response):
227
+ if request.path.startswith(f"{app.static_url_path}/"):
228
+ response.headers["Cache-Control"] = (
229
+ "public, max-age=31536000, immutable"
230
+ if request.args.get("v")
231
+ else "no-cache"
232
+ )
233
+ return response
234
+
235
+
236
+ CORE_NAVIGATION_START = [
237
+ {"heading": "Main"},
238
+ {"title": "Dashboard", "icon": "ti ti-home", "to": "/dashboard"},
239
+ ]
240
+ CORE_NAVIGATION_END = [
241
+ {"heading": "Account"},
242
+ {"title": "Change Password", "icon": "ti ti-key", "to": "/change"},
243
+ {"title": "Two-Factor Auth", "icon": "ti ti-shield-lock", "to": "/tf-setup"},
244
+ {"title": "Recovery Codes", "icon": "ti ti-lifebuoy", "to": "/mf-recovery-codes"},
245
+ {"heading": "Administration"},
246
+ {
247
+ "title": "User Management",
248
+ "icon": "ti ti-users-group",
249
+ "role": "admin",
250
+ "children": [
251
+ {"title": "Users", "icon": "ti ti-users", "to": "/users"},
252
+ {"title": "Roles", "icon": "ti ti-shield", "to": "/roles"},
253
+ ],
254
+ },
255
+ {
256
+ "title": "Activity Logs",
257
+ "icon": "ti ti-history",
258
+ "to": "/activities",
259
+ "role": "admin",
260
+ },
261
+ ]
262
+
263
+
264
+ def register_navigation(app):
265
+ app.jinja_env.globals["stk_navigation"] = lambda: [
266
+ *CORE_NAVIGATION_START,
267
+ *app.navigation,
268
+ *CORE_NAVIGATION_END,
269
+ ]
270
+
271
+
272
+ def register_errorhandlers(app):
273
+ import logging
274
+
275
+ logger = logging.getLogger(__name__)
276
+
277
+ def _is_api_request():
278
+ return (
279
+ request.path.startswith("/api/")
280
+ or request.accept_mimetypes.best == "application/json"
281
+ )
282
+
283
+ @app.errorhandler(Exception)
284
+ async def handle_exception(error):
285
+ db_session = g.pop("db_session", None)
286
+ if db_session is not None:
287
+ try:
288
+ # Detach loaded instances first so rollback doesn't expire them;
289
+ # error templates still read current_user after the session closes.
290
+ db_session.expunge_all()
291
+ await db_session.rollback()
292
+ except Exception:
293
+ logger.warning(
294
+ "Failed to rollback session during error handling", exc_info=True
295
+ )
296
+ finally:
297
+ await db_session.close()
298
+
299
+ # Only HTTP errors carry a status; SQLAlchemy errors also have a .code ("e3q8").
300
+ code = error.code if isinstance(error, HTTPException) else 500
301
+ if code == 500:
302
+ logger.exception("Unhandled exception")
303
+
304
+ if _is_api_request():
305
+ return {
306
+ "message": "Internal server error"
307
+ if code == 500
308
+ else getattr(error, "description", "Request failed")
309
+ }, code
310
+ # Fall back to 500.html for codes without a dedicated template (405, 403, ...)
311
+ return await render_template([f"{code}.html", "500.html"]), code
312
+
313
+ async def render_error(error):
314
+ error_code = getattr(error, "code", 500)
315
+ if _is_api_request():
316
+ return {
317
+ "message": error.name if hasattr(error, "name") else "Error"
318
+ }, error_code
319
+ return await render_template([f"{error_code}.html", "500.html"]), error_code
320
+
321
+ for errcode in [401, 404, 500]:
322
+ app.errorhandler(errcode)(render_error)
323
+ return None