- HTMX beforeSwap hook intercepts text/sexp responses and renders
them client-side via sexp.js before HTMX swaps the result in
- sexp_response() helper for returning text/sexp from route handlers
- Test detail page (/test/<nodeid>) with clickable test names
- HTMX navigation to detail returns sexp wire format (4x smaller
than pre-rendered HTML), full page loads render server-side
- ~test-detail component with back link, outcome badge, and
error traceback display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds Sexp.update() for re-rendering data-sexp elements with new data,
Sexp.hydrate() for finding and rendering all [data-sexp] elements,
and auto-init on DOMContentLoaded + htmx:afterSwap integration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Load sexp.js in ~app-layout before body.js
- Auto-process <script type="text/sexp"> tags on DOMContentLoaded
- Re-process after htmx:afterSwap for dynamic content
- Sexp.mount(target, expr, env) for rendering into DOM elements
- Sexp.processScripts() picks up data-components and data-mount tags
- client_components_tag() Python helper serializes Component objects
back to sexp source for client-side consumption
- 37 parity tests all passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Vanilla JS (no build tools) counterpart to shared/sexp/ Python modules.
Parses s-expression text, evaluates special forms, and renders to DOM
nodes or HTML strings. Full component system with defcomp/~name.
Includes:
- Parser: tokenizer + parse/parseAll matching Python parser exactly
- Evaluator: all special forms (if, when, cond, let, and, or, lambda,
defcomp, define, ->, set!), higher-order forms (map, filter, reduce)
- DOM renderer: createElement for HTML tags, SVG namespace support,
component invocation, raw! for pre-rendered HTML, <> fragments
- String renderer: matches Python html.render output for SSR parity
- ~50 built-in primitives (arithmetic, string, collection, predicates)
- 35 parity tests verifying JS output matches Python output via Node.js
Also fixes Python raw! handler to properly unwrap _RawHTML objects.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
blog_url, market_url, cart_url, events_url and g.rights were only
available as Jinja globals, not in the ctx dict passed to sexp
helper functions. This caused all cross-service links in the header
system (post title, cart badge, admin cog, admin nav items) to
produce relative URLs resolving to the current service domain.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When navigating from a deeper page (e.g. day) to a shallower one
(e.g. calendar) via HTMX, orphaned header rows from the deeper page
persisted in the DOM because OOB swaps only replaced specific child
divs, not siblings. Fix by sending empty OOB swaps to clear all
header row IDs not present at the current depth.
Applied to events (calendars/calendar/day/entry/admin/slots) and
market (market_home/browse/product/admin). Also restore app_label
in root header.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The word "settings" (app_label) was showing next to "Rose Ash 2.0"
in the top bar. Removed that label while restoring the settings cog
icon on the right side of the menu bar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The settings page is accessible via its own route; no need for a
persistent cog icon next to Rose Ash 2.0.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pass settings_url and is_admin to header-row component so the blog
settings cog appears on the root header row for admin users across
all services. Links to blog /admin/.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move admin cog generation and container_nav border wrapping from
blog-specific wrapper into shared post_header_html so all services
render identical post header rows. Blog, events, cart all delegate
to the shared helper now. Cart admin pages fetch container_nav_html
via fragments. Village Hall always links to blog.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move post admin header into shared/sexp/helpers.py so blog, cart,
events, and market all render the same admin row with identical nav:
calendars | markets | payments | entries | data | edit | settings.
All links are external (cross-service). The selected item shows
highlighted on the right and as white text next to "admin" on the left.
- blog: delegates to shared helper, removes blog-specific nav builder
- cart: delegates to shared helper for payments admin
- events: adds shared admin row (selected=calendars) to calendar admin
- market: adds /<slug>/admin/ route + page_admin blueprint, delegates
to shared helper (selected=markets). Fixes 404 on page-level admin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Show current subdomain name (blog, cart, events, etc.) next to the site
title in the root header row. Remove the redundant second "cart" menu row
from cart overview and checkout error pages.
Add dev-mode hot-reload for sexp templates: track file mtimes and re-read
changed files per-request when RELOAD=true, so .sexp edits are picked up
without restarting services.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
base_context() doesn't include blog_url/cart_url/etc — those live in
Jinja globals. Without them call_url(ctx, "blog_url", ...) falls back
to a relative path instead of https://blog.rose-ash.com/...
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Build a minimal context directly instead of relying on
get_template_context() which runs the full context processor chain
including cross-service fragment fetches. Each step (base_context,
fragments, post hydration) is independently try/excepted so the page
renders with whatever is available.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The relations container-nav fragment was inserting nav_label (e.g.
"calendars", "markets") as a URL path segment, generating wrong links
like /the-village-hall/markets/suma/ instead of /the-village-hall/suma/.
The nav_label is for display only, not URL construction.
Also adds a rich 404 handler that shows site headers and post breadcrumb
when a slug can be resolved from the URL path. Falls back gracefully to
the minimal error page if context building fails.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cross-subdomain hx-get breaks due to OAuth redirects. When external=true,
menu-row renders a plain <a href> without HTMX attributes, allowing
normal browser navigation.
Applied to post header links on events and market services which link
back to blog.rose-ash.com.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace all 676 inline sexp() string calls across 7 services with
render(component_name, **kwargs) calls backed by 46 external .sexpr
component definition files (587 defcomps total).
- Add render() function to shared/sexp/jinja_bridge.py
- Add load_service_components() helper and update load_sexp_dir() for *.sexpr
- Update parser keyword regex to support HTMX hx-on::event syntax
- Convert remaining inline HTML in route files to render() calls
- Add shared/sexp/templates/misc.sexp for cross-service utility components
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Disable htmx selfRequestsOnly, add CORS headers for *.rose-ash.com
- Remove same-origin guards from ~menu-row and ~nav-link htmx attrs
- Convert ~app-layout from string-concatenated HTML to pure sexp tree
- Extract ~app-head component, replace ~app-shell with inline structure
- Convert hamburger SVG from Python HTML constant to ~hamburger sexp component
- Fix cross-domain fragment URLs (events_url, market_url)
- Fix starts-with? primitive to handle nil values
- Fix duplicate admin menu rows on OOB swaps
- Add calendar admin nav links (slots, description)
- Convert slots page from Jinja to sexp rendering
- Disable page caching in development mode
- Backfill migration to clean orphaned container_relations
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Switch all cross-service relation calls to the new registry-aware
relate/unrelate/can-relate actions, and consolidate per-service
container-nav fragment fetches into the generic relations handler.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move 24 defcomp definitions from Python string constants in components.py
to 7 grouped .sexp files under shared/sexp/templates/. Add load_sexp_dir()
to jinja_bridge.py for file-based loading. Migrate events and market
link-card fragment handlers from render_template to sexp. Delete 9
superseded Jinja HTML fragment templates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes EvalError: Undefined symbol: path when rendering ~mobile-filter
component which uses an SVG <path> element.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Declarative relation registry via defrelation s-expressions with
cardinality enforcement (one-to-one, one-to-many, many-to-many),
registry-aware relate/unrelate/can-relate API endpoints, generic
container-nav fragment, and relation-driven UI components.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migrate ~52 GET route handlers across all 7 services from Jinja
render_template() to s-expression component rendering. Each service
gets a sexp_components.py with page/oob/cards render functions.
- Add per-service sexp_components.py (account, blog, cart, events,
federation, market, orders) with full page, OOB, and pagination
card rendering
- Add shared/sexp/helpers.py with call_url, root_header_html,
full_page, oob_page utilities
- Update all GET routes to use get_template_context() + render fns
- Fix get_template_context() to inject Jinja globals (URL helpers)
- Add qs_filter to base_context for sexp filter URL building
- Mount sexp_components.py in docker-compose.dev.yml for all services
- Import sexp_components in app.py for Hypercorn --reload watching
- Fix route_prefix import (shared.utils not shared.infrastructure.urls)
- Fix federation choose-username missing actor in context
- Fix market page_markets missing post in context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 9 new shared s-expression components (cart-mini, auth-menu,
account-nav-item, calendar-entry-nav, calendar-link-nav, market-link-nav,
post-card, base-shell, error-page) and wire them into all fragment route
handlers. 404/403 error pages now render entirely via s-expressions as a
full-page proof-of-concept, with Jinja fallback on failure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add setup_sexp_bridge() and load_shared_components() to factory.py
so all services get s-expression support automatically
- Create shared/sexp/components.py with ~link-card component definition
(replaces 5 per-service Jinja link_card.html templates)
- Replace blog's link-card fragment handler to use sexp() instead of
render_template() — first real s-expression rendered page content
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two-way bridge: sexp() Jinja global renders s-expression components in
templates, register_components() loads definitions at startup. Includes
~link-card component test proving unified replacement of 5 per-service
Jinja fragment templates.
19 new tests (218 total).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tree walker collects I/O nodes (frag, query, action, current-user,
htmx-request?), dispatches them via asyncio.gather(), substitutes results,
and renders to HTML. Failed I/O degrades gracefully to empty string.
27 new tests (199 total), all mocked at execute_io boundary — no
infrastructure dependencies needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
S-expression AST → HTML string renderer with ~100 HTML tags, void elements,
boolean attributes, XSS escaping, raw!, fragments, and components. Render-aware
special forms (if, when, cond, let, map, etc.) handle HTML tags in control flow
branches correctly by calling _render instead of _eval.
63 new tests (172 total across parser, evaluator, renderer).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
S-expression parser, evaluator, and primitive registry in shared/sexp/.
109 unit tests covering parsing, evaluation, special forms, lambdas,
closures, components (defcomp), and 60+ pure builtins.
Test infrastructure: Dockerfile.unit (tier 1, fast) and
Dockerfile.integration (tier 2, ffmpeg). Dev watch mode auto-reruns
on file changes. Deploy gate blocks push on test failure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the shared fallback template with a Jinja macro that each domain
(blog, events, market) can call with its own domain-specific nav items.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Used by both blog and events — belongs in shared/browser/templates
where the ChoiceLoader fallback resolves it for all apps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 1 - Relations service (internal): owns ContainerRelation, exposes
get-children data + attach/detach-child actions. Retargeted events, blog,
market callers from cart to relations.
Phase 2 - Likes service (internal): unified Like model replaces ProductLike
and PostLike with generic target_type/target_slug/target_id. Exposes
is-liked, liked-slugs, liked-ids data + toggle action.
Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries,
removed proxy endpoints from cart.
Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout
flow. Cart checkout now delegates to orders via create-order action.
Webhook/return routes and reconciliation moved to orders.
Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated
for all 3 new services. Added orders_url helper and factory model imports.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
entry_associations only uses HTTP fetch_data/call_action, no direct DB.
Events app imported it via ..post.services which doesn't exist in events.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
container_relations is a generic parent/child graph used by blog
(menu_nodes), market (marketplaces), and events (calendars). Move it
to cart as shared infrastructure. All services now call cart actions
(attach-child/detach-child) instead of querying the table directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
page_configs table lives in db_cart but blog was querying it directly,
causing UndefinedTableError. Move all PageConfig read/write endpoints to
cart service and have blog proxy via fetch_data/call_action.
Also fix OAuth callback to use code_hash lookup (codes are now stored
hashed) and pass grant_token in redirect URL to prevent auth loops.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove cross-DB relationships (CartItem.product, CartItem.market_place,
OrderItem.product) that break with per-service databases. Denormalize
product and marketplace fields onto cart_items/order_items at write time.
- Add AP internal inbox infrastructure (shared/infrastructure/internal_inbox*)
for synchronous inter-service writes via HMAC-authenticated POST
- Cart inbox blueprint handles Add/Remove/Update rose:CartItem activities
- Market app sends AP activities to cart inbox instead of writing CartItem directly
- Cart services use denormalized columns instead of cross-DB hydration/joins
- Add marketplaces-by-ids data endpoint to market service
- Alembic migration adds denormalized columns to cart_items and order_items
- Add OAuth device flow auth to market scraper persist_api (artdag client pattern)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Critical: Add ownership checks to all order routes (IDOR fix).
High: Redis rate limiting on auth endpoints, HMAC-signed internal
service calls replacing header-presence-only checks, nh3 HTML
sanitization on ghost_sync and product import, internal auth on
market API endpoints, SHA-256 hashed OAuth grant/code tokens.
Medium: SECRET_KEY production guard, AP signature enforcement,
is_admin param removal, cart_sid validation, SSRF protection on
remote actor fetch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
_load_user ran before _check_auth_state, so g.user was set to the wrong
user before the grant check could clear the stale session. Now grant
verification runs first, ensuring stale sessions are cleared before
the user is loaded.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues fixed:
- Sessions with uid but no grant_token (legacy or corrupt) were not
validated at all, allowing a user to be logged in as whoever got
their old numeric user ID after a DB rebuild
- DB errors during grant verification silently kept stale sessions
alive; now treated as invalid to fail-safe
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each service (blog, market, cart, events, federation, account) now owns
its own database schema with independent Alembic migrations. Removes the
monolithic shared/alembic/ that ran all migrations against a single DB.
- Add per-service alembic.ini, env.py, and 0001_initial.py migrations
- Add shared/db/alembic_env.py helper with table-name filtering
- Fix cross-DB FK in blog/models/snippet.py (users lives in db_account)
- Fix cart_impl.py cross-DB queries: fetch products and market_places
via internal data endpoints instead of direct SQL joins
- Fix blog ghost_sync to fetch page_configs from cart via data endpoint
- Add products-by-ids and page-config-ensure data endpoints
- Update all entrypoint.sh to create own DB and run own migrations
- Cart now uses db_cart instead of db_market
- Add docker-compose.dev.yml, dev.sh for local development
- CI deploys both rose-ash swarm stack and rose-ash-dev compose stack
- Fix Quart namespace package crash (root_path in factory.py)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Error handlers for FragmentError and generic Exception now return
self-contained HTML (no render_template) to avoid the infinite loop
where context processor → fetch_fragments → error → render_template
→ context processor → fetch_fragments → error ...
- Account Ghost membership sync moved to background task so it doesn't
block Hypercorn's startup timeout (was causing crash-loop).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
FragmentError now renders a 503 page naming which service is down
instead of a generic 500 error. Helps debug during deploys.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AP blueprints (activitypub.py, ap_social.py) were querying federation
tables (ap_actor_profiles etc.) on g.s which points to the app's own DB
after the per-app split. Now uses g._ap_s backed by get_federation_session()
for non-federation apps.
Also hardens Ghost sync before_app_serving to catch/rollback on failure
instead of crashing the Hypercorn worker.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>