create-caspian-app 1.3.18 → 1.3.20
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.
|
@@ -70,6 +70,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
|
|
|
70
70
|
- Components are authored single-file. Return `html(r"""...""", **context)` (import `html` from `casp.component_decorator`) to keep markup, server interpolation, and a PulsePoint `<script>` inline. Inside `html(...)`, `{{ ... }}` is server-side Jinja and `{ ... }` is left for PulsePoint; never use a Python f-string for the markup. Autoescaping is on, so `{{ value }}` is safe for user text and trusted HTML needs `Markup(...)` or `| safe`; a `children` value is auto-safe. Keep each file focused on one responsibility. Split multiple panels, tabs, forms, tables, cards, and toolbars into separate components instead of making one giant single-file component.
|
|
71
71
|
- For single-file Python components that receive browser props, treat the Python render as an explicit bridge. Attributes on the parent `x-*` tag arrive as raw string kwargs, including unevaluated PulsePoint expressions such as `"{permOpen}"`; they do not reach `pp.props` merely because the Python signature accepts them. Re-emit browser-facing values on the component's single native root with `attributes = get_attributes({...}, props)`, `<root {{ attributes }}>`, and `html(..., attributes=attributes)`. Otherwise `pp.props` is silently empty or missing those keys with no error or warning. Forwarded props are real DOM attributes, so avoid accidental native behavior such as a `title` tooltip by choosing a non-native API name such as `user-name` when appropriate. Follow the full helper and prop-passing contract in `node_modules/caspian-utils/dist/docs/components.md`.
|
|
72
72
|
- Use real Python imports for child components everywhere: a module's `x-*` tags resolve from the Components imported into that module (pages and layouts included), which disambiguates same-name components across directories. Runtime resolution precedence is inherited ancestor components, then the module's own Python imports. Slot content resolves in the scope where it was authored, so the module that writes an `x-*` tag must import that component. For directories whose names are not valid identifiers (hyphens, `(group)`), bind via `importlib.import_module(...)` assignment.
|
|
73
|
+
- All three import forms resolve, so pick the one that reads best rather than working around a directory layout. Import a name from its own file (`from src.lib.maddex.Button import Button`), import several names from a file that exports several (`from src.lib.maddex.Breadcrumb import Breadcrumb, BreadcrumbItem`), or import straight from a one-component-per-file **directory** without naming each file (`from src.lib.ppicons import Search, ArrowLeft` -> `<x-search />`, `<x-arrow-left />`). That last form is what the generated component directories are built for and is the preferred way to pull in icons. Python binds the _submodules_ there, since a component directory has no `__init__.py` re-exports, so Caspian unwraps a module binding that defines a component under its own file name (`Search.py` -> `Search`). The tag follows the _binding_ name, so `from src.lib.ppicons import Search as MagnifyIcon` renders `<x-magnify-icon />`. Nothing else is unwrapped: a helper module (`utils.py`), an ordinary `import os`, a bare package, or a file whose function is missing the `@component` decorator all still raise `UnknownComponentError` — so that error on a correct-looking import usually means the missing decorator, not the import form. The directory form also reaches only the component named after its file, so a multi-export file's other exports keep the exact-file form: `from src.lib.maddex import BreadcrumbItem` is a plain `ImportError` because there is no `BreadcrumbItem.py`. Ruff sees all three forms as unused imports, but `settings/check.py` suppresses `F401` for any name used as an `x-*` tag, so the gate stays clean.
|
|
73
74
|
- For CRUD operations and any browser-initiated reads from the backend, use route or backend `@rpc()` actions on the server and `pp.rpc(...)` from PulsePoint code on the client unless the user explicitly asks for another integration pattern.
|
|
74
75
|
- Google and GitHub OAuth ship pre-registered in this starter: `main.py` already calls `Auth.set_providers(GithubProvider(), GoogleProvider())`, and `AuthMiddleware` already handles the `signin/{google,github}` and `callback/{google,github}` paths under `api_auth_prefix` (default `/api/auth`). To add social sign-in, point a link or button at `/api/auth/signin/google` or `/api/auth/signin/github` and set the provider credentials in `.env` (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`). Do not hand-roll an OAuth flow, manual `httpx2`/`authlib` token exchange, or custom callback routes; reuse the shipped providers and let `auth.auth_providers(...)` own redirect, callback, and sign-in.
|
|
75
76
|
- For one-way streaming output, including AI/LLM/chat token streams, use Caspian's shipped RPC streaming: write a generator `@rpc()` action that `yield`s chunks (the runtime wraps generators as SSE via `casp.streaming.SSE`) and consume it with `pp.rpc(name, args, { onStream, onStreamComplete, onStreamError })`. When bridging a Python LLM/SDK stream, `async for` over the provider's stream inside the `@rpc()` action and `yield` each token. Do not reinvent one-way streaming with raw `fetch`/`ReadableStream`, `EventSource`, or a WebSocket; reserve WebSockets for genuinely bidirectional channels per the WebSocket rules above.
|
package/dist/AGENTS.md
CHANGED
|
@@ -67,7 +67,7 @@ Authoring is **Python-only and single-file**. There are no `.html` sidecars in t
|
|
|
67
67
|
**Components (`src/components/**/\*.py`):\*\*
|
|
68
68
|
|
|
69
69
|
- One `@component` function per responsibility, markup inline via `html(r"""...""")`, PulsePoint `<script>` inside the root. Split by responsibility exactly as you would split React components — **that analogy covers decomposition and hook API only, never markup syntax** (see the PulsePoint contract below).
|
|
70
|
-
- **Composition is Python-import-driven.** An `<x-*>` tag resolves from the `Component` objects imported into the module that authors the tag: `Container` → `<x-container>`, `CommandDialog` → `<x-command-dialog>`. Import every tag you write, including in pages and layouts. Same-file multi-exports are imported from that exact file. Directories that are not valid identifiers (hyphens, `(group)`) bind via `Name = importlib.import_module("src.app.some-dir.Name").Name`.
|
|
70
|
+
- **Composition is Python-import-driven.** An `<x-*>` tag resolves from the `Component` objects imported into the module that authors the tag: `Container` → `<x-container>`, `CommandDialog` → `<x-command-dialog>`. Import every tag you write, including in pages and layouts. Same-file multi-exports are imported from that exact file. Directories that are not valid identifiers (hyphens, `(group)`) bind via `Name = importlib.import_module("src.app.some-dir.Name").Name`. **A package import of a one-component-per-file directory also works** — `from src.lib.ppicons import Search, ArrowLeft` → `<x-search />`, `<x-arrow-left />` — even though Python binds the _submodules_ there rather than the Components (a component directory has no `__init__.py` re-exports). A module binding is unwrapped when the module defines a component under its own file name (`Search.py` → `Search`), and the tag alias stays the _binding_ name, so `import Search as MagnifyIcon` gives `<x-magnify-icon />`. Nothing else is unwrapped: `utils.py`, `import os`, a bare package, or a file whose function is missing `@component` still raise `UnknownComponentError`.
|
|
71
71
|
- Resolution precedence inside a component's output: inherited ancestor components, then the module's own imports (imports win). Slot content resolves in the scope where it was **authored**, so the module writing the tag must import it.
|
|
72
72
|
- A component may also be called directly as a function and interpolated with `{{ }}`; its nested tags still resolve from its own module's imports.
|
|
73
73
|
- **Root shape:** default to one authored top-level element with the `<script>` inside it.
|
package/dist/main.py
CHANGED
|
@@ -27,6 +27,7 @@ from fastapi.responses import (
|
|
|
27
27
|
RedirectResponse,
|
|
28
28
|
HTMLResponse,
|
|
29
29
|
JSONResponse,
|
|
30
|
+
PlainTextResponse,
|
|
30
31
|
)
|
|
31
32
|
from starlette.datastructures import MutableHeaders
|
|
32
33
|
from starlette.middleware import Middleware
|
|
@@ -472,6 +473,54 @@ class SecurityHeadersMiddleware:
|
|
|
472
473
|
await self.app(scope, receive, send_wrapper)
|
|
473
474
|
|
|
474
475
|
|
|
476
|
+
# Top-level directories under `public/` are asset namespaces, not page routes:
|
|
477
|
+
# `public/js/**` owns `/js/**` and nothing in `src/app/` can answer there. Built
|
|
478
|
+
# once, like SECURITY_HEADERS -- adding a new asset *directory* is a restart-level
|
|
479
|
+
# change, unlike adding a file to an existing one, which stays live.
|
|
480
|
+
PUBLIC_ASSET_NAMESPACES: frozenset[str] = (
|
|
481
|
+
frozenset(entry.name.casefold() for entry in Path("public").iterdir() if entry.is_dir())
|
|
482
|
+
if Path("public").is_dir()
|
|
483
|
+
else frozenset()
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
class MissingPublicAssetMiddleware:
|
|
488
|
+
"""404 a missing file in a public asset namespace instead of falling through.
|
|
489
|
+
|
|
490
|
+
`PublicFilesMiddleware` deliberately falls through when no file matches, so
|
|
491
|
+
normal routing keeps working. With `is_all_routes_private=True` that means a
|
|
492
|
+
missing asset reaches `AuthMiddleware` and answers `303 -> /signin`, which is
|
|
493
|
+
the wrong answer twice over: a `<script src="/js/typo.js">` then receives the
|
|
494
|
+
sign-in *page* as `200 text/html` and fails with a parse error that names the
|
|
495
|
+
wrong file, and every bogus asset path returns a full HTML page to anonymous
|
|
496
|
+
traffic. A path whose first segment is a real `public/` directory can only be
|
|
497
|
+
an asset request, so a miss there is a genuine 404.
|
|
498
|
+
|
|
499
|
+
Runs inside the rate limiter -- a 404 flood is still a flood -- but outside
|
|
500
|
+
sessions, CSRF, and auth, so a missing asset costs no session decryption.
|
|
501
|
+
"""
|
|
502
|
+
|
|
503
|
+
def __init__(self, app: ASGIApp):
|
|
504
|
+
self.app = app
|
|
505
|
+
|
|
506
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
|
|
507
|
+
if scope["type"] != "http" or scope.get("method", "GET").upper() not in {
|
|
508
|
+
"GET",
|
|
509
|
+
"HEAD",
|
|
510
|
+
}:
|
|
511
|
+
await self.app(scope, receive, send)
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
segment = str(scope.get("path", "")).lstrip("/").split("/", 1)[0].casefold()
|
|
515
|
+
if segment not in PUBLIC_ASSET_NAMESPACES:
|
|
516
|
+
await self.app(scope, receive, send)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
# PublicFilesMiddleware sits outside this one, so reaching here means it
|
|
520
|
+
# already declined: the file does not exist or escapes the public root.
|
|
521
|
+
await PlainTextResponse("Not Found", status_code=404)(scope, receive, send)
|
|
522
|
+
|
|
523
|
+
|
|
475
524
|
class BodySizeLimitMiddleware:
|
|
476
525
|
"""Reject oversized HTTP request bodies before route or RPC parsing."""
|
|
477
526
|
|
|
@@ -1534,6 +1583,9 @@ app.add_middleware(
|
|
|
1534
1583
|
path="/",
|
|
1535
1584
|
)
|
|
1536
1585
|
app.add_middleware(BodySizeLimitMiddleware)
|
|
1586
|
+
# Sits between the limiter and the session/auth layers: a miss under a public
|
|
1587
|
+
# asset namespace is a 404, not a sign-in redirect, and costs no session work.
|
|
1588
|
+
app.add_middleware(MissingPublicAssetMiddleware)
|
|
1537
1589
|
# Outermost of the security layers: reject flooding before any session
|
|
1538
1590
|
# decryption, template rendering, or database work is spent on the request.
|
|
1539
1591
|
app.add_middleware(RateLimitMiddleware)
|
|
@@ -90,7 +90,17 @@ RULES: list[Rule] = [
|
|
|
90
90
|
Rule(
|
|
91
91
|
"unquoted-brace-attr",
|
|
92
92
|
# `class={...}` / `selected={...}` -- invalid HTML, silently blanks the page.
|
|
93
|
-
|
|
93
|
+
#
|
|
94
|
+
# An attribute only exists *inside an opening tag*, and the rule must say
|
|
95
|
+
# so. A bare `\s[\w:.\-]+=\{` also matches `DIR={path}` in a shell script
|
|
96
|
+
# and `ENV PORT={port}` in a Dockerfile -- and this repo embeds both in
|
|
97
|
+
# triple-quoted strings under `src/lib/aws/`, which the Python scan keeps
|
|
98
|
+
# because it cannot tell a heredoc from a template. Requiring the opening
|
|
99
|
+
# tag is not a loosening: a real violation is always inside one.
|
|
100
|
+
#
|
|
101
|
+
# `[^<>]*?` bounds the attribute run to a single tag; it matches newlines
|
|
102
|
+
# (negated classes do), so an attribute on its own line is still caught.
|
|
103
|
+
re.compile(r"<[a-zA-Z][\w:.\-]*(?:[^<>]*?)?\s[\w:.\-]+=\{"),
|
|
94
104
|
"Unquoted brace attribute. This is invalid HTML: the parser splits the "
|
|
95
105
|
"value on spaces, the component root never compiles, and the page "
|
|
96
106
|
'renders blank with no console error. Quote it: attr="{expr}".',
|
|
@@ -116,7 +126,12 @@ RULES: list[Rule] = [
|
|
|
116
126
|
),
|
|
117
127
|
Rule(
|
|
118
128
|
"jsx-fragment",
|
|
119
|
-
|
|
129
|
+
# `</>` is unambiguous, and a well-formed fragment always has one. A bare
|
|
130
|
+
# `<>` is not: it is SQL's not-equals operator, and this repo runs
|
|
131
|
+
# `WHERE pid <> pg_backend_pid()` from a triple-quoted string. So the
|
|
132
|
+
# open tag counts only when an element follows it, which is what a
|
|
133
|
+
# fragment looks like and what `<> value` in SQL never does.
|
|
134
|
+
re.compile(r"</>|<>\s*<"),
|
|
120
135
|
"JSX fragment. A template needs exactly one real root element.",
|
|
121
136
|
),
|
|
122
137
|
Rule(
|