diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..53c1b82
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,37 @@
+# Database
+DB_PASSWORD=change_me_in_production
+
+# Mollie Payments (https://my.mollie.com/dashboard)
+MOLLIE_API_KEY=test_xxx
+
+# POD Providers
+PRODIGI_API_KEY=xxx
+PRINTFUL_API_TOKEN=xxx
+PRINTFUL_STORE_ID=
+POD_SANDBOX_MODE=true
+
+# Auth
+JWT_SECRET=generate_a_strong_secret_here
+
+# App
+CORS_ORIGINS=https://rswag.online
+PUBLIC_URL=https://rswag.online
+
+# AI Design Generation
+GEMINI_API_KEY=xxx
+
+# TBFF Revenue Split → Bonding Curve
+# Leave FLOW_SERVICE_URL empty to disable flow routing
+FLOW_SERVICE_URL=http://flow-service:3010
+FLOW_ID=xxx
+FLOW_FUNNEL_ID=xxx
+FLOW_REVENUE_SPLIT=0.5
+
+# SMTP Email
+SMTP_HOST=mail.example.com
+SMTP_PORT=587
+SMTP_USER=noreply@example.com
+SMTP_PASS=changeme
+
+# Frontend
+NEXT_PUBLIC_API_URL=https://rswag.online/api
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f731256
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,70 @@
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+# Python lib directories (not frontend/lib)
+/lib/
+/lib64/
+backend/lib/
+backend/lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+.venv/
+venv/
+ENV/
+
+# Node
+node_modules/
+.next/
+out/
+.pnpm-store/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+logs/
+
+# Database
+*.db
+*.sqlite3
+
+# Test
+.coverage
+htmlcov/
+.pytest_cache/
+.tox/
+
+# Build
+*.pyc
+.cache/
+
+# Docker
+docker-compose.override.yml
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..73df230
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,92 @@
+# rSwag - AI Assistant Context
+
+## Project Overview
+
+E-commerce platform for rSpace ecosystem merchandise (stickers, shirts, prints) with Mollie payments and print-on-demand fulfillment via Printful and Prodigi. Part of the rSpace ecosystem (rspace.online).
+
+## Architecture
+
+- **Frontend**: Next.js 15 App Router, shadcn/ui, Tailwind CSS, Geist font
+- **Backend**: FastAPI, SQLAlchemy, Alembic
+- **Database**: PostgreSQL
+- **Payments**: Mollie (redirect flow, Dutch data residency)
+- **Fulfillment**: Printful (apparel), Prodigi (stickers/prints)
+- **AI Design**: Gemini API for design generation
+- **Deployment**: Docker on Netcup RS 8000, Traefik routing
+
+## Key Directories
+
+| Directory | Purpose |
+|-----------|---------|
+| `backend/app/api/` | FastAPI route handlers |
+| `backend/app/models/` | SQLAlchemy ORM models |
+| `backend/app/schemas/` | Pydantic request/response schemas |
+| `backend/app/services/` | Business logic (mollie, pod, orders, spaces) |
+| `backend/app/pod/` | POD provider clients |
+| `frontend/app/` | Next.js App Router pages |
+| `frontend/components/` | React components |
+| `frontend/lib/` | Utilities (spaces, cn) |
+| `designs/` | Design assets (stickers, shirts, misc) |
+| `spaces/` | Space configs (multi-tenant branding/theming) |
+
+## Spaces (Multi-Tenant)
+
+rSwag supports subdomain-based spaces. Each space has its own branding, theme, and product catalog.
+
+- **Config**: `spaces/{space_id}/space.yaml` defines name, theme colors, design filter, tips
+- **Subdomain routing**: `{space}.rswag.online` detected by Next.js middleware, sets `space_id` cookie
+- **API filtering**: `GET /api/products?space=fungiflows` returns only that space's designs
+- **Theme injection**: CSS variables overridden at runtime from space config
+- **Cart isolation**: localStorage keys scoped by space (`cart_id_fungiflows`)
+- **Current spaces**: `_default` (rSwag hub), `fungiflows` (Fungi Flows merch)
+
+## Design Source
+
+Designs are stored in-repo at `./designs/` and mounted into the backend container.
+
+Each design has a `metadata.yaml` with name, description, products, variants, pricing, and `space` field.
+
+## API Endpoints
+
+### Spaces
+- `GET /api/spaces` - List all spaces
+- `GET /api/spaces/{id}` - Get space config (branding, theme, tips)
+
+### Public
+- `GET /api/designs` - List active designs (optional: `?space=X`)
+- `GET /api/designs/{slug}` - Get design details
+- `GET /api/designs/{slug}/image` - Serve design image
+- `GET /api/products` - List products with variants (optional: `?space=X`)
+- `POST /api/cart` - Create cart
+- `GET/POST/DELETE /api/cart/{id}/items` - Cart operations
+- `POST /api/checkout/session` - Create Mollie payment
+- `GET /api/orders/{id}` - Order status (requires email)
+- `POST /api/design/generate` - AI design generation
+
+### Webhooks
+- `POST /api/webhooks/mollie` - Mollie payment events
+- `POST /api/webhooks/prodigi` - Prodigi fulfillment updates
+- `POST /api/webhooks/printful` - Printful fulfillment updates
+
+### Admin (JWT required)
+- `POST /api/admin/auth/login` - Admin login
+- `GET /api/admin/orders` - List orders
+- `GET /api/admin/analytics/*` - Sales metrics
+
+## Deployment
+
+Push to Gitea triggers webhook auto-deploy on Netcup at `/opt/apps/rswag/`.
+
+## Branding
+
+Default (rSwag):
+- **Primary color**: Cyan (HSL 195 80% 45%)
+- **Secondary color**: Orange (HSL 45 80% 55%)
+- **Font**: Geist Sans + Geist Mono
+- **Theme**: rSpace spatial web aesthetic
+
+Fungi Flows space (`fungiflows.rswag.online`):
+- **Primary**: Gold (#ffd700)
+- **Secondary**: Bioluminescent green (#39ff14)
+- **Background**: Deep purple (#08070d)
+- **Theme**: Psychedelic mushroom hip-hop aesthetic
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6e9b829
--- /dev/null
+++ b/README.md
@@ -0,0 +1,87 @@
+# rSwag
+
+Merchandise store for the **rSpace ecosystem** at **rswag.online**
+
+## Stack
+
+- **Frontend**: Next.js 15 + shadcn/ui + Tailwind CSS + Geist font
+- **Backend**: FastAPI + SQLAlchemy + Alembic
+- **Database**: PostgreSQL
+- **Payments**: Mollie (EU data residency)
+- **Fulfillment**: Printful (apparel) + Prodigi (stickers/prints)
+- **AI Design**: Gemini API for on-demand design generation
+
+## Architecture
+
+```
+rswag.online
+ │
+ ▼
+ Cloudflare Tunnel → Traefik
+ │ │
+ ▼ ▼
+ Next.js (3000) FastAPI (8000)
+ │
+ ┌───────────────┼───────────────┐
+ ▼ ▼ ▼
+ PostgreSQL Mollie POD APIs
+```
+
+## Development
+
+### Quick Start
+
+```bash
+cp .env.example .env
+# Edit .env with your API keys
+
+docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
+
+# Backend: http://localhost:8000
+# Frontend: http://localhost:3000
+```
+
+### Local Development (without Docker)
+
+```bash
+# Backend
+cd backend
+pip install -e .
+uvicorn app.main:app --reload
+
+# Frontend
+cd frontend
+pnpm install
+pnpm dev
+```
+
+## Project Structure
+
+```
+rswag/
+├── backend/ # FastAPI Python backend
+│ ├── app/
+│ │ ├── api/ # Route handlers
+│ │ ├── models/ # SQLAlchemy ORM
+│ │ ├── schemas/ # Pydantic models
+│ │ ├── services/ # Business logic
+│ │ └── pod/ # POD provider clients
+│ └── alembic/ # Database migrations
+├── frontend/ # Next.js 15 frontend
+│ ├── app/ # App Router pages
+│ ├── components/ # React components
+│ └── lib/ # Utilities
+├── designs/ # Design assets (in-repo)
+│ ├── stickers/
+│ ├── shirts/
+│ └── misc/
+└── config/ # POD provider config
+```
+
+## Deployment
+
+Deployed on Netcup RS 8000 via Docker Compose with Traefik reverse proxy.
+
+```bash
+ssh netcup "cd /opt/apps/rswag && git pull && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build"
+```
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..113c77f
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,52 @@
+# Stage 1: Build
+FROM python:3.12-slim AS builder
+
+WORKDIR /app
+
+# Install build dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ libpq-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Python dependencies
+COPY requirements.txt .
+RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
+
+# Stage 2: Runtime
+FROM python:3.12-slim AS runtime
+
+WORKDIR /app
+
+# Install runtime dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ libpq5 \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create non-root user
+RUN addgroup --system --gid 1001 appgroup && \
+ adduser --system --uid 1001 --ingroup appgroup appuser
+
+# Install wheels
+COPY --from=builder /app/wheels /wheels
+RUN pip install --no-cache /wheels/*
+
+# Copy application code
+COPY --chown=appuser:appgroup app/ ./app/
+COPY --chown=appuser:appgroup alembic/ ./alembic/
+COPY --chown=appuser:appgroup alembic.ini ./
+
+# Copy Infisical entrypoint
+COPY --chown=appuser:appgroup entrypoint.sh ./entrypoint.sh
+RUN chmod +x /app/entrypoint.sh
+
+# Create directories for mounted volumes
+RUN mkdir -p /app/designs /app/config && \
+ chown -R appuser:appgroup /app
+
+USER appuser
+
+EXPOSE 8000
+
+ENTRYPOINT ["/app/entrypoint.sh"]
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/alembic.ini b/backend/alembic.ini
new file mode 100644
index 0000000..534cbc5
--- /dev/null
+++ b/backend/alembic.ini
@@ -0,0 +1,42 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+
+sqlalchemy.url = postgresql://swag:devpassword@localhost:5432/swag
+
+[post_write_hooks]
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
new file mode 100644
index 0000000..948d318
--- /dev/null
+++ b/backend/alembic/env.py
@@ -0,0 +1,83 @@
+"""Alembic environment configuration."""
+
+import asyncio
+from logging.config import fileConfig
+
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+
+from alembic import context
+
+# Import models and base
+from app.database import Base
+from app.models import * # noqa: F401, F403
+
+# this is the Alembic Config object
+config = context.config
+
+# Interpret the config file for Python logging.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# Model metadata for autogenerate
+target_metadata = Base.metadata
+
+
+def get_url():
+ """Get database URL from environment or config."""
+ import os
+ return os.environ.get("DATABASE_URL", config.get_main_option("sqlalchemy.url"))
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode."""
+ url = get_url()
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def do_run_migrations(connection: Connection) -> None:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+ """Run migrations in async mode."""
+ url = get_url()
+ if url.startswith("postgresql://"):
+ url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
+
+ configuration = config.get_section(config.config_ini_section)
+ configuration["sqlalchemy.url"] = url
+
+ connectable = async_engine_from_config(
+ configuration,
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+ await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode."""
+ asyncio.run(run_async_migrations())
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako
new file mode 100644
index 0000000..fbc4b07
--- /dev/null
+++ b/backend/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py
new file mode 100644
index 0000000..24f7582
--- /dev/null
+++ b/backend/alembic/versions/001_initial.py
@@ -0,0 +1,142 @@
+"""Initial database schema
+
+Revision ID: 001_initial
+Revises:
+Create Date: 2026-01-29
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = "001_initial"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Customers table
+ op.create_table(
+ "customers",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("email", sa.String(255), nullable=False),
+ sa.Column("stripe_customer_id", sa.String(255), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_customers_email", "customers", ["email"], unique=True)
+
+ # Carts table
+ op.create_table(
+ "carts",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("customer_id", postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.Column("expires_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ # Cart items table
+ op.create_table(
+ "cart_items",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("cart_id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("product_slug", sa.String(100), nullable=False),
+ sa.Column("product_name", sa.String(255), nullable=False),
+ sa.Column("variant", sa.String(50), nullable=True),
+ sa.Column("quantity", sa.Integer(), nullable=False),
+ sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["cart_id"], ["carts.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ # Orders table
+ op.create_table(
+ "orders",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("customer_id", postgresql.UUID(as_uuid=True), nullable=True),
+ sa.Column("stripe_session_id", sa.String(255), nullable=True),
+ sa.Column("stripe_payment_intent_id", sa.String(255), nullable=True),
+ sa.Column("status", sa.String(50), nullable=True),
+ sa.Column("shipping_name", sa.String(255), nullable=True),
+ sa.Column("shipping_email", sa.String(255), nullable=True),
+ sa.Column("shipping_address_line1", sa.String(255), nullable=True),
+ sa.Column("shipping_address_line2", sa.String(255), nullable=True),
+ sa.Column("shipping_city", sa.String(100), nullable=True),
+ sa.Column("shipping_state", sa.String(100), nullable=True),
+ sa.Column("shipping_postal_code", sa.String(20), nullable=True),
+ sa.Column("shipping_country", sa.String(2), nullable=True),
+ sa.Column("subtotal", sa.Numeric(10, 2), nullable=True),
+ sa.Column("shipping_cost", sa.Numeric(10, 2), nullable=True),
+ sa.Column("tax", sa.Numeric(10, 2), nullable=True),
+ sa.Column("total", sa.Numeric(10, 2), nullable=True),
+ sa.Column("currency", sa.String(3), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.Column("paid_at", sa.DateTime(), nullable=True),
+ sa.Column("shipped_at", sa.DateTime(), nullable=True),
+ sa.Column("delivered_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["customer_id"], ["customers.id"]),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ # Order items table
+ op.create_table(
+ "order_items",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("order_id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("product_slug", sa.String(100), nullable=False),
+ sa.Column("product_name", sa.String(255), nullable=False),
+ sa.Column("variant", sa.String(50), nullable=True),
+ sa.Column("quantity", sa.Integer(), nullable=False),
+ sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
+ sa.Column("pod_provider", sa.String(50), nullable=True),
+ sa.Column("pod_order_id", sa.String(255), nullable=True),
+ sa.Column("pod_status", sa.String(50), nullable=True),
+ sa.Column("pod_tracking_number", sa.String(100), nullable=True),
+ sa.Column("pod_tracking_url", sa.Text(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.ForeignKeyConstraint(["order_id"], ["orders.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ # Admin users table
+ op.create_table(
+ "admin_users",
+ sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
+ sa.Column("email", sa.String(255), nullable=False),
+ sa.Column("password_hash", sa.String(255), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_admin_users_email", "admin_users", ["email"], unique=True)
+
+ # Product overrides table
+ op.create_table(
+ "product_overrides",
+ sa.Column("slug", sa.String(100), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=True),
+ sa.Column("price_override", sa.Numeric(10, 2), nullable=True),
+ sa.Column("updated_at", sa.DateTime(), nullable=True),
+ sa.PrimaryKeyConstraint("slug"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("product_overrides")
+ op.drop_index("ix_admin_users_email", table_name="admin_users")
+ op.drop_table("admin_users")
+ op.drop_table("order_items")
+ op.drop_table("orders")
+ op.drop_table("cart_items")
+ op.drop_table("carts")
+ op.drop_index("ix_customers_email", table_name="customers")
+ op.drop_table("customers")
diff --git a/backend/alembic/versions/002_stripe_to_mollie.py b/backend/alembic/versions/002_stripe_to_mollie.py
new file mode 100644
index 0000000..9a3de22
--- /dev/null
+++ b/backend/alembic/versions/002_stripe_to_mollie.py
@@ -0,0 +1,36 @@
+"""Migrate from Stripe to Mollie payment provider
+
+Revision ID: 002_stripe_to_mollie
+Revises: 001_initial
+Create Date: 2026-02-18
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "002_stripe_to_mollie"
+down_revision: Union[str, None] = "001_initial"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Rename Stripe-specific columns to generic payment columns
+ op.alter_column("orders", "stripe_session_id", new_column_name="payment_id")
+ op.alter_column("orders", "stripe_payment_intent_id", new_column_name="payment_method")
+
+ # Add payment_provider column
+ op.add_column("orders", sa.Column("payment_provider", sa.String(50), nullable=True))
+
+ # Rename stripe_customer_id on customers table
+ op.alter_column("customers", "stripe_customer_id", new_column_name="external_id")
+
+
+def downgrade() -> None:
+ op.alter_column("customers", "external_id", new_column_name="stripe_customer_id")
+ op.drop_column("orders", "payment_provider")
+ op.alter_column("orders", "payment_method", new_column_name="stripe_payment_intent_id")
+ op.alter_column("orders", "payment_id", new_column_name="stripe_session_id")
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
new file mode 100644
index 0000000..f7826c0
--- /dev/null
+++ b/backend/app/__init__.py
@@ -0,0 +1 @@
+# Mycopunk Swag Store Backend
diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py
new file mode 100644
index 0000000..a8efdec
--- /dev/null
+++ b/backend/app/api/__init__.py
@@ -0,0 +1,3 @@
+"""API routes."""
+
+from app.api import designs, products, cart, checkout, orders, webhooks, health
diff --git a/backend/app/api/admin/__init__.py b/backend/app/api/admin/__init__.py
new file mode 100644
index 0000000..9d44e3b
--- /dev/null
+++ b/backend/app/api/admin/__init__.py
@@ -0,0 +1,12 @@
+"""Admin API routes."""
+
+from fastapi import APIRouter
+
+from app.api.admin import auth, orders, analytics, products
+
+router = APIRouter()
+
+router.include_router(auth.router, prefix="/auth", tags=["admin-auth"])
+router.include_router(orders.router, prefix="/orders", tags=["admin-orders"])
+router.include_router(analytics.router, prefix="/analytics", tags=["admin-analytics"])
+router.include_router(products.router, prefix="/products", tags=["admin-products"])
diff --git a/backend/app/api/admin/analytics.py b/backend/app/api/admin/analytics.py
new file mode 100644
index 0000000..d757357
--- /dev/null
+++ b/backend/app/api/admin/analytics.py
@@ -0,0 +1,39 @@
+"""Admin analytics endpoints."""
+
+from datetime import datetime, timedelta
+
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.services.analytics_service import AnalyticsService
+from app.services.auth_service import get_current_admin
+
+router = APIRouter()
+
+
+def get_analytics_service(db: AsyncSession = Depends(get_db)) -> AnalyticsService:
+ return AnalyticsService(db)
+
+
+@router.get("/sales")
+async def get_sales_analytics(
+ days: int = Query(default=30, le=365),
+ service: AnalyticsService = Depends(get_analytics_service),
+ _admin=Depends(get_current_admin),
+):
+ """Get sales analytics (admin only)."""
+ start_date = datetime.utcnow() - timedelta(days=days)
+ return await service.get_sales_summary(start_date)
+
+
+@router.get("/products")
+async def get_product_analytics(
+ days: int = Query(default=30, le=365),
+ limit: int = Query(default=10, le=50),
+ service: AnalyticsService = Depends(get_analytics_service),
+ _admin=Depends(get_current_admin),
+):
+ """Get product performance analytics (admin only)."""
+ start_date = datetime.utcnow() - timedelta(days=days)
+ return await service.get_product_performance(start_date, limit)
diff --git a/backend/app/api/admin/auth.py b/backend/app/api/admin/auth.py
new file mode 100644
index 0000000..7304a40
--- /dev/null
+++ b/backend/app/api/admin/auth.py
@@ -0,0 +1,36 @@
+"""Admin authentication endpoints."""
+
+from fastapi import APIRouter, HTTPException, Depends
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.services.auth_service import AuthService
+
+router = APIRouter()
+
+
+class LoginRequest(BaseModel):
+ email: str
+ password: str
+
+
+class LoginResponse(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+
+
+def get_auth_service(db: AsyncSession = Depends(get_db)) -> AuthService:
+ return AuthService(db)
+
+
+@router.post("/login", response_model=LoginResponse)
+async def login(
+ request: LoginRequest,
+ auth_service: AuthService = Depends(get_auth_service),
+):
+ """Admin login."""
+ token = await auth_service.authenticate(request.email, request.password)
+ if not token:
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+ return LoginResponse(access_token=token)
diff --git a/backend/app/api/admin/orders.py b/backend/app/api/admin/orders.py
new file mode 100644
index 0000000..2d465b7
--- /dev/null
+++ b/backend/app/api/admin/orders.py
@@ -0,0 +1,57 @@
+"""Admin order management endpoints."""
+
+from uuid import UUID
+
+from fastapi import APIRouter, HTTPException, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.schemas.order import OrderResponse, OrderStatus
+from app.services.order_service import OrderService
+from app.services.auth_service import get_current_admin
+
+router = APIRouter()
+
+
+def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
+ return OrderService(db)
+
+
+@router.get("", response_model=list[OrderResponse])
+async def list_orders(
+ status: OrderStatus | None = None,
+ limit: int = Query(default=50, le=100),
+ offset: int = Query(default=0, ge=0),
+ service: OrderService = Depends(get_order_service),
+ _admin=Depends(get_current_admin),
+):
+ """List all orders (admin only)."""
+ orders = await service.list_orders(status=status, limit=limit, offset=offset)
+ return orders
+
+
+@router.get("/{order_id}", response_model=OrderResponse)
+async def get_order(
+ order_id: UUID,
+ service: OrderService = Depends(get_order_service),
+ _admin=Depends(get_current_admin),
+):
+ """Get order details (admin only)."""
+ order = await service.get_order_by_id(order_id)
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ return order
+
+
+@router.put("/{order_id}/status")
+async def update_order_status(
+ order_id: UUID,
+ status: OrderStatus,
+ service: OrderService = Depends(get_order_service),
+ _admin=Depends(get_current_admin),
+):
+ """Update order status (admin only)."""
+ order = await service.update_status(order_id, status)
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ return {"status": "updated", "order_id": order_id, "new_status": status}
diff --git a/backend/app/api/admin/products.py b/backend/app/api/admin/products.py
new file mode 100644
index 0000000..3ffd966
--- /dev/null
+++ b/backend/app/api/admin/products.py
@@ -0,0 +1,57 @@
+"""Admin product management endpoints."""
+
+from fastapi import APIRouter, HTTPException, Depends
+from pydantic import BaseModel
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.services.design_service import DesignService
+from app.services.auth_service import get_current_admin
+
+router = APIRouter()
+
+
+class ProductOverrideRequest(BaseModel):
+ is_active: bool | None = None
+ price_override: float | None = None
+
+
+def get_design_service() -> DesignService:
+ return DesignService()
+
+
+@router.put("/{slug}/override")
+async def update_product_override(
+ slug: str,
+ override: ProductOverrideRequest,
+ db: AsyncSession = Depends(get_db),
+ design_service: DesignService = Depends(get_design_service),
+ _admin=Depends(get_current_admin),
+):
+ """Update product visibility or price override (admin only)."""
+ # Verify product exists
+ product = await design_service.get_product(slug)
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+
+ # Update override in database
+ await design_service.set_product_override(
+ db=db,
+ slug=slug,
+ is_active=override.is_active,
+ price_override=override.price_override,
+ )
+
+ return {"status": "updated", "slug": slug}
+
+
+@router.post("/sync")
+async def sync_designs(
+ design_service: DesignService = Depends(get_design_service),
+ _admin=Depends(get_current_admin),
+):
+ """Force sync designs from the designs directory (admin only)."""
+ # Clear any caches and reload
+ design_service.clear_cache()
+ designs = await design_service.list_designs()
+ return {"status": "synced", "count": len(designs)}
diff --git a/backend/app/api/cart.py b/backend/app/api/cart.py
new file mode 100644
index 0000000..2b74b82
--- /dev/null
+++ b/backend/app/api/cart.py
@@ -0,0 +1,83 @@
+"""Cart API endpoints."""
+
+from uuid import UUID
+
+from fastapi import APIRouter, HTTPException, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.schemas.cart import (
+ CartCreate,
+ CartResponse,
+ CartItemCreate,
+ CartItemUpdate,
+ CartItemResponse,
+)
+from app.services.cart_service import CartService
+
+router = APIRouter()
+
+
+def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
+ return CartService(db)
+
+
+@router.post("", response_model=CartResponse)
+async def create_cart(
+ service: CartService = Depends(get_cart_service),
+):
+ """Create a new shopping cart."""
+ cart = await service.create_cart()
+ return cart
+
+
+@router.get("/{cart_id}", response_model=CartResponse)
+async def get_cart(
+ cart_id: UUID,
+ service: CartService = Depends(get_cart_service),
+):
+ """Get cart by ID."""
+ cart = await service.get_cart(cart_id)
+ if not cart:
+ raise HTTPException(status_code=404, detail="Cart not found")
+ return cart
+
+
+@router.post("/{cart_id}/items", response_model=CartResponse)
+async def add_item(
+ cart_id: UUID,
+ item: CartItemCreate,
+ service: CartService = Depends(get_cart_service),
+):
+ """Add item to cart."""
+ cart = await service.add_item(cart_id, item)
+ if not cart:
+ raise HTTPException(status_code=404, detail="Cart not found")
+ return cart
+
+
+@router.put("/{cart_id}/items/{item_id}", response_model=CartResponse)
+async def update_item(
+ cart_id: UUID,
+ item_id: UUID,
+ update: CartItemUpdate,
+ service: CartService = Depends(get_cart_service),
+):
+ """Update cart item quantity."""
+ cart = await service.update_item(cart_id, item_id, update.quantity)
+ if not cart:
+ raise HTTPException(status_code=404, detail="Cart or item not found")
+ return cart
+
+
+@router.delete("/{cart_id}/items/{item_id}", response_model=CartResponse)
+async def remove_item(
+ cart_id: UUID,
+ item_id: UUID,
+ service: CartService = Depends(get_cart_service),
+):
+ """Remove item from cart."""
+ cart = await service.remove_item(cart_id, item_id)
+ if not cart:
+ raise HTTPException(status_code=404, detail="Cart or item not found")
+ return cart
diff --git a/backend/app/api/checkout.py b/backend/app/api/checkout.py
new file mode 100644
index 0000000..1baecec
--- /dev/null
+++ b/backend/app/api/checkout.py
@@ -0,0 +1,53 @@
+"""Checkout API endpoints."""
+
+from fastapi import APIRouter, HTTPException, Depends, Request
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.schemas.order import CheckoutRequest, CheckoutResponse
+from app.services.mollie_service import MollieService
+from app.services.cart_service import CartService
+
+router = APIRouter()
+
+
+def get_mollie_service() -> MollieService:
+ return MollieService()
+
+
+def get_cart_service(db: AsyncSession = Depends(get_db)) -> CartService:
+ return CartService(db)
+
+
+@router.post("/session", response_model=CheckoutResponse)
+async def create_checkout_session(
+ checkout_request: CheckoutRequest,
+ request: Request,
+ mollie_service: MollieService = Depends(get_mollie_service),
+ cart_service: CartService = Depends(get_cart_service),
+):
+ """Create a Mollie payment session."""
+ # Get cart
+ cart = await cart_service.get_cart(checkout_request.cart_id)
+ if not cart:
+ raise HTTPException(status_code=404, detail="Cart not found")
+
+ if not cart.items:
+ raise HTTPException(status_code=400, detail="Cart is empty")
+
+ # Build webhook URL from request origin
+ base_url = str(request.base_url).rstrip("/")
+ webhook_url = f"{base_url}/api/webhooks/mollie"
+
+ # Create Mollie payment
+ result = await mollie_service.create_payment(
+ cart=cart,
+ success_url=checkout_request.success_url,
+ cancel_url=checkout_request.cancel_url,
+ webhook_url=webhook_url,
+ )
+
+ return CheckoutResponse(
+ checkout_url=result["url"],
+ session_id=result["payment_id"],
+ )
diff --git a/backend/app/api/design_generator.py b/backend/app/api/design_generator.py
new file mode 100644
index 0000000..e8304e4
--- /dev/null
+++ b/backend/app/api/design_generator.py
@@ -0,0 +1,240 @@
+"""AI design generation API."""
+
+import os
+import re
+import uuid
+from datetime import date
+from pathlib import Path
+
+import httpx
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from app.config import get_settings
+from app.api.designs import design_service
+
+router = APIRouter()
+settings = get_settings()
+
+
+class DesignRequest(BaseModel):
+ """Request to generate a new design."""
+ concept: str
+ name: str
+ tags: list[str] = []
+ product_type: str = "sticker"
+
+
+class DesignResponse(BaseModel):
+ """Response with generated design info."""
+ slug: str
+ name: str
+ image_url: str
+ status: str
+
+
+def slugify(text: str) -> str:
+ """Convert text to URL-friendly slug."""
+ text = text.lower().strip()
+ text = re.sub(r'[^\w\s-]', '', text)
+ text = re.sub(r'[\s_-]+', '-', text)
+ text = re.sub(r'^-+|-+$', '', text)
+ return text
+
+
+@router.post("/generate", response_model=DesignResponse)
+async def generate_design(request: DesignRequest):
+ """Generate a new design using AI."""
+
+ gemini_api_key = os.environ.get("GEMINI_API_KEY", "")
+ if not gemini_api_key:
+ raise HTTPException(
+ status_code=503,
+ detail="AI generation not configured. Set GEMINI_API_KEY."
+ )
+
+ # Create slug from name
+ slug = slugify(request.name)
+ if not slug:
+ slug = f"design-{uuid.uuid4().hex[:8]}"
+
+ # Check if design already exists
+ design_dir = settings.designs_dir / "stickers" / slug
+ if design_dir.exists():
+ raise HTTPException(
+ status_code=409,
+ detail=f"Design '{slug}' already exists"
+ )
+
+ # Build the image generation prompt
+ style_prompt = f"""A striking sticker design for "{request.name}".
+{request.concept}
+The design should have a clean, modern spatial-web aesthetic with interconnected
+nodes, network patterns, and a collaborative/commons feel.
+Colors: vibrant cyan, warm orange accents on dark background.
+High contrast, suitable for vinyl sticker printing.
+Square format, clean edges for die-cut sticker."""
+
+ # Call Gemini API for image generation
+ try:
+ async with httpx.AsyncClient(timeout=120.0) as client:
+ # Use gemini-3-pro-image-preview for image generation
+ response = await client.post(
+ f"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent?key={gemini_api_key}",
+ json={
+ "contents": [{
+ "parts": [{
+ "text": style_prompt
+ }]
+ }],
+ "generationConfig": {
+ "responseModalities": ["image", "text"]
+ }
+ },
+ headers={"Content-Type": "application/json"}
+ )
+
+ if response.status_code != 200:
+ error_detail = response.text[:500] if response.text else "Unknown error"
+ raise HTTPException(
+ status_code=502,
+ detail=f"AI generation failed ({response.status_code}): {error_detail}"
+ )
+
+ result = response.json()
+
+ # Extract image data from response
+ image_data = None
+ for candidate in result.get("candidates", []):
+ for part in candidate.get("content", {}).get("parts", []):
+ if "inlineData" in part:
+ image_data = part["inlineData"]["data"]
+ break
+ if image_data:
+ break
+
+ if not image_data:
+ # Log what we got for debugging
+ import json
+ raise HTTPException(
+ status_code=502,
+ detail=f"AI did not return an image. Response: {json.dumps(result)[:500]}"
+ )
+
+ except httpx.TimeoutException:
+ raise HTTPException(
+ status_code=504,
+ detail="AI generation timed out"
+ )
+ except Exception as e:
+ raise HTTPException(
+ status_code=502,
+ detail=f"AI generation error: {str(e)}"
+ )
+
+ # Create design directory
+ design_dir.mkdir(parents=True, exist_ok=True)
+
+ # Save image
+ import base64
+ image_path = design_dir / f"{slug}.png"
+ image_bytes = base64.b64decode(image_data)
+ image_path.write_bytes(image_bytes)
+
+ # Create metadata.yaml
+ # Escape quotes in user-provided strings to prevent YAML parsing errors
+ safe_name = request.name.replace('"', '\\"')
+ safe_concept = request.concept.replace('"', '\\"')
+ tags_str = ", ".join(request.tags) if request.tags else "rspace, sticker, ai-generated"
+ metadata_content = f"""name: "{safe_name}"
+slug: {slug}
+description: "{safe_concept}"
+tags: [{tags_str}]
+created: {date.today().isoformat()}
+author: ai-generated
+
+source:
+ file: {slug}.png
+ format: png
+ dimensions:
+ width: 1024
+ height: 1024
+ dpi: 300
+ color_profile: sRGB
+
+products:
+ - type: sticker
+ provider: prodigi
+ sku: GLOBAL-STI-KIS-3X3
+ variants: [matte, gloss]
+ retail_price: 3.50
+
+status: draft
+"""
+
+ metadata_path = design_dir / "metadata.yaml"
+ metadata_path.write_text(metadata_content)
+
+ return DesignResponse(
+ slug=slug,
+ name=request.name,
+ image_url=f"/api/designs/{slug}/image",
+ status="draft"
+ )
+
+
+def find_design_dir(slug: str) -> Path | None:
+ """Find a design directory by slug, searching all categories."""
+ for category_dir in settings.designs_dir.iterdir():
+ if not category_dir.is_dir():
+ continue
+ design_dir = category_dir / slug
+ if design_dir.exists() and (design_dir / "metadata.yaml").exists():
+ return design_dir
+ return None
+
+
+@router.post("/{slug}/activate")
+async def activate_design(slug: str):
+ """Activate a draft design to make it visible in the store."""
+
+ design_dir = find_design_dir(slug)
+ if not design_dir:
+ raise HTTPException(status_code=404, detail="Design not found")
+
+ metadata_path = design_dir / "metadata.yaml"
+
+ # Read and update metadata
+ content = metadata_path.read_text()
+ content = content.replace("status: draft", "status: active")
+ metadata_path.write_text(content)
+
+ # Clear the design service cache so the new status is picked up
+ design_service.clear_cache()
+
+ return {"status": "activated", "slug": slug}
+
+
+@router.delete("/{slug}")
+async def delete_design(slug: str):
+ """Delete a design (only drafts can be deleted)."""
+ import shutil
+
+ design_dir = find_design_dir(slug)
+ if not design_dir:
+ raise HTTPException(status_code=404, detail="Design not found")
+
+ metadata_path = design_dir / "metadata.yaml"
+
+ # Check if draft
+ content = metadata_path.read_text()
+ if "status: active" in content:
+ raise HTTPException(
+ status_code=400,
+ detail="Cannot delete active designs. Set to draft first."
+ )
+
+ # Delete directory
+ shutil.rmtree(design_dir)
+
+ return {"status": "deleted", "slug": slug}
diff --git a/backend/app/api/designs.py b/backend/app/api/designs.py
new file mode 100644
index 0000000..4c6f88d
--- /dev/null
+++ b/backend/app/api/designs.py
@@ -0,0 +1,275 @@
+"""Designs API endpoints."""
+
+import io
+import logging
+from pathlib import Path
+
+import httpx
+from fastapi import APIRouter, HTTPException
+from fastapi.responses import FileResponse, StreamingResponse
+from PIL import Image
+
+from app.config import get_settings
+from app.schemas.design import Design
+from app.services.design_service import DesignService
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+design_service = DesignService()
+settings = get_settings()
+
+# Mockup template configs: product_type → (template path, design bounding box, blend mode)
+# Coordinates are for 1024x1024 photorealistic templates
+MOCKUP_TEMPLATES = {
+ "shirt": {
+ "template": "shirt-template.png",
+ "design_box": (262, 230, 500, 450), # x, y, w, h — chest area on black tee
+ "blend": "screen", # screen blend for light designs on dark fabric
+ },
+ "sticker": {
+ "template": "sticker-template.png",
+ "design_box": (270, 210, 470, 530), # inside the white sticker area
+ "blend": "paste",
+ },
+ "print": {
+ "template": "print-template.png",
+ "design_box": (225, 225, 575, 500), # inside the black frame
+ "blend": "paste",
+ },
+}
+
+# Map mockup type → matching product types from metadata
+_TYPE_MAP = {
+ "shirt": ("shirt", "tshirt", "tee", "hoodie"),
+ "sticker": ("sticker",),
+ "print": ("print",),
+}
+
+# Cache generated mockups in memory: (slug, product_type) → PNG bytes
+_mockup_cache: dict[tuple[str, str], bytes] = {}
+
+
+@router.get("", response_model=list[Design])
+async def list_designs(
+ status: str = "active",
+ category: str | None = None,
+ space: str | None = None,
+):
+ """List all designs."""
+ designs = await design_service.list_designs(status=status, category=category, space=space)
+ return designs
+
+
+@router.get("/{slug}", response_model=Design)
+async def get_design(slug: str):
+ """Get a single design by slug."""
+ design = await design_service.get_design(slug)
+ if not design:
+ raise HTTPException(status_code=404, detail="Design not found")
+ return design
+
+
+@router.get("/{slug}/image")
+async def get_design_image(slug: str):
+ """Serve the design image."""
+ image_path = await design_service.get_design_image_path(slug)
+ if not image_path or not Path(image_path).exists():
+ raise HTTPException(status_code=404, detail="Image not found")
+
+ return FileResponse(
+ image_path,
+ media_type="image/png",
+ headers={
+ "Cache-Control": "public, max-age=86400",
+ },
+ )
+
+
+@router.get("/{slug}/mockup")
+async def get_design_mockup(slug: str, type: str = "shirt", fresh: bool = False):
+ """Serve the design composited onto a product mockup template.
+
+ For Printful-provider designs: fetches photorealistic mockup from
+ Printful's mockup generator API (cached after first generation).
+ For other designs: composites with Pillow using local templates.
+
+ Query params:
+ type: Product type — "shirt", "sticker", or "print" (default: shirt)
+ fresh: If true, bypass cache and regenerate mockup
+ """
+ cache_key = (slug, type)
+ if not fresh and cache_key in _mockup_cache:
+ return StreamingResponse(
+ io.BytesIO(_mockup_cache[cache_key]),
+ media_type="image/png",
+ headers={"Cache-Control": "public, max-age=86400"},
+ )
+
+ # Load design to check provider
+ design = await design_service.get_design(slug)
+ if not design:
+ raise HTTPException(status_code=404, detail="Design not found")
+
+ # Find a Printful-provider product matching the requested mockup type
+ printful_product = None
+ accepted_types = _TYPE_MAP.get(type, (type,))
+ for p in design.products:
+ if p.provider == "printful" and p.type in accepted_types:
+ printful_product = p
+ break
+
+ # Try Printful mockup API for Printful-provider designs
+ if printful_product and settings.printful_api_token:
+ png_bytes = await _get_printful_mockup(slug, printful_product)
+ if png_bytes:
+ _mockup_cache[cache_key] = png_bytes
+ return StreamingResponse(
+ io.BytesIO(png_bytes),
+ media_type="image/png",
+ headers={"Cache-Control": "public, max-age=86400"},
+ )
+
+ # Fallback: Pillow compositing with local templates
+ return await _pillow_mockup(slug, type)
+
+
+async def _get_printful_mockup(slug: str, product) -> bytes | None:
+ """Fetch mockup from Printful API. Returns PNG bytes or None."""
+ from app.pod.printful_client import PrintfulClient
+
+ printful = PrintfulClient()
+ if not printful.enabled:
+ return None
+
+ try:
+ product_id = int(product.sku)
+
+ # Get first variant for mockup preview
+ variants = await printful.get_catalog_variants(product_id)
+ if not variants:
+ logger.warning(f"No Printful variants for product {product_id}")
+ return None
+ variant_ids = [variants[0]["id"]]
+
+ # Public image URL for Printful to download
+ image_url = f"{settings.public_url}/api/designs/{slug}/image"
+
+ # Generate mockup (blocks up to ~60s on first call)
+ mockups = await printful.generate_mockup_and_wait(
+ product_id=product_id,
+ variant_ids=variant_ids,
+ image_url=image_url,
+ placement="front",
+ technique="dtg",
+ )
+
+ if not mockups:
+ return None
+
+ # v2 response: catalog_variant_mockups[] → .mockups[] → .mockup_url
+ mockup_url = None
+ for variant_mockup in mockups:
+ for mockup in variant_mockup.get("mockups", []):
+ mockup_url = mockup.get("mockup_url") or mockup.get("url")
+ if mockup_url:
+ break
+ if mockup_url:
+ break
+
+ if not mockup_url:
+ logger.warning(f"No mockup URL in Printful response for {slug}")
+ return None
+
+ # Download the mockup image
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.get(mockup_url)
+ resp.raise_for_status()
+ return resp.content
+
+ except Exception as e:
+ logger.warning(f"Printful mockup failed for {slug}: {e}")
+ return None
+
+
+async def _pillow_mockup(slug: str, type: str) -> StreamingResponse:
+ """Generate photorealistic mockup using Pillow compositing."""
+ from PIL import ImageChops
+
+ template_config = MOCKUP_TEMPLATES.get(type)
+ if not template_config:
+ raise HTTPException(status_code=400, detail=f"Unknown product type: {type}")
+
+ image_path = await design_service.get_design_image_path(slug)
+ if not image_path or not Path(image_path).exists():
+ raise HTTPException(status_code=404, detail="Design image not found")
+
+ # Load template from frontend/public/mockups/ or /app/mockups/ (Docker mount)
+ template_dir = Path(__file__).resolve().parents[3] / "frontend" / "public" / "mockups"
+ template_path = template_dir / template_config["template"]
+ if not template_path.exists():
+ template_path = Path("/app/mockups") / template_config["template"]
+ if not template_path.exists():
+ raise HTTPException(status_code=404, detail="Mockup template not found")
+
+ # Load images
+ template_img = Image.open(str(template_path)).convert("RGB")
+ design_img = Image.open(image_path).convert("RGBA")
+
+ # Start with the photorealistic template as the base
+ canvas = template_img.copy()
+
+ # Scale design to fit bounding box while maintaining aspect ratio
+ bx, by, bw, bh = template_config["design_box"]
+ scale = min(bw / design_img.width, bh / design_img.height)
+ dw = int(design_img.width * scale)
+ dh = int(design_img.height * scale)
+ dx = bx + (bw - dw) // 2
+ dy = by + (bh - dh) // 2
+
+ design_resized = design_img.resize((dw, dh), Image.LANCZOS)
+
+ blend_mode = template_config.get("blend", "paste")
+
+ if blend_mode == "screen":
+ # Screen blend for light designs on dark fabric.
+ # We use a brightness-based mask so only non-dark pixels from
+ # the design show through, preventing a visible dark rectangle
+ # when the design has its own dark background.
+ design_rgb = design_resized.convert("RGB")
+
+ # Extract the region under the design
+ region = canvas.crop((dx, dy, dx + dw, dy + dh))
+
+ # Screen blend the design onto the fabric region
+ blended = ImageChops.screen(region, design_rgb)
+
+ # Create a luminance mask from the design — only bright pixels blend in.
+ # This prevents the design's dark background from creating a visible box.
+ lum = design_rgb.convert("L")
+ # Boost contrast so only clearly visible parts of the design show
+ lum = lum.point(lambda p: min(255, int(p * 1.5)))
+
+ # Composite: use luminance as mask (bright pixels = show blended, dark = keep original)
+ result = Image.composite(blended, region, lum)
+ canvas.paste(result, (dx, dy))
+ else:
+ # Direct paste — for stickers/prints where design goes on a light surface
+ if design_resized.mode == "RGBA":
+ canvas.paste(design_resized, (dx, dy), design_resized)
+ else:
+ canvas.paste(design_resized, (dx, dy))
+
+ # Export to high-quality PNG
+ buf = io.BytesIO()
+ canvas.save(buf, format="PNG", optimize=True)
+ png_bytes = buf.getvalue()
+
+ # Cache the result
+ cache_key = (slug, type)
+ _mockup_cache[cache_key] = png_bytes
+
+ return StreamingResponse(
+ io.BytesIO(png_bytes),
+ media_type="image/png",
+ headers={"Cache-Control": "public, max-age=86400"},
+ )
diff --git a/backend/app/api/health.py b/backend/app/api/health.py
new file mode 100644
index 0000000..9630460
--- /dev/null
+++ b/backend/app/api/health.py
@@ -0,0 +1,21 @@
+"""Health check endpoint."""
+
+from fastapi import APIRouter
+
+from app.config import get_settings
+from app.services.flow_service import FlowService
+
+router = APIRouter()
+settings = get_settings()
+
+
+@router.get("/health")
+async def health_check():
+ """Health check endpoint."""
+ flow_service = FlowService()
+ return {
+ "status": "healthy",
+ "payment_provider": "mollie",
+ "flow_enabled": flow_service.enabled,
+ "flow_revenue_split": settings.flow_revenue_split if flow_service.enabled else None,
+ }
diff --git a/backend/app/api/orders.py b/backend/app/api/orders.py
new file mode 100644
index 0000000..8ebf3dc
--- /dev/null
+++ b/backend/app/api/orders.py
@@ -0,0 +1,53 @@
+"""Orders API endpoints."""
+
+from uuid import UUID
+
+from fastapi import APIRouter, HTTPException, Depends, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.schemas.order import OrderResponse
+from app.services.order_service import OrderService
+
+router = APIRouter()
+
+
+def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
+ return OrderService(db)
+
+
+@router.get("/{order_id}", response_model=OrderResponse)
+async def get_order(
+ order_id: UUID,
+ email: str = Query(..., description="Email used for the order"),
+ service: OrderService = Depends(get_order_service),
+):
+ """Get order by ID (requires email verification)."""
+ order = await service.get_order_by_id_and_email(order_id, email)
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+ return order
+
+
+@router.get("/{order_id}/tracking")
+async def get_order_tracking(
+ order_id: UUID,
+ email: str = Query(..., description="Email used for the order"),
+ service: OrderService = Depends(get_order_service),
+):
+ """Get tracking information for an order."""
+ order = await service.get_order_by_id_and_email(order_id, email)
+ if not order:
+ raise HTTPException(status_code=404, detail="Order not found")
+
+ tracking = []
+ for item in order.items:
+ if item.pod_tracking_number:
+ tracking.append({
+ "product": item.product_name,
+ "tracking_number": item.pod_tracking_number,
+ "tracking_url": item.pod_tracking_url,
+ "status": item.pod_status,
+ })
+
+ return {"order_id": order_id, "tracking": tracking}
diff --git a/backend/app/api/products.py b/backend/app/api/products.py
new file mode 100644
index 0000000..b29eb36
--- /dev/null
+++ b/backend/app/api/products.py
@@ -0,0 +1,33 @@
+"""Products API endpoints."""
+
+from fastapi import APIRouter, HTTPException
+
+from app.schemas.product import Product
+from app.services.design_service import DesignService
+
+router = APIRouter()
+design_service = DesignService()
+
+
+@router.get("", response_model=list[Product])
+async def list_products(
+ category: str | None = None,
+ product_type: str | None = None,
+ space: str | None = None,
+):
+ """List all products (designs with variants flattened for storefront)."""
+ products = await design_service.list_products(
+ category=category,
+ product_type=product_type,
+ space=space,
+ )
+ return products
+
+
+@router.get("/{slug}", response_model=Product)
+async def get_product(slug: str):
+ """Get a single product by slug."""
+ product = await design_service.get_product(slug)
+ if not product:
+ raise HTTPException(status_code=404, detail="Product not found")
+ return product
diff --git a/backend/app/api/spaces.py b/backend/app/api/spaces.py
new file mode 100644
index 0000000..382fa03
--- /dev/null
+++ b/backend/app/api/spaces.py
@@ -0,0 +1,23 @@
+"""Spaces API endpoints."""
+
+from fastapi import APIRouter, HTTPException
+
+from app.services.space_service import SpaceService, Space
+
+router = APIRouter()
+space_service = SpaceService()
+
+
+@router.get("", response_model=list[Space])
+async def list_spaces():
+ """List all available spaces."""
+ return space_service.list_spaces()
+
+
+@router.get("/{space_id}", response_model=Space)
+async def get_space(space_id: str):
+ """Get a specific space by ID."""
+ space = space_service.get_space(space_id)
+ if not space:
+ raise HTTPException(status_code=404, detail="Space not found")
+ return space
diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py
new file mode 100644
index 0000000..b233eb7
--- /dev/null
+++ b/backend/app/api/upload.py
@@ -0,0 +1,145 @@
+"""Design upload API — users upload their own artwork."""
+
+import io
+import re
+import uuid
+from datetime import date
+from pathlib import Path
+
+from fastapi import APIRouter, Form, HTTPException, UploadFile
+from PIL import Image
+from pydantic import BaseModel
+
+from app.config import get_settings
+from app.api.designs import design_service
+
+router = APIRouter()
+settings = get_settings()
+
+ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
+MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
+MIN_DIMENSION = 500
+
+
+class UploadResponse(BaseModel):
+ slug: str
+ name: str
+ image_url: str
+ status: str
+ products: list[dict]
+
+
+def slugify(text: str) -> str:
+ text = text.lower().strip()
+ text = re.sub(r'[^\w\s-]', '', text)
+ text = re.sub(r'[\s_-]+', '-', text)
+ text = re.sub(r'^-+|-+$', '', text)
+ return text
+
+
+@router.post("/upload", response_model=UploadResponse)
+async def upload_design(
+ file: UploadFile,
+ name: str = Form(...),
+ space: str = Form("default"),
+ tags: str = Form(""),
+):
+ """Upload a custom design image."""
+
+ # Validate content type
+ if file.content_type not in ALLOWED_TYPES:
+ raise HTTPException(400, "Only PNG, JPEG, and WebP files are accepted")
+
+ # Read file and check size
+ contents = await file.read()
+ if len(contents) > MAX_FILE_SIZE:
+ raise HTTPException(400, "File size must be under 10 MB")
+
+ # Open with Pillow and validate dimensions
+ try:
+ img = Image.open(io.BytesIO(contents))
+ except Exception:
+ raise HTTPException(400, "Could not read image file")
+
+ if img.width < MIN_DIMENSION or img.height < MIN_DIMENSION:
+ raise HTTPException(400, f"Image must be at least {MIN_DIMENSION}x{MIN_DIMENSION} pixels")
+
+ # Create slug
+ slug = slugify(name)
+ if not slug:
+ slug = f"upload-{uuid.uuid4().hex[:8]}"
+
+ # Check for existing design
+ design_dir = settings.designs_dir / "uploads" / slug
+ if design_dir.exists():
+ slug = f"{slug}-{uuid.uuid4().hex[:6]}"
+ design_dir = settings.designs_dir / "uploads" / slug
+
+ # Save image as PNG
+ design_dir.mkdir(parents=True, exist_ok=True)
+ img = img.convert("RGBA")
+ image_path = design_dir / f"{slug}.png"
+ img.save(str(image_path), "PNG")
+
+ # Build metadata
+ safe_name = name.replace('"', '\\"')
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else ["custom", "upload"]
+ tags_str = ", ".join(tag_list)
+ space_field = space if space != "default" else "all"
+
+ metadata_content = f"""name: "{safe_name}"
+slug: {slug}
+description: "Custom uploaded design"
+tags: [{tags_str}]
+space: {space_field}
+category: uploads
+created: "{date.today().isoformat()}"
+author: user-upload
+
+source:
+ file: {slug}.png
+ format: png
+ dimensions:
+ width: {img.width}
+ height: {img.height}
+ dpi: 300
+ color_profile: sRGB
+
+products:
+ - type: sticker
+ provider: prodigi
+ sku: GLOBAL-STI-KIS-3X3
+ variants: [matte, gloss]
+ retail_price: 3.50
+ - type: shirt
+ provider: printful
+ sku: "71"
+ variants: [S, M, L, XL, 2XL]
+ retail_price: 29.99
+ - type: print
+ provider: prodigi
+ sku: GLOBAL-FAP-A4
+ variants: [matte, lustre]
+ retail_price: 12.99
+
+status: draft
+"""
+ metadata_path = design_dir / "metadata.yaml"
+ metadata_path.write_text(metadata_content)
+
+ # Clear design cache so the new upload is discoverable
+ design_service.clear_cache()
+
+ products = [
+ {"type": "sticker", "price": 3.50},
+ {"type": "shirt", "price": 29.99},
+ {"type": "print", "price": 12.99},
+ ]
+
+ return UploadResponse(
+ slug=slug,
+ name=name,
+ image_url=f"/api/designs/{slug}/image",
+ status="draft",
+ products=products,
+ )
diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py
new file mode 100644
index 0000000..8006740
--- /dev/null
+++ b/backend/app/api/webhooks.py
@@ -0,0 +1,95 @@
+"""Webhook endpoints for Mollie and POD providers."""
+
+from fastapi import APIRouter, Request, HTTPException, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.database import get_db
+from app.services.mollie_service import MollieService
+from app.services.order_service import OrderService
+
+router = APIRouter()
+
+
+def get_mollie_service() -> MollieService:
+ return MollieService()
+
+
+def get_order_service(db: AsyncSession = Depends(get_db)) -> OrderService:
+ return OrderService(db)
+
+
+@router.post("/mollie")
+async def mollie_webhook(
+ request: Request,
+ mollie_service: MollieService = Depends(get_mollie_service),
+ order_service: OrderService = Depends(get_order_service),
+):
+ """Handle Mollie webhook events.
+
+ Mollie sends a POST with form data containing just the payment ID.
+ We then fetch the full payment details from Mollie's API to verify status.
+ """
+ form = await request.form()
+ payment_id = form.get("id")
+
+ if not payment_id:
+ raise HTTPException(status_code=400, detail="Missing payment id")
+
+ # Fetch payment from Mollie API (this IS the verification — no signature needed)
+ payment = await mollie_service.get_payment(payment_id)
+
+ status = payment.get("status")
+ if status == "paid":
+ await order_service.handle_successful_payment(payment)
+ elif status in ("failed", "canceled", "expired"):
+ # Log but no action needed
+ print(f"Mollie payment {payment_id} status: {status}")
+
+ return {"status": "ok"}
+
+
+@router.post("/prodigi")
+async def prodigi_webhook(
+ request: Request,
+ order_service: OrderService = Depends(get_order_service),
+):
+ """Handle Prodigi webhook events."""
+ payload = await request.json()
+
+ event_type = payload.get("event")
+ order_data = payload.get("order", {})
+
+ if event_type in ["order.shipped", "order.complete"]:
+ await order_service.update_pod_status(
+ pod_provider="prodigi",
+ pod_order_id=order_data.get("id"),
+ status=event_type.replace("order.", ""),
+ tracking_number=order_data.get("shipments", [{}])[0].get("trackingNumber"),
+ tracking_url=order_data.get("shipments", [{}])[0].get("trackingUrl"),
+ )
+
+ return {"status": "ok"}
+
+
+@router.post("/printful")
+async def printful_webhook(
+ request: Request,
+ order_service: OrderService = Depends(get_order_service),
+):
+ """Handle Printful webhook events."""
+ payload = await request.json()
+
+ event_type = payload.get("type")
+ order_data = payload.get("data", {}).get("order", {})
+
+ if event_type in ["package_shipped", "order_fulfilled"]:
+ shipment = payload.get("data", {}).get("shipment", {})
+ await order_service.update_pod_status(
+ pod_provider="printful",
+ pod_order_id=str(order_data.get("id")),
+ status="shipped" if event_type == "package_shipped" else "fulfilled",
+ tracking_number=shipment.get("tracking_number"),
+ tracking_url=shipment.get("tracking_url"),
+ )
+
+ return {"status": "ok"}
diff --git a/backend/app/config.py b/backend/app/config.py
new file mode 100644
index 0000000..8c9498a
--- /dev/null
+++ b/backend/app/config.py
@@ -0,0 +1,86 @@
+"""Application configuration."""
+
+from functools import lru_cache
+from pathlib import Path
+
+from pydantic import AliasChoices, Field
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ """Application settings loaded from environment variables."""
+
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ extra="ignore",
+ )
+
+ # Database
+ database_url: str = "postgresql://swag:devpassword@localhost:5432/swag"
+
+ # Redis
+ redis_url: str = "redis://localhost:6379"
+
+ # Mollie
+ mollie_api_key: str = ""
+
+ # POD Providers
+ prodigi_api_key: str = ""
+ printful_api_token: str = ""
+ printful_store_id: str = ""
+ pod_sandbox_mode: bool = True
+
+ # Flow Service (TBFF revenue split → bonding curve)
+ flow_service_url: str = ""
+ flow_id: str = ""
+ flow_funnel_id: str = ""
+ flow_revenue_split: float = 0.5 # fraction of margin routed to flow (0.0-1.0)
+
+ # Auth
+ jwt_secret: str = "dev-secret-change-in-production"
+ jwt_algorithm: str = "HS256"
+ jwt_expire_hours: int = 24
+
+ # Email (SMTP via Mailcow)
+ smtp_host: str = "mail.rmail.online"
+ smtp_port: int = 587
+ smtp_user: str = ""
+ smtp_password: str = Field(default="", validation_alias=AliasChoices("smtp_password", "SMTP_PASSWORD", "SMTP_PASS"))
+ smtp_from_email: str = "noreply@rswag.online"
+ smtp_from_name: str = "rSwag"
+
+ # CORS
+ cors_origins: str = "http://localhost:3000"
+
+ # Paths
+ designs_path: str = "/app/designs"
+ config_path: str = "/app/config"
+ spaces_path: str = "/app/spaces"
+
+ # App
+ app_name: str = "rSwag"
+ public_url: str = "https://rswag.online"
+ debug: bool = False
+
+ @property
+ def designs_dir(self) -> Path:
+ return Path(self.designs_path)
+
+ @property
+ def config_dir(self) -> Path:
+ return Path(self.config_path)
+
+ @property
+ def spaces_dir(self) -> Path:
+ return Path(self.spaces_path)
+
+ @property
+ def cors_origins_list(self) -> list[str]:
+ return [origin.strip() for origin in self.cors_origins.split(",")]
+
+
+@lru_cache
+def get_settings() -> Settings:
+ """Get cached settings instance."""
+ return Settings()
diff --git a/backend/app/database.py b/backend/app/database.py
new file mode 100644
index 0000000..ede7747
--- /dev/null
+++ b/backend/app/database.py
@@ -0,0 +1,46 @@
+"""Database configuration and session management."""
+
+from collections.abc import AsyncGenerator
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.orm import DeclarativeBase
+
+from app.config import get_settings
+
+settings = get_settings()
+
+# Convert postgresql:// to postgresql+asyncpg:// for async
+database_url = settings.database_url
+if database_url.startswith("postgresql://"):
+ database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
+
+engine = create_async_engine(
+ database_url,
+ echo=settings.debug,
+ pool_pre_ping=True,
+)
+
+AsyncSessionLocal = async_sessionmaker(
+ engine,
+ class_=AsyncSession,
+ expire_on_commit=False,
+)
+
+
+class Base(DeclarativeBase):
+ """Base class for SQLAlchemy models."""
+
+ pass
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+ """Dependency for getting database session."""
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ await session.commit()
+ except Exception:
+ await session.rollback()
+ raise
+ finally:
+ await session.close()
diff --git a/backend/app/main.py b/backend/app/main.py
new file mode 100644
index 0000000..96bb77e
--- /dev/null
+++ b/backend/app/main.py
@@ -0,0 +1,65 @@
+"""FastAPI application entry point."""
+
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from app.config import get_settings
+from app.api import designs, products, cart, checkout, orders, webhooks, health, design_generator, upload, spaces
+from app.api.admin import router as admin_router
+
+settings = get_settings()
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """Application lifespan handler."""
+ # Startup
+ print(f"Starting {settings.app_name}...")
+ print(f"Designs path: {settings.designs_path}")
+ print(f"POD sandbox mode: {settings.pod_sandbox_mode}")
+ yield
+ # Shutdown
+ print("Shutting down...")
+
+
+app = FastAPI(
+ title=settings.app_name,
+ description="E-commerce API for rSpace ecosystem merchandise",
+ version="0.1.0",
+ lifespan=lifespan,
+)
+
+# CORS middleware - allow all rswag.online subdomains + configured origins
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=settings.cors_origins_list,
+ allow_origin_regex=r"https?://(([\w-]+\.)?rswag\.online|fungiswag\.jeffemmett\.com)",
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+# Include routers
+app.include_router(health.router, prefix="/api", tags=["health"])
+app.include_router(designs.router, prefix="/api/designs", tags=["designs"])
+app.include_router(products.router, prefix="/api/products", tags=["products"])
+app.include_router(cart.router, prefix="/api/cart", tags=["cart"])
+app.include_router(checkout.router, prefix="/api/checkout", tags=["checkout"])
+app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
+app.include_router(webhooks.router, prefix="/api/webhooks", tags=["webhooks"])
+app.include_router(design_generator.router, prefix="/api/design", tags=["design-generator"])
+app.include_router(upload.router, prefix="/api/design", tags=["upload"])
+app.include_router(spaces.router, prefix="/api/spaces", tags=["spaces"])
+app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
+
+
+@app.get("/")
+async def root():
+ """Root endpoint."""
+ return {
+ "name": settings.app_name,
+ "version": "0.1.0",
+ "docs": "/docs",
+ }
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
new file mode 100644
index 0000000..e5f7844
--- /dev/null
+++ b/backend/app/models/__init__.py
@@ -0,0 +1,17 @@
+"""SQLAlchemy ORM models."""
+
+from app.models.customer import Customer
+from app.models.cart import Cart, CartItem
+from app.models.order import Order, OrderItem
+from app.models.admin import AdminUser
+from app.models.product import ProductOverride
+
+__all__ = [
+ "Customer",
+ "Cart",
+ "CartItem",
+ "Order",
+ "OrderItem",
+ "AdminUser",
+ "ProductOverride",
+]
diff --git a/backend/app/models/admin.py b/backend/app/models/admin.py
new file mode 100644
index 0000000..cf856cc
--- /dev/null
+++ b/backend/app/models/admin.py
@@ -0,0 +1,24 @@
+"""Admin user model."""
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+
+
+class AdminUser(Base):
+ """Admin user model for authentication."""
+
+ __tablename__ = "admin_users"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
+ password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
diff --git a/backend/app/models/cart.py b/backend/app/models/cart.py
new file mode 100644
index 0000000..6436308
--- /dev/null
+++ b/backend/app/models/cart.py
@@ -0,0 +1,60 @@
+"""Cart models."""
+
+import uuid
+from datetime import datetime, timedelta
+
+from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+
+
+def default_expiry():
+ return datetime.utcnow() + timedelta(days=7)
+
+
+class Cart(Base):
+ """Shopping cart model."""
+
+ __tablename__ = "carts"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ customer_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
+ )
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
+ )
+ expires_at: Mapped[datetime] = mapped_column(DateTime, default=default_expiry)
+
+ # Relationships
+ customer: Mapped["Customer | None"] = relationship("Customer", back_populates="carts")
+ items: Mapped[list["CartItem"]] = relationship(
+ "CartItem", back_populates="cart", cascade="all, delete-orphan"
+ )
+
+
+class CartItem(Base):
+ """Cart item model."""
+
+ __tablename__ = "cart_items"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ cart_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("carts.id", ondelete="CASCADE"), nullable=False
+ )
+ product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
+ product_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+ unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ # Relationships
+ cart: Mapped["Cart"] = relationship("Cart", back_populates="items")
diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py
new file mode 100644
index 0000000..10a1578
--- /dev/null
+++ b/backend/app/models/customer.py
@@ -0,0 +1,27 @@
+"""Customer model."""
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import String, DateTime
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+
+
+class Customer(Base):
+ """Customer model for storing customer information."""
+
+ __tablename__ = "customers"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
+ external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ # Relationships
+ carts: Mapped[list["Cart"]] = relationship("Cart", back_populates="customer")
+ orders: Mapped[list["Order"]] = relationship("Order", back_populates="customer")
diff --git a/backend/app/models/order.py b/backend/app/models/order.py
new file mode 100644
index 0000000..c8cbcf3
--- /dev/null
+++ b/backend/app/models/order.py
@@ -0,0 +1,106 @@
+"""Order models."""
+
+import uuid
+from datetime import datetime
+from enum import Enum
+
+from sqlalchemy import String, Integer, Numeric, DateTime, ForeignKey, Text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.database import Base
+
+
+class OrderStatus(str, Enum):
+ """Order status enum."""
+
+ PENDING = "pending"
+ PAID = "paid"
+ PROCESSING = "processing"
+ PRINTING = "printing"
+ SHIPPED = "shipped"
+ DELIVERED = "delivered"
+ CANCELLED = "cancelled"
+ REFUNDED = "refunded"
+
+
+class Order(Base):
+ """Order model."""
+
+ __tablename__ = "orders"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ customer_id: Mapped[uuid.UUID | None] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("customers.id"), nullable=True
+ )
+
+ # Payment provider info (provider-agnostic)
+ payment_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ payment_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ payment_method: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+ status: Mapped[str] = mapped_column(String(50), default=OrderStatus.PENDING.value)
+
+ # Shipping info
+ shipping_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ shipping_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ shipping_address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ shipping_address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ shipping_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ shipping_postal_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ shipping_country: Mapped[str | None] = mapped_column(String(2), nullable=True)
+
+ # Financials
+ subtotal: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
+ shipping_cost: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
+ tax: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
+ total: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
+ currency: Mapped[str] = mapped_column(String(3), default="USD")
+
+ # Timestamps
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
+ )
+ paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ shipped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+ delivered_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+ # Relationships
+ customer: Mapped["Customer | None"] = relationship("Customer", back_populates="orders")
+ items: Mapped[list["OrderItem"]] = relationship(
+ "OrderItem", back_populates="order", cascade="all, delete-orphan"
+ )
+
+
+class OrderItem(Base):
+ """Order item model."""
+
+ __tablename__ = "order_items"
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
+ )
+ order_id: Mapped[uuid.UUID] = mapped_column(
+ UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False
+ )
+ product_slug: Mapped[str] = mapped_column(String(100), nullable=False)
+ product_name: Mapped[str] = mapped_column(String(255), nullable=False)
+ variant: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ quantity: Mapped[int] = mapped_column(Integer, nullable=False)
+ unit_price: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False)
+
+ # POD fulfillment
+ pod_provider: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ pod_order_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ pod_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
+ pod_tracking_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ pod_tracking_url: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+
+ # Relationships
+ order: Mapped["Order"] = relationship("Order", back_populates="items")
diff --git a/backend/app/models/product.py b/backend/app/models/product.py
new file mode 100644
index 0000000..c1f4c6f
--- /dev/null
+++ b/backend/app/models/product.py
@@ -0,0 +1,21 @@
+"""Product override model."""
+
+from datetime import datetime
+
+from sqlalchemy import String, Boolean, Numeric, DateTime
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+
+
+class ProductOverride(Base):
+ """Product override model for visibility and price overrides."""
+
+ __tablename__ = "product_overrides"
+
+ slug: Mapped[str] = mapped_column(String(100), primary_key=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ price_override: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
+ )
diff --git a/backend/app/pod/__init__.py b/backend/app/pod/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/pod/printful_client.py b/backend/app/pod/printful_client.py
new file mode 100644
index 0000000..e9031b2
--- /dev/null
+++ b/backend/app/pod/printful_client.py
@@ -0,0 +1,273 @@
+"""Printful Print-on-Demand API client (v2).
+
+Handles catalog lookup, mockup generation, and order submission.
+API v2 docs: https://developers.printful.com/docs/v2-beta/
+Rate limit: 120 req/60s (leaky bucket), lower for mockups.
+"""
+
+import asyncio
+import logging
+import time
+
+import httpx
+
+from app.config import get_settings
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+BASE_URL = "https://api.printful.com/v2"
+
+# In-memory cache for catalog variants: {product_id: {"variants": [...], "ts": float}}
+_variant_cache: dict[int, dict] = {}
+_VARIANT_CACHE_TTL = 86400 # 24 hours
+
+
+class PrintfulClient:
+ """Client for the Printful v2 API."""
+
+ def __init__(self):
+ self.api_token = settings.printful_api_token
+ self.sandbox = settings.pod_sandbox_mode
+ self.enabled = bool(self.api_token)
+
+ @property
+ def _headers(self) -> dict[str, str]:
+ headers = {
+ "Authorization": f"Bearer {self.api_token}",
+ "Content-Type": "application/json",
+ }
+ if settings.printful_store_id:
+ headers["X-PF-Store-Id"] = settings.printful_store_id
+ return headers
+
+ # ── Catalog ──
+
+ async def get_catalog_variants(self, product_id: int) -> list[dict]:
+ """Get variants for a catalog product (cached 24h).
+
+ Each variant has: id (int), size (str), color (str), color_code (str).
+ """
+ cached = _variant_cache.get(product_id)
+ if cached and (time.time() - cached["ts"]) < _VARIANT_CACHE_TTL:
+ return cached["variants"]
+
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(
+ f"{BASE_URL}/catalog-products/{product_id}/catalog-variants",
+ headers=self._headers,
+ )
+ resp.raise_for_status()
+ variants = resp.json().get("data", [])
+
+ _variant_cache[product_id] = {"variants": variants, "ts": time.time()}
+ return variants
+
+ async def resolve_variant_id(
+ self,
+ product_id: int,
+ size: str,
+ color: str = "Black",
+ ) -> int | None:
+ """Resolve (product_id, size, color) → Printful catalog_variant_id.
+
+ Our metadata uses SKU "71" + variants ["S","M","L",...].
+ Printful orders require numeric catalog_variant_id.
+ """
+ variants = await self.get_catalog_variants(product_id)
+
+ # Try exact match on size + color
+ for v in variants:
+ if (
+ v.get("size", "").upper() == size.upper()
+ and color.lower() in v.get("color", "").lower()
+ ):
+ return v.get("id")
+
+ # Fallback: match size only
+ for v in variants:
+ if v.get("size", "").upper() == size.upper():
+ return v.get("id")
+
+ return None
+
+ # ── Mockup Generation ──
+
+ async def create_mockup_task(
+ self,
+ product_id: int,
+ variant_ids: list[int],
+ image_url: str,
+ placement: str = "front",
+ technique: str = "dtg",
+ ) -> str:
+ """Start async mockup generation task (v2 format).
+
+ Returns task_id to poll with get_mockup_task().
+
+ v2 payload uses products array with catalog source, and layers
+ inside placements instead of flat image_url.
+ """
+ payload = {
+ "products": [
+ {
+ "source": "catalog",
+ "catalog_product_id": product_id,
+ "catalog_variant_ids": variant_ids,
+ "placements": [
+ {
+ "placement": placement,
+ "technique": technique,
+ "layers": [
+ {
+ "type": "file",
+ "url": image_url,
+ }
+ ],
+ }
+ ],
+ }
+ ],
+ "format": "png",
+ }
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{BASE_URL}/mockup-tasks",
+ headers=self._headers,
+ json=payload,
+ )
+ resp.raise_for_status()
+ # v2 returns {"data": [{ ... }]} — data is a list
+ raw_data = resp.json().get("data", [])
+ data = raw_data[0] if isinstance(raw_data, list) and raw_data else raw_data
+ task_id = data.get("id") or data.get("task_key") or data.get("task_id")
+ logger.info(f"Printful mockup task created: {task_id}")
+ return str(task_id)
+
+ async def get_mockup_task(self, task_id: str) -> dict:
+ """Poll mockup task status (v2 format).
+
+ Returns dict with "status" (pending/completed/failed) and
+ "catalog_variant_mockups" list when completed.
+ """
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(
+ f"{BASE_URL}/mockup-tasks",
+ headers=self._headers,
+ params={"id": task_id},
+ )
+ resp.raise_for_status()
+ # v2 returns {"data": [{ ... }]} — data is a list
+ raw_data = resp.json().get("data", [])
+ if isinstance(raw_data, list) and raw_data:
+ return raw_data[0]
+ return raw_data if isinstance(raw_data, dict) else {}
+
+ async def generate_mockup_and_wait(
+ self,
+ product_id: int,
+ variant_ids: list[int],
+ image_url: str,
+ placement: str = "front",
+ technique: str = "dtg",
+ max_polls: int = 20,
+ poll_interval: float = 3.0,
+ ) -> list[dict] | None:
+ """Create mockup task and poll until complete.
+
+ Returns list of mockup dicts with "mockup_url" fields,
+ or None on failure/timeout.
+ """
+ task_id = await self.create_mockup_task(
+ product_id, variant_ids, image_url, placement, technique
+ )
+
+ for _ in range(max_polls):
+ await asyncio.sleep(poll_interval)
+ result = await self.get_mockup_task(task_id)
+ status = result.get("status", "")
+
+ if status == "completed":
+ return (
+ result.get("mockups", [])
+ or result.get("catalog_variant_mockups", [])
+ )
+ elif status == "failed":
+ reasons = result.get("failure_reasons", [])
+ logger.error(f"Mockup task {task_id} failed: {reasons}")
+ return None
+
+ logger.warning(f"Mockup task {task_id} timed out after {max_polls} polls")
+ return None
+
+ # ── Orders ──
+
+ async def create_order(
+ self,
+ items: list[dict],
+ recipient: dict,
+ ) -> dict:
+ """Create a fulfillment order.
+
+ Args:
+ items: List of dicts with:
+ - catalog_variant_id (int)
+ - quantity (int)
+ - image_url (str) — public URL to design
+ - placement (str, default "front")
+ recipient: dict with name, address1, city, state_code,
+ country_code, zip, email (optional)
+ """
+ if not self.enabled:
+ raise ValueError("Printful API token not configured")
+
+ order_items = []
+ for item in items:
+ order_items.append({
+ "source": "catalog",
+ "catalog_variant_id": item["catalog_variant_id"],
+ "quantity": item.get("quantity", 1),
+ "placements": [
+ {
+ "placement": item.get("placement", "front"),
+ "technique": "dtg",
+ "layers": [
+ {
+ "type": "file",
+ "url": item["image_url"],
+ }
+ ],
+ }
+ ],
+ })
+
+ payload = {
+ "recipient": recipient,
+ "items": order_items,
+ }
+
+ # Sandbox mode: create as draft (not sent to production)
+ if self.sandbox:
+ payload["draft"] = True
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{BASE_URL}/orders",
+ headers=self._headers,
+ json=payload,
+ )
+ resp.raise_for_status()
+ result = resp.json().get("data", {})
+ logger.info(f"Printful order created: {result.get('id')}")
+ return result
+
+ async def get_order(self, order_id: str) -> dict:
+ """Get order details by Printful order ID."""
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(
+ f"{BASE_URL}/orders/{order_id}",
+ headers=self._headers,
+ )
+ resp.raise_for_status()
+ return resp.json().get("data", {})
diff --git a/backend/app/pod/prodigi_client.py b/backend/app/pod/prodigi_client.py
new file mode 100644
index 0000000..46ad01a
--- /dev/null
+++ b/backend/app/pod/prodigi_client.py
@@ -0,0 +1,129 @@
+"""Prodigi Print-on-Demand API client (v4).
+
+Handles order submission, product specs, and quotes.
+Sandbox: https://api.sandbox.prodigi.com/v4.0/
+Production: https://api.prodigi.com/v4.0/
+"""
+
+import logging
+
+import httpx
+
+from app.config import get_settings
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+SANDBOX_URL = "https://api.sandbox.prodigi.com/v4.0"
+PRODUCTION_URL = "https://api.prodigi.com/v4.0"
+
+
+class ProdigiClient:
+ """Client for the Prodigi v4 Print API."""
+
+ def __init__(self):
+ self.api_key = settings.prodigi_api_key
+ self.base_url = SANDBOX_URL if settings.pod_sandbox_mode else PRODUCTION_URL
+ self.enabled = bool(self.api_key)
+
+ @property
+ def _headers(self) -> dict:
+ return {
+ "X-API-Key": self.api_key,
+ "Content-Type": "application/json",
+ }
+
+ async def create_order(
+ self,
+ items: list[dict],
+ recipient: dict,
+ shipping_method: str = "Budget",
+ metadata: dict | None = None,
+ ) -> dict:
+ """Create a Prodigi print order.
+
+ Args:
+ items: List of items, each with:
+ - sku: Prodigi SKU (e.g., "GLOBAL-STI-KIS-4X4")
+ - copies: Number of copies
+ - sizing: "fillPrintArea" | "fitPrintArea" | "stretchToPrintArea"
+ - assets: [{"printArea": "default", "url": "https://..."}]
+ recipient: Shipping address with:
+ - name: Recipient name
+ - email: Email (optional)
+ - address: {line1, line2, townOrCity, stateOrCounty, postalOrZipCode, countryCode}
+ shipping_method: "Budget" | "Standard" | "Express"
+ metadata: Optional key/value metadata
+ """
+ if not self.enabled:
+ raise ValueError("Prodigi API key not configured")
+
+ payload = {
+ "shippingMethod": shipping_method,
+ "recipient": recipient,
+ "items": items,
+ }
+ if metadata:
+ payload["metadata"] = metadata
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ resp = await client.post(
+ f"{self.base_url}/Orders",
+ headers=self._headers,
+ json=payload,
+ )
+ resp.raise_for_status()
+ result = resp.json()
+ logger.info(f"Prodigi order created: {result.get('id')}")
+ return result
+
+ async def get_order(self, order_id: str) -> dict:
+ """Get order details by Prodigi order ID."""
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(
+ f"{self.base_url}/Orders/{order_id}",
+ headers=self._headers,
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+ async def get_product(self, sku: str) -> dict:
+ """Get product specifications (dimensions, print areas, etc.)."""
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.get(
+ f"{self.base_url}/products/{sku}",
+ headers=self._headers,
+ )
+ resp.raise_for_status()
+ return resp.json()
+
+ async def get_quote(
+ self,
+ items: list[dict],
+ shipping_method: str = "Budget",
+ destination_country: str = "US",
+ ) -> dict:
+ """Get a pricing quote before ordering.
+
+ Args:
+ items: List with sku, copies, sizing, assets
+ shipping_method: Shipping tier
+ destination_country: 2-letter country code
+ """
+ payload = {
+ "shippingMethod": shipping_method,
+ "destinationCountryCode": destination_country,
+ "items": [
+ {"sku": item["sku"], "copies": item.get("copies", 1)}
+ for item in items
+ ],
+ }
+
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ f"{self.base_url}/quotes",
+ headers=self._headers,
+ json=payload,
+ )
+ resp.raise_for_status()
+ return resp.json()
diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py
new file mode 100644
index 0000000..8e92624
--- /dev/null
+++ b/backend/app/schemas/__init__.py
@@ -0,0 +1,28 @@
+"""Pydantic schemas for API request/response models."""
+
+from app.schemas.design import Design, DesignProduct, DesignSource
+from app.schemas.product import Product, ProductVariant
+from app.schemas.cart import (
+ CartCreate,
+ CartResponse,
+ CartItemCreate,
+ CartItemUpdate,
+ CartItemResponse,
+)
+from app.schemas.order import OrderResponse, OrderItemResponse, OrderStatus
+
+__all__ = [
+ "Design",
+ "DesignProduct",
+ "DesignSource",
+ "Product",
+ "ProductVariant",
+ "CartCreate",
+ "CartResponse",
+ "CartItemCreate",
+ "CartItemUpdate",
+ "CartItemResponse",
+ "OrderResponse",
+ "OrderItemResponse",
+ "OrderStatus",
+]
diff --git a/backend/app/schemas/cart.py b/backend/app/schemas/cart.py
new file mode 100644
index 0000000..c7af0c6
--- /dev/null
+++ b/backend/app/schemas/cart.py
@@ -0,0 +1,57 @@
+"""Cart schemas."""
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class CartItemCreate(BaseModel):
+ """Request to add item to cart."""
+
+ product_slug: str
+ product_name: str
+ variant: str | None = None
+ quantity: int = 1
+ unit_price: float
+
+
+class CartItemUpdate(BaseModel):
+ """Request to update cart item."""
+
+ quantity: int
+
+
+class CartItemResponse(BaseModel):
+ """Cart item in response."""
+
+ id: UUID
+ product_slug: str
+ product_name: str
+ variant: str | None
+ quantity: int
+ unit_price: float
+ subtotal: float
+
+ class Config:
+ from_attributes = True
+
+
+class CartCreate(BaseModel):
+ """Request to create a new cart."""
+
+ pass
+
+
+class CartResponse(BaseModel):
+ """Cart response."""
+
+ id: UUID
+ items: list[CartItemResponse]
+ item_count: int
+ subtotal: float
+ created_at: datetime
+ expires_at: datetime
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/design.py b/backend/app/schemas/design.py
new file mode 100644
index 0000000..caf129f
--- /dev/null
+++ b/backend/app/schemas/design.py
@@ -0,0 +1,43 @@
+"""Design schemas."""
+
+from pydantic import BaseModel
+
+
+class DesignSource(BaseModel):
+ """Design source file information."""
+
+ file: str
+ format: str
+ dimensions: dict[str, int]
+ dpi: int
+ color_profile: str = "sRGB"
+
+
+class DesignProduct(BaseModel):
+ """Product configuration for a design."""
+
+ type: str
+ provider: str
+ sku: str
+ variants: list[str] = []
+ retail_price: float
+
+
+class Design(BaseModel):
+ """Design information from metadata.yaml."""
+
+ slug: str
+ name: str
+ description: str
+ tags: list[str] = []
+ category: str
+ author: str = ""
+ created: str = ""
+ source: DesignSource
+ products: list[DesignProduct] = []
+ space: str = "default"
+ status: str = "draft"
+ image_url: str = ""
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py
new file mode 100644
index 0000000..28731ce
--- /dev/null
+++ b/backend/app/schemas/order.py
@@ -0,0 +1,76 @@
+"""Order schemas."""
+
+from datetime import datetime
+from enum import Enum
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class OrderStatus(str, Enum):
+ """Order status enum."""
+
+ PENDING = "pending"
+ PAID = "paid"
+ PROCESSING = "processing"
+ PRINTING = "printing"
+ SHIPPED = "shipped"
+ DELIVERED = "delivered"
+ CANCELLED = "cancelled"
+ REFUNDED = "refunded"
+
+
+class OrderItemResponse(BaseModel):
+ """Order item in response."""
+
+ id: UUID
+ product_slug: str
+ product_name: str
+ variant: str | None
+ quantity: int
+ unit_price: float
+ pod_provider: str | None
+ pod_status: str | None
+ pod_tracking_number: str | None
+ pod_tracking_url: str | None
+
+ class Config:
+ from_attributes = True
+
+
+class OrderResponse(BaseModel):
+ """Order response."""
+
+ id: UUID
+ status: str
+ shipping_name: str | None
+ shipping_email: str | None
+ shipping_city: str | None
+ shipping_country: str | None
+ subtotal: float | None
+ shipping_cost: float | None
+ tax: float | None
+ total: float | None
+ currency: str
+ items: list[OrderItemResponse]
+ created_at: datetime
+ paid_at: datetime | None
+ shipped_at: datetime | None
+
+ class Config:
+ from_attributes = True
+
+
+class CheckoutRequest(BaseModel):
+ """Request to create checkout session."""
+
+ cart_id: UUID
+ success_url: str
+ cancel_url: str
+
+
+class CheckoutResponse(BaseModel):
+ """Checkout session response."""
+
+ checkout_url: str
+ session_id: str
diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py
new file mode 100644
index 0000000..b3399a6
--- /dev/null
+++ b/backend/app/schemas/product.py
@@ -0,0 +1,30 @@
+"""Product schemas."""
+
+from pydantic import BaseModel
+
+
+class ProductVariant(BaseModel):
+ """Product variant information."""
+
+ name: str
+ sku: str
+ provider: str
+ price: float
+
+
+class Product(BaseModel):
+ """Product for display in storefront."""
+
+ slug: str
+ name: str
+ description: str
+ category: str
+ product_type: str # sticker, shirt, print
+ tags: list[str] = []
+ image_url: str
+ base_price: float
+ variants: list[ProductVariant] = []
+ is_active: bool = True
+
+ class Config:
+ from_attributes = True
diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py
new file mode 100644
index 0000000..de2060f
--- /dev/null
+++ b/backend/app/services/__init__.py
@@ -0,0 +1 @@
+"""Business logic services."""
diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py
new file mode 100644
index 0000000..ac4210b
--- /dev/null
+++ b/backend/app/services/analytics_service.py
@@ -0,0 +1,94 @@
+"""Analytics service for admin dashboard."""
+
+from datetime import datetime
+
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.order import Order, OrderItem, OrderStatus
+
+
+class AnalyticsService:
+ """Service for analytics and reporting."""
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_sales_summary(self, start_date: datetime) -> dict:
+ """Get sales summary for the given period."""
+ # Total revenue
+ revenue_result = await self.db.execute(
+ select(func.sum(Order.total))
+ .where(
+ Order.created_at >= start_date,
+ Order.status.in_([
+ OrderStatus.PAID.value,
+ OrderStatus.PROCESSING.value,
+ OrderStatus.SHIPPED.value,
+ OrderStatus.DELIVERED.value,
+ ]),
+ )
+ )
+ total_revenue = revenue_result.scalar() or 0
+
+ # Total orders
+ orders_result = await self.db.execute(
+ select(func.count(Order.id))
+ .where(Order.created_at >= start_date)
+ )
+ total_orders = orders_result.scalar() or 0
+
+ # Completed orders
+ completed_result = await self.db.execute(
+ select(func.count(Order.id))
+ .where(
+ Order.created_at >= start_date,
+ Order.status.in_([
+ OrderStatus.SHIPPED.value,
+ OrderStatus.DELIVERED.value,
+ ]),
+ )
+ )
+ completed_orders = completed_result.scalar() or 0
+
+ # Average order value
+ avg_order = total_revenue / total_orders if total_orders > 0 else 0
+
+ return {
+ "total_revenue": float(total_revenue),
+ "total_orders": total_orders,
+ "completed_orders": completed_orders,
+ "average_order_value": float(avg_order),
+ "period_start": start_date.isoformat(),
+ }
+
+ async def get_product_performance(
+ self,
+ start_date: datetime,
+ limit: int = 10,
+ ) -> list[dict]:
+ """Get top performing products."""
+ result = await self.db.execute(
+ select(
+ OrderItem.product_slug,
+ OrderItem.product_name,
+ func.sum(OrderItem.quantity).label("total_quantity"),
+ func.sum(OrderItem.quantity * OrderItem.unit_price).label("total_revenue"),
+ )
+ .join(Order)
+ .where(Order.created_at >= start_date)
+ .group_by(OrderItem.product_slug, OrderItem.product_name)
+ .order_by(func.sum(OrderItem.quantity * OrderItem.unit_price).desc())
+ .limit(limit)
+ )
+
+ products = []
+ for row in result:
+ products.append({
+ "slug": row.product_slug,
+ "name": row.product_name,
+ "total_quantity": row.total_quantity,
+ "total_revenue": float(row.total_revenue),
+ })
+
+ return products
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
new file mode 100644
index 0000000..0f4dcd6
--- /dev/null
+++ b/backend/app/services/auth_service.py
@@ -0,0 +1,90 @@
+"""Authentication service for admin users."""
+
+from datetime import datetime, timedelta
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from jose import JWTError, jwt
+from passlib.hash import bcrypt
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.database import get_db
+from app.models.admin import AdminUser
+
+settings = get_settings()
+security = HTTPBearer()
+
+
+class AuthService:
+ """Service for admin authentication."""
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def authenticate(self, email: str, password: str) -> str | None:
+ """Authenticate admin user and return JWT token."""
+ result = await self.db.execute(
+ select(AdminUser).where(AdminUser.email == email)
+ )
+ admin = result.scalar_one_or_none()
+
+ if not admin or not admin.is_active:
+ return None
+
+ if not bcrypt.verify(password, admin.password_hash):
+ return None
+
+ # Create JWT token
+ expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours)
+ payload = {
+ "sub": str(admin.id),
+ "email": admin.email,
+ "exp": expire,
+ }
+ token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
+ return token
+
+ async def verify_token(self, token: str) -> AdminUser | None:
+ """Verify JWT token and return admin user."""
+ try:
+ payload = jwt.decode(
+ token,
+ settings.jwt_secret,
+ algorithms=[settings.jwt_algorithm],
+ )
+ admin_id = payload.get("sub")
+ if not admin_id:
+ return None
+
+ result = await self.db.execute(
+ select(AdminUser).where(AdminUser.id == admin_id)
+ )
+ admin = result.scalar_one_or_none()
+ if not admin or not admin.is_active:
+ return None
+
+ return admin
+ except JWTError:
+ return None
+
+ @staticmethod
+ def hash_password(password: str) -> str:
+ """Hash a password."""
+ return bcrypt.hash(password)
+
+
+async def get_current_admin(
+ credentials: HTTPAuthorizationCredentials = Depends(security),
+ db: AsyncSession = Depends(get_db),
+) -> AdminUser:
+ """Dependency to get current authenticated admin."""
+ auth_service = AuthService(db)
+ admin = await auth_service.verify_token(credentials.credentials)
+ if not admin:
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid or expired token",
+ )
+ return admin
diff --git a/backend/app/services/cart_service.py b/backend/app/services/cart_service.py
new file mode 100644
index 0000000..4132005
--- /dev/null
+++ b/backend/app/services/cart_service.py
@@ -0,0 +1,146 @@
+"""Cart service for managing shopping carts."""
+
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.models.cart import Cart, CartItem
+from app.schemas.cart import CartItemCreate, CartResponse, CartItemResponse
+
+
+class CartService:
+ """Service for cart operations."""
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def create_cart(self) -> CartResponse:
+ """Create a new shopping cart."""
+ cart = Cart()
+ self.db.add(cart)
+ await self.db.commit()
+ # Re-fetch with items loaded to avoid lazy loading issues
+ result = await self.db.execute(
+ select(Cart)
+ .where(Cart.id == cart.id)
+ .options(selectinload(Cart.items))
+ )
+ cart = result.scalar_one()
+ return self._cart_to_response(cart)
+
+ async def get_cart(self, cart_id: UUID) -> CartResponse | None:
+ """Get cart by ID."""
+ result = await self.db.execute(
+ select(Cart)
+ .where(Cart.id == cart_id)
+ .options(selectinload(Cart.items))
+ )
+ cart = result.scalar_one_or_none()
+ if not cart:
+ return None
+ return self._cart_to_response(cart)
+
+ async def add_item(
+ self,
+ cart_id: UUID,
+ item: CartItemCreate,
+ ) -> CartResponse | None:
+ """Add item to cart."""
+ result = await self.db.execute(
+ select(Cart)
+ .where(Cart.id == cart_id)
+ .options(selectinload(Cart.items))
+ )
+ cart = result.scalar_one_or_none()
+ if not cart:
+ return None
+
+ # Check if item already exists (same product + variant)
+ for existing in cart.items:
+ if (
+ existing.product_slug == item.product_slug
+ and existing.variant == item.variant
+ ):
+ existing.quantity += item.quantity
+ await self.db.commit()
+ return await self.get_cart(cart_id)
+
+ # Add new item
+ cart_item = CartItem(
+ cart_id=cart_id,
+ product_slug=item.product_slug,
+ product_name=item.product_name,
+ variant=item.variant,
+ quantity=item.quantity,
+ unit_price=item.unit_price,
+ )
+ self.db.add(cart_item)
+ await self.db.commit()
+ return await self.get_cart(cart_id)
+
+ async def update_item(
+ self,
+ cart_id: UUID,
+ item_id: UUID,
+ quantity: int,
+ ) -> CartResponse | None:
+ """Update cart item quantity."""
+ result = await self.db.execute(
+ select(CartItem)
+ .where(CartItem.id == item_id, CartItem.cart_id == cart_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ return None
+
+ if quantity <= 0:
+ await self.db.delete(item)
+ else:
+ item.quantity = quantity
+
+ await self.db.commit()
+ return await self.get_cart(cart_id)
+
+ async def remove_item(
+ self,
+ cart_id: UUID,
+ item_id: UUID,
+ ) -> CartResponse | None:
+ """Remove item from cart."""
+ result = await self.db.execute(
+ select(CartItem)
+ .where(CartItem.id == item_id, CartItem.cart_id == cart_id)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ return None
+
+ await self.db.delete(item)
+ await self.db.commit()
+ return await self.get_cart(cart_id)
+
+ def _cart_to_response(self, cart: Cart) -> CartResponse:
+ """Convert Cart model to response schema."""
+ items = [
+ CartItemResponse(
+ id=item.id,
+ product_slug=item.product_slug,
+ product_name=item.product_name,
+ variant=item.variant,
+ quantity=item.quantity,
+ unit_price=float(item.unit_price),
+ subtotal=float(item.unit_price) * item.quantity,
+ )
+ for item in cart.items
+ ]
+
+ return CartResponse(
+ id=cart.id,
+ items=items,
+ item_count=sum(item.quantity for item in items),
+ subtotal=sum(item.subtotal for item in items),
+ created_at=cart.created_at,
+ expires_at=cart.expires_at,
+ )
diff --git a/backend/app/services/design_service.py b/backend/app/services/design_service.py
new file mode 100644
index 0000000..65e16cc
--- /dev/null
+++ b/backend/app/services/design_service.py
@@ -0,0 +1,300 @@
+"""Design service for reading designs from the designs directory."""
+
+from pathlib import Path
+from functools import lru_cache
+
+import yaml
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.schemas.design import Design, DesignSource, DesignProduct
+from app.schemas.product import Product, ProductVariant
+from app.models.product import ProductOverride
+
+settings = get_settings()
+
+
+class DesignService:
+ """Service for reading and managing designs."""
+
+ def __init__(self):
+ self.designs_path = settings.designs_dir
+ self._cache: dict[str, Design] = {}
+
+ def clear_cache(self):
+ """Clear the design cache."""
+ self._cache.clear()
+
+ async def list_designs(
+ self,
+ status: str = "active",
+ category: str | None = None,
+ space: str | None = None,
+ ) -> list[Design]:
+ """List all designs from the designs directory."""
+ designs = []
+
+ if not self.designs_path.exists():
+ return designs
+
+ for category_dir in self.designs_path.iterdir():
+ if not category_dir.is_dir():
+ continue
+
+ # Filter by category if specified
+ if category and category_dir.name != category:
+ continue
+
+ for design_dir in category_dir.iterdir():
+ if not design_dir.is_dir():
+ continue
+
+ design = await self._load_design(design_dir, category_dir.name)
+ if design and design.status == status:
+ # Filter by space if specified
+ if space and space != "all":
+ if design.space != space and design.space != "all":
+ continue
+ designs.append(design)
+
+ return designs
+
+ async def get_design(self, slug: str) -> Design | None:
+ """Get a single design by slug."""
+ # Check cache
+ if slug in self._cache:
+ return self._cache[slug]
+
+ # Search for the design
+ for category_dir in self.designs_path.iterdir():
+ if not category_dir.is_dir():
+ continue
+
+ design_dir = category_dir / slug
+ if design_dir.exists():
+ design = await self._load_design(design_dir, category_dir.name)
+ if design:
+ self._cache[slug] = design
+ return design
+
+ return None
+
+ async def get_design_image_path(self, slug: str) -> str | None:
+ """Get the path to the design image file."""
+ design = await self.get_design(slug)
+ if not design:
+ return None
+
+ # Look for exported PNG first
+ for category_dir in self.designs_path.iterdir():
+ if not category_dir.is_dir():
+ continue
+
+ design_dir = category_dir / slug
+ if not design_dir.exists():
+ continue
+
+ # Check exports/300dpi first
+ export_path = design_dir / "exports" / "300dpi" / f"{slug}.png"
+ if export_path.exists():
+ return str(export_path)
+
+ # Check for source PNG
+ source_path = design_dir / design.source.file
+ if source_path.exists() and source_path.suffix.lower() == ".png":
+ return str(source_path)
+
+ # Check for any PNG in the directory
+ for png_file in design_dir.glob("*.png"):
+ return str(png_file)
+
+ return None
+
+ async def _load_design(self, design_dir: Path, category: str) -> Design | None:
+ """Load a design from its directory."""
+ metadata_path = design_dir / "metadata.yaml"
+ if not metadata_path.exists():
+ return None
+
+ try:
+ with open(metadata_path) as f:
+ metadata = yaml.safe_load(f)
+ except Exception:
+ return None
+
+ if not metadata:
+ return None
+
+ slug = metadata.get("slug", design_dir.name)
+
+ # Parse source info
+ source_data = metadata.get("source", {})
+ source = DesignSource(
+ file=source_data.get("file", f"{slug}.svg"),
+ format=source_data.get("format", "svg"),
+ dimensions=source_data.get("dimensions", {"width": 0, "height": 0}),
+ dpi=source_data.get("dpi", 300),
+ color_profile=source_data.get("color_profile", "sRGB"),
+ )
+
+ # Parse products
+ products = []
+ for p in metadata.get("products", []):
+ products.append(
+ DesignProduct(
+ type=p.get("type", ""),
+ provider=p.get("provider", ""),
+ sku=str(p.get("sku", "")), # Convert to string (some SKUs are integers)
+ variants=p.get("variants", []),
+ retail_price=float(p.get("retail_price", 0)),
+ )
+ )
+
+ return Design(
+ slug=slug,
+ name=metadata.get("name", slug),
+ description=metadata.get("description", ""),
+ tags=metadata.get("tags", []),
+ category=category,
+ author=metadata.get("author", ""),
+ created=str(metadata.get("created", "")),
+ source=source,
+ products=products,
+ space=metadata.get("space", "default"),
+ status=metadata.get("status", "draft"),
+ image_url=f"/api/designs/{slug}/image",
+ )
+
+ async def list_products(
+ self,
+ category: str | None = None,
+ product_type: str | None = None,
+ space: str | None = None,
+ ) -> list[Product]:
+ """List all products (designs formatted for storefront)."""
+ designs = await self.list_designs(status="active", category=category, space=space)
+ products = []
+
+ for design in designs:
+ # Skip designs with no products
+ if not design.products:
+ continue
+
+ # Filter by product type if specified
+ matching_products = [
+ dp for dp in design.products
+ if not product_type or dp.type == product_type
+ ]
+
+ if not matching_products:
+ continue
+
+ # Use the first matching product for base info, combine all variants
+ dp = matching_products[0]
+ all_variants = []
+
+ for mp in matching_products:
+ if mp.variants:
+ for v in mp.variants:
+ all_variants.append(
+ ProductVariant(
+ name=f"{v} ({mp.provider})",
+ sku=f"{mp.sku}-{v}",
+ provider=mp.provider,
+ price=mp.retail_price,
+ )
+ )
+ else:
+ all_variants.append(
+ ProductVariant(
+ name=f"default ({mp.provider})",
+ sku=mp.sku,
+ provider=mp.provider,
+ price=mp.retail_price,
+ )
+ )
+
+ products.append(
+ Product(
+ slug=design.slug,
+ name=design.name,
+ description=design.description,
+ category=design.category,
+ product_type=dp.type,
+ tags=design.tags,
+ image_url=design.image_url,
+ base_price=dp.retail_price,
+ variants=all_variants,
+ is_active=True,
+ )
+ )
+
+ return products
+
+ async def get_product(self, slug: str) -> Product | None:
+ """Get a single product by slug."""
+ design = await self.get_design(slug)
+ if not design or not design.products:
+ return None
+
+ # Use the first product configuration
+ dp = design.products[0]
+ variants = [
+ ProductVariant(
+ name=v,
+ sku=f"{dp.sku}-{v}",
+ provider=dp.provider,
+ price=dp.retail_price,
+ )
+ for v in dp.variants
+ ] if dp.variants else [
+ ProductVariant(
+ name="default",
+ sku=dp.sku,
+ provider=dp.provider,
+ price=dp.retail_price,
+ )
+ ]
+
+ return Product(
+ slug=design.slug,
+ name=design.name,
+ description=design.description,
+ category=design.category,
+ product_type=dp.type,
+ tags=design.tags,
+ image_url=design.image_url,
+ base_price=dp.retail_price,
+ variants=variants,
+ is_active=True,
+ )
+
+ async def set_product_override(
+ self,
+ db: AsyncSession,
+ slug: str,
+ is_active: bool | None = None,
+ price_override: float | None = None,
+ ):
+ """Set a product override in the database."""
+ # Check if override exists
+ result = await db.execute(
+ select(ProductOverride).where(ProductOverride.slug == slug)
+ )
+ override = result.scalar_one_or_none()
+
+ if override:
+ if is_active is not None:
+ override.is_active = is_active
+ if price_override is not None:
+ override.price_override = price_override
+ else:
+ override = ProductOverride(
+ slug=slug,
+ is_active=is_active if is_active is not None else True,
+ price_override=price_override,
+ )
+ db.add(override)
+
+ await db.commit()
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
new file mode 100644
index 0000000..609bc2b
--- /dev/null
+++ b/backend/app/services/email_service.py
@@ -0,0 +1,249 @@
+"""Email service for order confirmations and shipping notifications."""
+
+import logging
+import ssl
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+import aiosmtplib
+
+from app.config import get_settings
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+
+class EmailService:
+ """Async email sender via SMTP (Mailcow)."""
+
+ @property
+ def enabled(self) -> bool:
+ return bool(settings.smtp_user and settings.smtp_password)
+
+ async def send_order_confirmation(
+ self,
+ *,
+ to_email: str,
+ to_name: str | None,
+ order_id: str,
+ items: list[dict],
+ total: float,
+ currency: str = "USD",
+ ):
+ """Send order confirmation email after successful payment."""
+ if not self.enabled:
+ logger.info("SMTP not configured, skipping order confirmation email")
+ return
+
+ subject = f"Order Confirmed — {settings.app_name} #{order_id[:8]}"
+ html = self._render_confirmation_html(
+ to_name=to_name,
+ order_id=order_id,
+ items=items,
+ total=total,
+ currency=currency,
+ )
+
+ await self._send(to_email=to_email, subject=subject, html=html)
+
+ async def send_shipping_notification(
+ self,
+ *,
+ to_email: str,
+ to_name: str | None,
+ order_id: str,
+ tracking_number: str | None = None,
+ tracking_url: str | None = None,
+ ):
+ """Send shipping notification when POD provider ships the order."""
+ if not self.enabled:
+ return
+
+ subject = f"Your Order Has Shipped — {settings.app_name}"
+ html = self._render_shipping_html(
+ to_name=to_name,
+ order_id=order_id,
+ tracking_number=tracking_number,
+ tracking_url=tracking_url,
+ )
+
+ await self._send(to_email=to_email, subject=subject, html=html)
+
+ async def _send(self, *, to_email: str, subject: str, html: str):
+ """Send an HTML email via SMTP."""
+ msg = MIMEMultipart("alternative")
+ msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
+ msg["To"] = to_email
+ msg["Subject"] = subject
+
+ # Plain-text fallback
+ plain = html.replace(" ", "\n").replace("
", "\n")
+ # Strip remaining tags
+ import re
+ plain = re.sub(r"<[^>]+>", "", plain)
+ msg.attach(MIMEText(plain, "plain"))
+ msg.attach(MIMEText(html, "html"))
+
+ tls_context = ssl.create_default_context()
+ tls_context.check_hostname = False
+ tls_context.verify_mode = ssl.CERT_NONE # self-signed cert on Mailcow
+
+ try:
+ await aiosmtplib.send(
+ msg,
+ hostname=settings.smtp_host,
+ port=settings.smtp_port,
+ username=settings.smtp_user,
+ password=settings.smtp_password,
+ start_tls=True,
+ tls_context=tls_context,
+ )
+ logger.info(f"Sent email to {to_email}: {subject}")
+ except Exception as e:
+ logger.error(f"Failed to send email to {to_email}: {e}")
+
+ def _render_confirmation_html(
+ self,
+ *,
+ to_name: str | None,
+ order_id: str,
+ items: list[dict],
+ total: float,
+ currency: str,
+ ) -> str:
+ greeting = f"Hi {to_name}," if to_name else "Hi there,"
+ order_url = f"{settings.public_url}/checkout/success?order_id={order_id}"
+ currency_symbol = "$" if currency == "USD" else currency + " "
+
+ items_html = ""
+ for item in items:
+ qty = item.get("quantity", 1)
+ name = item.get("product_name", "Item")
+ variant = item.get("variant", "")
+ price = item.get("unit_price", 0)
+ variant_str = f" ({variant})" if variant else ""
+ items_html += f"""
+
+ {name}{variant_str}
+ {qty}
+ {currency_symbol}{price:.2f}
+ """
+
+ return f"""
+
+
+
+
+
+
+
+
rSw
+
Order Confirmed
+
+
+
+
{greeting}
+
+ Thank you for your order! Your items are being prepared for production.
+ Print-on-demand means each piece is made just for you at the nearest fulfillment center.
+
+
+
+
+
Order Summary
+
+
+ Item
+ Qty
+ Price
+
+ {items_html}
+
+ Total
+ {currency_symbol}{total:.2f}
+
+
+
+
+
+
+
+
+
+
What Happens Next
+
+ Your design is sent to the nearest print facility
+ Each item is printed on demand — just for you
+ You'll get a shipping email with tracking info
+ Revenue from your purchase supports the community
+
+
+
+
+
+
Order #{order_id[:8]}
+
{settings.app_name} — Community merch, on demand.
+
Part of the rStack ecosystem.
+
+
+
+
+"""
+
+ def _render_shipping_html(
+ self,
+ *,
+ to_name: str | None,
+ order_id: str,
+ tracking_number: str | None,
+ tracking_url: str | None,
+ ) -> str:
+ greeting = f"Hi {to_name}," if to_name else "Hi there,"
+ order_url = f"{settings.public_url}/checkout/success?order_id={order_id}"
+
+ tracking_html = ""
+ if tracking_number:
+ track_link = tracking_url or "#"
+ tracking_html = f"""
+ """
+
+ return f"""
+
+
+
+
+
+
+
+
rSw
+
Your Order Has Shipped!
+
+
+
{greeting}
+
+ Great news — your order is on its way! It was printed at the nearest fulfillment center and is now heading to you.
+
+
+ {tracking_html}
+
+
+
+
+
Order #{order_id[:8]}
+
{settings.app_name} — Community merch, on demand.
+
+
+
+
+"""
diff --git a/backend/app/services/flow_service.py b/backend/app/services/flow_service.py
new file mode 100644
index 0000000..d92a200
--- /dev/null
+++ b/backend/app/services/flow_service.py
@@ -0,0 +1,100 @@
+"""Flow service client for TBFF revenue routing.
+
+After a swag sale, the margin (sale price minus POD fulfillment cost)
+gets deposited into a TBFF funnel via the flow-service. The flow-service
+manages threshold-based distribution, and when the funnel overflows its
+MAX threshold, excess funds route to the bonding curve.
+
+Revenue split flow:
+ Mollie payment → calculate margin → deposit to flow-service funnel
+ ↓
+ TBFF thresholds
+ ↓ overflow
+ bonding curve ($MYCO)
+"""
+
+import logging
+
+import httpx
+
+from app.config import get_settings
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+
+class FlowService:
+ """Client for the payment-infra flow-service."""
+
+ def __init__(self):
+ self.base_url = settings.flow_service_url.rstrip("/")
+ self.flow_id = settings.flow_id
+ self.funnel_id = settings.flow_funnel_id
+ self.enabled = bool(self.base_url and self.flow_id and self.funnel_id)
+
+ async def deposit_revenue(
+ self,
+ amount: float,
+ currency: str = "USD",
+ order_id: str | None = None,
+ description: str | None = None,
+ ) -> dict | None:
+ """Deposit revenue margin into the TBFF funnel.
+
+ Args:
+ amount: Fiat amount to deposit (post-split margin)
+ currency: Currency code (default USD)
+ order_id: rSwag order ID for traceability
+ description: Human-readable note
+ """
+ if not self.enabled:
+ logger.info("Flow service not configured, skipping revenue deposit")
+ return None
+
+ if amount <= 0:
+ return None
+
+ payload = {
+ "funnelId": self.funnel_id,
+ "amount": round(amount, 2),
+ "currency": currency,
+ "source": "rswag",
+ "metadata": {},
+ }
+ if order_id:
+ payload["metadata"]["order_id"] = order_id
+ if description:
+ payload["metadata"]["description"] = description
+
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(
+ f"{self.base_url}/api/flows/{self.flow_id}/deposit",
+ json=payload,
+ )
+ resp.raise_for_status()
+ result = resp.json()
+ logger.info(
+ f"Revenue deposited to flow: ${amount:.2f} {currency} "
+ f"(order={order_id})"
+ )
+ return result
+ except httpx.HTTPError as e:
+ logger.error(f"Failed to deposit revenue to flow service: {e}")
+ return None
+
+ async def get_flow_stats(self) -> dict | None:
+ """Get current flow stats (balance, thresholds, etc.)."""
+ if not self.enabled:
+ return None
+
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(
+ f"{self.base_url}/api/flows/{self.flow_id}",
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except httpx.HTTPError as e:
+ logger.error(f"Failed to get flow stats: {e}")
+ return None
diff --git a/backend/app/services/mollie_service.py b/backend/app/services/mollie_service.py
new file mode 100644
index 0000000..9a63c99
--- /dev/null
+++ b/backend/app/services/mollie_service.py
@@ -0,0 +1,79 @@
+"""Mollie payment service."""
+
+from mollie.api.client import Client
+
+from app.config import get_settings
+from app.schemas.cart import CartResponse
+
+settings = get_settings()
+
+
+class MollieService:
+ """Service for Mollie payment operations."""
+
+ def __init__(self):
+ self.client = Client()
+ if settings.mollie_api_key:
+ self.client.set_api_key(settings.mollie_api_key)
+
+ async def create_payment(
+ self,
+ cart: CartResponse,
+ success_url: str,
+ cancel_url: str,
+ webhook_url: str,
+ ) -> dict:
+ """Create a Mollie payment.
+
+ Mollie uses a redirect flow: create payment → redirect to hosted page →
+ webhook callback on completion → redirect to success URL.
+ """
+ # Build description from cart items
+ item_names = [item.product_name for item in cart.items]
+ description = f"rSwag order: {', '.join(item_names[:3])}"
+ if len(item_names) > 3:
+ description += f" (+{len(item_names) - 3} more)"
+
+ # Calculate total from cart
+ total = sum(item.unit_price * item.quantity for item in cart.items)
+
+ payment = self.client.payments.create({
+ "amount": {
+ "currency": "USD",
+ "value": f"{total:.2f}",
+ },
+ "description": description,
+ "redirectUrl": f"{success_url}?payment_id={{paymentId}}",
+ "cancelUrl": cancel_url,
+ "webhookUrl": webhook_url,
+ "metadata": {
+ "cart_id": str(cart.id),
+ },
+ })
+
+ return {
+ "url": payment["_links"]["checkout"]["href"],
+ "payment_id": payment["id"],
+ }
+
+ async def get_payment(self, payment_id: str) -> dict:
+ """Get Mollie payment details."""
+ payment = self.client.payments.get(payment_id)
+ return payment
+
+ async def create_refund(
+ self,
+ payment_id: str,
+ amount: float | None = None,
+ currency: str = "USD",
+ ) -> dict:
+ """Create a refund for a Mollie payment."""
+ payment = self.client.payments.get(payment_id)
+ refund_data = {}
+ if amount is not None:
+ refund_data["amount"] = {
+ "currency": currency,
+ "value": f"{amount:.2f}",
+ }
+ refund = self.client.payment_refunds.with_parent_id(payment_id).create(refund_data)
+ return refund
diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py
new file mode 100644
index 0000000..3cf3d21
--- /dev/null
+++ b/backend/app/services/order_service.py
@@ -0,0 +1,495 @@
+"""Order management service."""
+
+import logging
+from datetime import datetime
+from uuid import UUID
+
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.config import get_settings
+from app.models.order import Order, OrderItem, OrderStatus
+from app.models.customer import Customer
+from app.models.cart import Cart
+from app.schemas.order import OrderResponse, OrderItemResponse
+from app.services.flow_service import FlowService
+from app.pod.printful_client import PrintfulClient
+from app.pod.prodigi_client import ProdigiClient
+from app.services.design_service import DesignService
+from app.services.email_service import EmailService
+
+logger = logging.getLogger(__name__)
+settings = get_settings()
+
+
+class OrderService:
+ """Service for order operations."""
+
+ def __init__(self, db: AsyncSession):
+ self.db = db
+
+ async def get_order_by_id(self, order_id: UUID) -> OrderResponse | None:
+ """Get order by ID."""
+ result = await self.db.execute(
+ select(Order)
+ .where(Order.id == order_id)
+ .options(selectinload(Order.items))
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ return None
+ return self._order_to_response(order)
+
+ async def get_order_by_id_and_email(
+ self,
+ order_id: UUID,
+ email: str,
+ ) -> OrderResponse | None:
+ """Get order by ID with email verification."""
+ result = await self.db.execute(
+ select(Order)
+ .where(Order.id == order_id, Order.shipping_email == email)
+ .options(selectinload(Order.items))
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ return None
+ return self._order_to_response(order)
+
+ async def list_orders(
+ self,
+ status: OrderStatus | None = None,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> list[OrderResponse]:
+ """List orders with optional status filter."""
+ query = select(Order).options(selectinload(Order.items))
+ if status:
+ query = query.where(Order.status == status.value)
+ query = query.order_by(Order.created_at.desc()).limit(limit).offset(offset)
+
+ result = await self.db.execute(query)
+ orders = result.scalars().all()
+ return [self._order_to_response(o) for o in orders]
+
+ async def update_status(
+ self,
+ order_id: UUID,
+ status: OrderStatus,
+ ) -> OrderResponse | None:
+ """Update order status."""
+ result = await self.db.execute(
+ select(Order)
+ .where(Order.id == order_id)
+ .options(selectinload(Order.items))
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ return None
+
+ order.status = status.value
+ if status == OrderStatus.SHIPPED:
+ order.shipped_at = datetime.utcnow()
+ elif status == OrderStatus.DELIVERED:
+ order.delivered_at = datetime.utcnow()
+
+ await self.db.commit()
+ return self._order_to_response(order)
+
+ async def handle_successful_payment(self, payment: dict):
+ """Handle successful Mollie payment.
+
+ Called by the Mollie webhook when payment status is 'paid'.
+ Mollie payment object contains metadata with cart_id.
+ """
+ cart_id = payment.get("metadata", {}).get("cart_id")
+ if not cart_id:
+ return
+
+ # Get cart
+ result = await self.db.execute(
+ select(Cart)
+ .where(Cart.id == UUID(cart_id))
+ .options(selectinload(Cart.items))
+ )
+ cart = result.scalar_one_or_none()
+ if not cart or not cart.items:
+ return
+
+ # Extract amount from Mollie payment
+ amount = payment.get("amount", {})
+ total = float(amount.get("value", "0"))
+ currency = amount.get("currency", "USD")
+
+ # Create order
+ order = Order(
+ payment_provider="mollie",
+ payment_id=payment.get("id"),
+ payment_method=payment.get("method"),
+ status=OrderStatus.PAID.value,
+ shipping_email=payment.get("metadata", {}).get("email", ""),
+ subtotal=total,
+ total=total,
+ currency=currency,
+ paid_at=datetime.utcnow(),
+ )
+ self.db.add(order)
+ await self.db.flush()
+
+ # Create order items
+ for cart_item in cart.items:
+ order_item = OrderItem(
+ order_id=order.id,
+ product_slug=cart_item.product_slug,
+ product_name=cart_item.product_name,
+ variant=cart_item.variant,
+ quantity=cart_item.quantity,
+ unit_price=float(cart_item.unit_price),
+ pod_status="pending",
+ )
+ self.db.add(order_item)
+
+ await self.db.commit()
+
+ # Route revenue margin to TBFF flow → bonding curve
+ await self._deposit_revenue_to_flow(order)
+
+ # Submit to POD providers
+ await self._submit_to_pod(order)
+
+ # Send confirmation email (non-blocking — don't fail the order if email fails)
+ try:
+ email_service = EmailService()
+ await email_service.send_order_confirmation(
+ to_email=order.shipping_email or "",
+ to_name=order.shipping_name,
+ order_id=str(order.id),
+ items=[
+ {
+ "product_name": item.product_name,
+ "variant": item.variant,
+ "quantity": item.quantity,
+ "unit_price": float(item.unit_price),
+ }
+ for item in order.items
+ ],
+ total=float(order.total),
+ currency=order.currency or "USD",
+ )
+ except Exception as e:
+ logger.error(f"Failed to send confirmation email for order {order.id}: {e}")
+
+ async def update_pod_status(
+ self,
+ pod_provider: str,
+ pod_order_id: str,
+ status: str,
+ tracking_number: str | None = None,
+ tracking_url: str | None = None,
+ ):
+ """Update POD status for order items."""
+ await self.db.execute(
+ update(OrderItem)
+ .where(
+ OrderItem.pod_provider == pod_provider,
+ OrderItem.pod_order_id == pod_order_id,
+ )
+ .values(
+ pod_status=status,
+ pod_tracking_number=tracking_number,
+ pod_tracking_url=tracking_url,
+ )
+ )
+ await self.db.commit()
+
+ # Send shipping notification when items ship
+ if status in ("shipped", "in_transit") and tracking_number:
+ await self._send_shipping_email(
+ pod_provider=pod_provider,
+ pod_order_id=pod_order_id,
+ tracking_number=tracking_number,
+ tracking_url=tracking_url,
+ )
+
+ async def _send_shipping_email(
+ self,
+ pod_provider: str,
+ pod_order_id: str,
+ tracking_number: str | None,
+ tracking_url: str | None,
+ ):
+ """Send shipping notification for an order."""
+ try:
+ # Find the order via its items
+ result = await self.db.execute(
+ select(OrderItem)
+ .where(
+ OrderItem.pod_provider == pod_provider,
+ OrderItem.pod_order_id == pod_order_id,
+ )
+ .limit(1)
+ )
+ item = result.scalar_one_or_none()
+ if not item:
+ return
+
+ result = await self.db.execute(
+ select(Order).where(Order.id == item.order_id)
+ )
+ order = result.scalar_one_or_none()
+ if not order or not order.shipping_email:
+ return
+
+ email_service = EmailService()
+ await email_service.send_shipping_notification(
+ to_email=order.shipping_email,
+ to_name=order.shipping_name,
+ order_id=str(order.id),
+ tracking_number=tracking_number,
+ tracking_url=tracking_url,
+ )
+ except Exception as e:
+ logger.error(f"Failed to send shipping email: {e}")
+
+ async def _submit_to_pod(self, order: Order):
+ """Route order items to the correct POD provider for fulfillment.
+
+ Reads each item's design metadata to determine provider (printful/prodigi),
+ groups items, and submits separate orders per provider.
+ """
+ if not order.shipping_address_line1:
+ logger.info(f"Order {order.id} has no shipping address, skipping POD")
+ return
+
+ design_service = DesignService()
+ printful_items = []
+ prodigi_items = []
+
+ for item in order.items:
+ image_url = f"https://fungiswag.jeffemmett.com/api/designs/{item.product_slug}/image"
+
+ design = await design_service.get_design(item.product_slug)
+ provider = "prodigi" # default
+ product_sku = item.variant or item.product_slug
+
+ if design and design.products:
+ product_config = design.products[0]
+ provider = product_config.provider
+ product_sku = product_config.sku
+
+ if provider == "printful":
+ # Extract size from variant string (e.g. "71-M" → "M", or just "M")
+ size = item.variant or "M"
+ if "-" in size:
+ size = size.split("-", 1)[1]
+
+ printful_items.append({
+ "order_item": item,
+ "product_id": int(product_sku),
+ "size": size,
+ "quantity": item.quantity,
+ "image_url": image_url,
+ })
+ else:
+ prodigi_items.append({
+ "order_item": item,
+ "sku": item.variant or item.product_slug,
+ "quantity": item.quantity,
+ "image_url": image_url,
+ })
+
+ if printful_items:
+ await self._submit_to_printful(order, printful_items)
+ if prodigi_items:
+ await self._submit_to_prodigi(order, prodigi_items)
+
+ async def _submit_to_printful(self, order: Order, items: list[dict]):
+ """Submit items to Printful for fulfillment."""
+ printful = PrintfulClient()
+ if not printful.enabled:
+ logger.info("Printful not configured, skipping")
+ return
+
+ order_items = []
+ for item_data in items:
+ variant_id = await printful.resolve_variant_id(
+ product_id=item_data["product_id"],
+ size=item_data["size"],
+ )
+ if not variant_id:
+ logger.error(
+ f"Could not resolve Printful variant for product "
+ f"{item_data['product_id']} size {item_data['size']}"
+ )
+ continue
+
+ order_items.append({
+ "catalog_variant_id": variant_id,
+ "quantity": item_data["quantity"],
+ "image_url": item_data["image_url"],
+ "placement": "front_large",
+ })
+
+ if not order_items:
+ return
+
+ recipient = {
+ "name": order.shipping_name or "",
+ "address1": order.shipping_address_line1 or "",
+ "address2": order.shipping_address_line2 or "",
+ "city": order.shipping_city or "",
+ "state_code": order.shipping_state or "",
+ "country_code": order.shipping_country or "",
+ "zip": order.shipping_postal_code or "",
+ "email": order.shipping_email or "",
+ }
+
+ try:
+ result = await printful.create_order(
+ items=order_items,
+ recipient=recipient,
+ )
+ pod_order_id = str(result.get("id", ""))
+
+ for item_data in items:
+ item_data["order_item"].pod_provider = "printful"
+ item_data["order_item"].pod_order_id = pod_order_id
+ item_data["order_item"].pod_status = "submitted"
+
+ order.status = OrderStatus.PROCESSING.value
+ await self.db.commit()
+ logger.info(f"Submitted order {order.id} to Printful: {pod_order_id}")
+
+ except Exception as e:
+ logger.error(f"Failed to submit order {order.id} to Printful: {e}")
+
+ async def _submit_to_prodigi(self, order: Order, items: list[dict]):
+ """Submit items to Prodigi for fulfillment."""
+ prodigi = ProdigiClient()
+ if not prodigi.enabled:
+ logger.info("Prodigi not configured, skipping")
+ return
+
+ prodigi_items = []
+ for item_data in items:
+ prodigi_items.append({
+ "sku": item_data["sku"],
+ "copies": item_data["quantity"],
+ "sizing": "fillPrintArea",
+ "assets": [{"printArea": "default", "url": item_data["image_url"]}],
+ })
+
+ recipient = {
+ "name": order.shipping_name or "",
+ "email": order.shipping_email or "",
+ "address": {
+ "line1": order.shipping_address_line1 or "",
+ "line2": order.shipping_address_line2 or "",
+ "townOrCity": order.shipping_city or "",
+ "stateOrCounty": order.shipping_state or "",
+ "postalOrZipCode": order.shipping_postal_code or "",
+ "countryCode": order.shipping_country or "",
+ },
+ }
+
+ try:
+ result = await prodigi.create_order(
+ items=prodigi_items,
+ recipient=recipient,
+ metadata={"rswag_order_id": str(order.id)},
+ )
+ pod_order_id = result.get("id")
+
+ for item_data in items:
+ item_data["order_item"].pod_provider = "prodigi"
+ item_data["order_item"].pod_order_id = pod_order_id
+ item_data["order_item"].pod_status = "submitted"
+
+ order.status = OrderStatus.PROCESSING.value
+ await self.db.commit()
+ logger.info(f"Submitted order {order.id} to Prodigi: {pod_order_id}")
+
+ except Exception as e:
+ logger.error(f"Failed to submit order {order.id} to Prodigi: {e}")
+
+ async def _deposit_revenue_to_flow(self, order: Order):
+ """Calculate margin and deposit to TBFF flow for bonding curve funding.
+
+ Revenue split:
+ total sale - POD cost estimate = margin
+ margin × flow_revenue_split = amount deposited to flow
+ flow → Transak on-ramp → USDC → bonding curve → $MYCO
+ """
+ split = settings.flow_revenue_split
+ if split <= 0:
+ return
+
+ total = float(order.total) if order.total else 0
+ if total <= 0:
+ return
+
+ # Revenue split: configurable fraction of total goes to flow
+ # (POD costs + operational expenses kept as fiat remainder)
+ flow_amount = round(total * split, 2)
+
+ flow_service = FlowService()
+ await flow_service.deposit_revenue(
+ amount=flow_amount,
+ currency=order.currency or "USD",
+ order_id=str(order.id),
+ description=f"rSwag sale revenue split ({split:.0%} of ${total:.2f})",
+ )
+
+ async def _get_or_create_customer(self, email: str) -> Customer | None:
+ """Get or create customer by email."""
+ if not email:
+ return None
+
+ result = await self.db.execute(
+ select(Customer).where(Customer.email == email)
+ )
+ customer = result.scalar_one_or_none()
+ if customer:
+ return customer
+
+ customer = Customer(email=email)
+ self.db.add(customer)
+ await self.db.flush()
+ return customer
+
+ def _order_to_response(self, order: Order) -> OrderResponse:
+ """Convert Order model to response schema."""
+ items = [
+ OrderItemResponse(
+ id=item.id,
+ product_slug=item.product_slug,
+ product_name=item.product_name,
+ variant=item.variant,
+ quantity=item.quantity,
+ unit_price=float(item.unit_price),
+ pod_provider=item.pod_provider,
+ pod_status=item.pod_status,
+ pod_tracking_number=item.pod_tracking_number,
+ pod_tracking_url=item.pod_tracking_url,
+ )
+ for item in order.items
+ ]
+
+ return OrderResponse(
+ id=order.id,
+ status=order.status,
+ shipping_name=order.shipping_name,
+ shipping_email=order.shipping_email,
+ shipping_city=order.shipping_city,
+ shipping_country=order.shipping_country,
+ subtotal=float(order.subtotal) if order.subtotal else None,
+ shipping_cost=float(order.shipping_cost) if order.shipping_cost else None,
+ tax=float(order.tax) if order.tax else None,
+ total=float(order.total) if order.total else None,
+ currency=order.currency,
+ items=items,
+ created_at=order.created_at,
+ paid_at=order.paid_at,
+ shipped_at=order.shipped_at,
+ )
diff --git a/backend/app/services/space_service.py b/backend/app/services/space_service.py
new file mode 100644
index 0000000..14acbaa
--- /dev/null
+++ b/backend/app/services/space_service.py
@@ -0,0 +1,104 @@
+"""Space (tenant) service for multi-subdomain support."""
+
+from pathlib import Path
+
+import yaml
+from pydantic import BaseModel
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+class SpaceTheme(BaseModel):
+ """Theme configuration for a space."""
+
+ primary: str = "195 80% 45%"
+ primary_foreground: str = "0 0% 100%"
+ secondary: str = "45 80% 55%"
+ secondary_foreground: str = "222.2 47.4% 11.2%"
+ background: str = "0 0% 100%"
+ foreground: str = "222.2 84% 4.9%"
+ card: str = "0 0% 100%"
+ card_foreground: str = "222.2 84% 4.9%"
+ popover: str = "0 0% 100%"
+ popover_foreground: str = "222.2 84% 4.9%"
+ muted: str = "210 40% 96.1%"
+ muted_foreground: str = "215.4 16.3% 46.9%"
+ accent: str = "210 40% 96.1%"
+ accent_foreground: str = "222.2 47.4% 11.2%"
+ destructive: str = "0 84.2% 60.2%"
+ destructive_foreground: str = "210 40% 98%"
+ border: str = "214.3 31.8% 91.4%"
+ input: str = "214.3 31.8% 91.4%"
+ ring: str = "195 80% 45%"
+
+
+class Space(BaseModel):
+ """Space configuration."""
+
+ id: str
+ name: str
+ tagline: str = ""
+ description: str = ""
+ domain: str = ""
+ footer_text: str = ""
+ theme: SpaceTheme = SpaceTheme()
+ design_filter: str = "all"
+ logo_url: str | None = None
+ design_tips: list[str] = []
+
+
+class SpaceService:
+ """Service for loading and resolving spaces."""
+
+ def __init__(self):
+ self.spaces_path = Path(settings.spaces_path)
+ self._cache: dict[str, Space] = {}
+ self._loaded = False
+
+ def _ensure_loaded(self):
+ if self._loaded:
+ return
+ self._load_all()
+ self._loaded = True
+
+ def _load_all(self):
+ if not self.spaces_path.exists():
+ return
+ for space_dir in self.spaces_path.iterdir():
+ if not space_dir.is_dir():
+ continue
+ config_path = space_dir / "space.yaml"
+ if not config_path.exists():
+ continue
+ try:
+ with open(config_path) as f:
+ data = yaml.safe_load(f)
+ space = Space(**data)
+ self._cache[space.id] = space
+ except Exception:
+ continue
+
+ def get_space(self, space_id: str) -> Space | None:
+ """Get a space by its ID."""
+ self._ensure_loaded()
+ return self._cache.get(space_id)
+
+ def get_default(self) -> Space:
+ """Get the default space."""
+ self._ensure_loaded()
+ return self._cache.get(
+ "default",
+ Space(id="default", name="rSwag", domain="rswag.online"),
+ )
+
+ def list_spaces(self) -> list[Space]:
+ """List all spaces."""
+ self._ensure_loaded()
+ return list(self._cache.values())
+
+ def clear_cache(self):
+ """Clear the cache to force reload."""
+ self._cache.clear()
+ self._loaded = False
diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh
new file mode 100755
index 0000000..983dda3
--- /dev/null
+++ b/backend/entrypoint.sh
@@ -0,0 +1,90 @@
+#!/bin/sh
+# Infisical secret injection entrypoint (Python version)
+set -e
+
+INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
+INFISICAL_ENV="${INFISICAL_ENV:-prod}"
+INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rswag}"
+
+if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
+ echo "[infisical] No credentials set, starting without secret injection"
+ exec "$@"
+fi
+
+echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
+
+EXPORTS=$(python3 -c "
+import urllib.request, json, os, sys
+
+base = os.environ['INFISICAL_URL']
+slug = os.environ['INFISICAL_PROJECT_SLUG']
+env = os.environ['INFISICAL_ENV']
+
+try:
+ data = json.dumps({'clientId': os.environ['INFISICAL_CLIENT_ID'], 'clientSecret': os.environ['INFISICAL_CLIENT_SECRET']}).encode()
+ req = urllib.request.Request(f'{base}/api/v1/auth/universal-auth/login', data=data, headers={'Content-Type': 'application/json'})
+ auth = json.loads(urllib.request.urlopen(req).read())
+ token = auth.get('accessToken')
+ if not token:
+ print('[infisical] Auth failed', file=sys.stderr)
+ sys.exit(1)
+
+ req = urllib.request.Request(f'{base}/api/v3/secrets/raw?workspaceSlug={slug}&environment={env}&secretPath=/&recursive=true')
+ req.add_header('Authorization', f'Bearer {token}')
+ secrets = json.loads(urllib.request.urlopen(req).read())
+
+ if 'secrets' not in secrets:
+ print('[infisical] No secrets returned', file=sys.stderr)
+ sys.exit(1)
+
+ for s in secrets['secrets']:
+ key = s['secretKey']
+ val = s['secretValue'].replace(\"'\", \"'\\\\'\")
+ existing = os.environ.get(key, '')
+ if existing and existing != val:
+ print(f'[infisical] Keeping explicit env var for {key}', file=sys.stderr)
+ continue
+ print(f\"export {key}='{val}'\")
+except Exception as e:
+ print(f'[infisical] Error: {e}', file=sys.stderr)
+ sys.exit(1)
+" 2>&1) || {
+ echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
+ exec "$@"
+}
+
+if echo "$EXPORTS" | grep -q "^export "; then
+ COUNT=$(echo "$EXPORTS" | grep -c "^export ")
+ eval "$EXPORTS"
+ echo "[infisical] Injected ${COUNT} secrets"
+else
+ echo "[infisical] WARNING: $EXPORTS"
+ echo "[infisical] Starting with existing env vars"
+fi
+
+# Fetch SMTP config from claude-ops /mail (authoritative source for rSwag email)
+SMTP_OVERRIDES=$(python3 -c "
+import urllib.request, json, os, sys
+base = os.environ.get('INFISICAL_URL', 'http://infisical:8080')
+try:
+ data = json.dumps({'clientId': os.environ['INFISICAL_CLIENT_ID'], 'clientSecret': os.environ['INFISICAL_CLIENT_SECRET']}).encode()
+ req = urllib.request.Request(f'{base}/api/v1/auth/universal-auth/login', data=data, headers={'Content-Type': 'application/json'})
+ token = json.loads(urllib.request.urlopen(req).read()).get('accessToken','')
+ req = urllib.request.Request(f'{base}/api/v3/secrets/raw?workspaceSlug=claude-ops&environment=prod&secretPath=/mail')
+ req.add_header('Authorization', f'Bearer {token}')
+ secrets = json.loads(urllib.request.urlopen(req).read())
+ mapping = {'RSWAG_SMTP_HOST': 'SMTP_HOST', 'RSWAG_SMTP_USER': 'SMTP_USER', 'RSWAG_SMTP_PASSWORD': 'SMTP_PASSWORD'}
+ for s in secrets.get('secrets',[]):
+ env_key = mapping.get(s['secretKey'])
+ if env_key:
+ val = s['secretValue'].replace(\"'\", \"'\\\\'\")
+ print(f\"export {env_key}='{val}'\")
+except Exception as e:
+ print(f'[smtp] Could not fetch from claude-ops: {e}', file=sys.stderr)
+" 2>&1) || true
+if echo "$SMTP_OVERRIDES" | grep -q "^export "; then
+ eval "$SMTP_OVERRIDES"
+ echo "[infisical] Loaded SMTP config from claude-ops/mail"
+fi
+
+exec "$@"
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
new file mode 100644
index 0000000..53fcd12
--- /dev/null
+++ b/backend/pyproject.toml
@@ -0,0 +1,54 @@
+[project]
+name = "rswag"
+version = "0.1.0"
+description = "E-commerce backend for rSpace ecosystem merchandise"
+authors = [{ name = "Jeff Emmett", email = "jeff@rspace.online" }]
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "fastapi>=0.109.0",
+ "uvicorn[standard]>=0.27.0",
+ "sqlalchemy>=2.0.0",
+ "alembic>=1.13.0",
+ "asyncpg>=0.29.0",
+ "psycopg2-binary>=2.9.0",
+ "pydantic>=2.5.0",
+ "pydantic-settings>=2.1.0",
+ "python-jose[cryptography]>=3.3.0",
+ "passlib[bcrypt]>=1.7.4",
+ "httpx>=0.26.0",
+ "mollie-api-python>=3.0.0",
+ "pyyaml>=6.0.0",
+ "pillow>=10.0.0",
+ "python-multipart>=0.0.6",
+ "redis>=5.0.0",
+ "aiofiles>=23.0.0",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "pytest-asyncio>=0.23.0",
+ "httpx>=0.26.0",
+ "black>=24.0.0",
+ "ruff>=0.1.0",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["app"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "W"]
+ignore = ["E501"]
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]
diff --git a/backend/requirements.txt b/backend/requirements.txt
new file mode 100644
index 0000000..2dd93db
--- /dev/null
+++ b/backend/requirements.txt
@@ -0,0 +1,35 @@
+# Core
+fastapi>=0.109.0
+uvicorn[standard]>=0.27.0
+
+# Database
+sqlalchemy>=2.0.0
+alembic>=1.13.0
+asyncpg>=0.29.0
+psycopg2-binary>=2.9.0
+
+# Validation
+pydantic>=2.5.0
+pydantic-settings>=2.1.0
+
+# Auth
+python-jose[cryptography]>=3.3.0
+passlib[bcrypt]>=1.7.4
+
+# HTTP Client
+httpx>=0.26.0
+
+# Payments (Mollie)
+mollie-api-python>=3.0.0
+
+# Config & Utils
+pyyaml>=6.0.0
+pillow>=10.0.0
+python-multipart>=0.0.6
+aiofiles>=23.0.0
+
+# Email
+aiosmtplib>=3.0.0
+
+# Cache
+redis>=5.0.0
diff --git a/backlog/config.yml b/backlog/config.yml
new file mode 100644
index 0000000..2f83faf
--- /dev/null
+++ b/backlog/config.yml
@@ -0,0 +1,16 @@
+project_name: "rSwag"
+default_status: "To Do"
+statuses: ["To Do", "In Progress", "Done"]
+labels: []
+milestones: []
+date_format: yyyy-mm-dd
+max_column_width: 20
+default_editor: "nvim"
+auto_open_browser: true
+default_port: 6420
+remote_operations: true
+auto_commit: false
+bypass_git_hooks: false
+check_active_branches: true
+active_branch_days: 30
+task_prefix: "task"
diff --git a/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md b/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md
new file mode 100644
index 0000000..cb5c5c8
--- /dev/null
+++ b/backlog/tasks/task-1 - Configure-Mollie-API-key-for-production-payments.md
@@ -0,0 +1,16 @@
+---
+id: TASK-1
+title: Configure Mollie API key for production payments
+status: To Do
+assignee: []
+created_date: '2026-02-18 19:51'
+labels: []
+dependencies: []
+priority: high
+---
+
+## Description
+
+
+Sign up at my.mollie.com, get live API key, add MOLLIE_API_KEY to /opt/apps/rswag/.env on Netcup. Configure webhook URL in Mollie dashboard pointing to https://rswag.online/api/webhooks/mollie (or fungiswag.jeffemmett.com equivalent). Test mode key starts with test_, live key starts with live_.
+
diff --git a/backlog/tasks/task-10 - Consistent-rApp-header-bar-with-AppSwitcher-SpaceSwitcher.md b/backlog/tasks/task-10 - Consistent-rApp-header-bar-with-AppSwitcher-SpaceSwitcher.md
new file mode 100644
index 0000000..1bd1964
--- /dev/null
+++ b/backlog/tasks/task-10 - Consistent-rApp-header-bar-with-AppSwitcher-SpaceSwitcher.md
@@ -0,0 +1,16 @@
+---
+id: TASK-10
+title: Consistent rApp header bar with AppSwitcher + SpaceSwitcher
+status: Done
+assignee: []
+created_date: '2026-02-25 07:28'
+labels: []
+dependencies: []
+priority: high
+---
+
+## Description
+
+
+rSwag header now matches standard rApp pattern: [AppSwitcher | SpaceSwitcher | Logo ... Nav ... Auth Cart]. Removed custom rSwag-specific SpaceSwitcher, adopted the ecosystem-wide component. EcosystemFooter also standardized.
+
diff --git a/backlog/tasks/task-11 - Migrate-all-SMTP-references-to-mail.rmail.online.md b/backlog/tasks/task-11 - Migrate-all-SMTP-references-to-mail.rmail.online.md
new file mode 100644
index 0000000..c60a170
--- /dev/null
+++ b/backlog/tasks/task-11 - Migrate-all-SMTP-references-to-mail.rmail.online.md
@@ -0,0 +1,29 @@
+---
+id: TASK-11
+title: Migrate all SMTP references to mail.rmail.online
+status: Done
+assignee: []
+created_date: '2026-02-25 08:00'
+updated_date: '2026-02-25 08:25'
+labels: [infrastructure, email]
+dependencies: [TASK-6]
+priority: medium
+---
+
+## Description
+
+
+Replace all references to mx.jeffemmett.com with mail.rmail.online across all repositories. Set up rswag.online domain in Mailcow with noreply@ mailbox, DNS records, and SMTP credentials wired into rSwag backend via Infisical.
+
+
+## Implementation Notes
+
+
+Cross-repo cleanup: Updated 10 backlog/doc files across 6 repos (rinbox-online, rmail-online, dev-ops, payment-infra, cadcad-discourse-forum, configuration dotfiles). Updated live CLAUDE.md. Only 2 intentional references remain (ADDITIONAL_SAN backward compat in task-11 migration notes).
+
+Mailcow setup: Created rswag.online domain (2048-bit DKIM), noreply@rswag.online mailbox. Cloudflare DNS: MX (mail.rmail.online, priority 10), SPF (v=spf1 ip4:159.195.32.209 ~all), DKIM (dkim._domainkey), DMARC (p=quarantine).
+
+Infisical wiring: Stored RSWAG_SMTP_HOST, RSWAG_SMTP_USER, RSWAG_SMTP_PASSWORD in claude-ops /mail folder. Added rswag-container identity as viewer on claude-ops project. Entrypoint.sh fetches SMTP config from claude-ops at startup, overriding stale values from .env and rSwag Infisical project. config.py AliasChoices accepts both SMTP_PASSWORD and SMTP_PASS.
+
+Deploy: Triggered via webhook with correct HMAC secret. Container logs confirm: "[infisical] Loaded SMTP config from claude-ops/mail".
+
diff --git a/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md b/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md
new file mode 100644
index 0000000..60b41ef
--- /dev/null
+++ b/backlog/tasks/task-2 - Configure-Printful-and-Prodigi-API-keys.md
@@ -0,0 +1,23 @@
+---
+id: TASK-2
+title: Configure Printful and Prodigi API keys
+status: Done
+assignee: []
+created_date: '2026-02-18 19:51'
+updated_date: '2026-02-25 07:34'
+labels: []
+dependencies: []
+priority: high
+---
+
+## Description
+
+
+Add PRINTFUL_API_TOKEN and PRODIGI_API_KEY to .env. Currently empty — orders will be created but not submitted to POD providers. Also implement the POD client code in backend/app/pod/ to actually submit orders after Stripe payment.
+
+
+## Implementation Notes
+
+
+Printful API token configured. Prodigi API key also set up. POD client code in backend/app/pod/ submits orders after Mollie payment. Printful mockup generation working.
+
diff --git a/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md b/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md
new file mode 100644
index 0000000..964ce57
--- /dev/null
+++ b/backlog/tasks/task-3 - Replace-placeholder-Fungi-Flows-design-assets.md
@@ -0,0 +1,23 @@
+---
+id: TASK-3
+title: Replace placeholder Fungi Flows design assets
+status: Done
+assignee: []
+created_date: '2026-02-18 19:51'
+updated_date: '2026-02-25 07:28'
+labels: []
+dependencies: []
+priority: medium
+---
+
+## Description
+
+
+Current fungi-logo-tee.png and fungi-spore.png are Pillow-generated placeholders. Replace with real artwork from Darren/Fungi Flows team. Designs at designs/shirts/fungi-logo-tee/ and designs/stickers/fungi-spore/.
+
+
+## Implementation Notes
+
+
+Fungi Flows placeholder designs deleted. Only DefectFi 'Don't Abuse the Holes' design remains with real Printful products (shirt SKU 71, hoodie SKU 146).
+
diff --git a/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md b/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md
new file mode 100644
index 0000000..c29b4d0
--- /dev/null
+++ b/backlog/tasks/task-4 - Integrate-EncryptID-authentication-for-rSwag.md
@@ -0,0 +1,23 @@
+---
+id: TASK-4
+title: Integrate EncryptID authentication for rSwag
+status: Done
+assignee: []
+created_date: '2026-02-18 19:51'
+updated_date: '2026-02-25 07:28'
+labels: []
+dependencies: []
+priority: medium
+---
+
+## Description
+
+
+Replace email/password admin auth with EncryptID passkeys to be consistent with other rApps (rWork, rFiles, rNotes). Use @encryptid/sdk, WebAuthn flow, DID-based user identity, space role checking. See /home/jeffe/Github/encryptid-sdk/ and rwork-online for patterns.
+
+
+## Implementation Notes
+
+
+EncryptID auth integrated with passkey sign-in via vendored @encryptid/sdk. AuthButton + Zustand auth store matching rMaps pattern.
+
diff --git a/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md b/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md
new file mode 100644
index 0000000..0ce268c
--- /dev/null
+++ b/backlog/tasks/task-5 - Add-real-Printful-mockup-API-integration.md
@@ -0,0 +1,39 @@
+---
+id: TASK-5
+title: Add real Printful mockup API integration
+status: Done
+assignee: []
+created_date: '2026-02-18 19:51'
+updated_date: '2026-02-25 07:28'
+labels: []
+dependencies: []
+priority: high
+---
+
+## Description
+
+
+Current upload page uses client-side Canvas compositing with simple template images. When Printful API token is configured, enhance with real Printful Mockup Generator API (POST /mockup-generator/create-task) for photorealistic product previews showing actual garment colors and fabric texture.
+
+
+## Implementation Notes
+
+
+2026-02-21: Printful client code is DONE and deployed. Blocking issue: API token not scoped to store.
+
+What's done:
+- backend/app/pod/printful_client.py created (catalog, mockups, orders)
+- designs.py updated (Printful mockup path + Pillow fallback)
+- order_service.py refactored (provider-aware routing: printful vs prodigi)
+- Token stored at ~/.secrets/printful_api_token and in Netcup .env
+- Deployed to fungiswag.jeffemmett.com (Pillow fallback working)
+
+Blocking:
+- Token u5WU...R2d returns "This endpoint requires store_id" on mockup/order APIs
+- Need to create a NEW token on developers.printful.com scoped to "Fungi Flows" store
+- Select the store in the "Access" dropdown (not "Account (all stores)")
+
+Once new token is set, just update ~/.secrets/printful_api_token and Netcup .env, rebuild, done.
+
+Printful mockup API v2 integrated. Falls back to Pillow compositing with local templates. Old fungi designs removed, only defectfi-dont-abuse-holes remains.
+
diff --git a/backlog/tasks/task-6 - Add-order-confirmation-emails.md b/backlog/tasks/task-6 - Add-order-confirmation-emails.md
new file mode 100644
index 0000000..ba58a5b
--- /dev/null
+++ b/backlog/tasks/task-6 - Add-order-confirmation-emails.md
@@ -0,0 +1,25 @@
+---
+id: TASK-6
+title: Add order confirmation emails
+status: Done
+assignee: []
+created_date: '2026-02-18 19:51'
+updated_date: '2026-02-25 07:34'
+labels: []
+dependencies: []
+priority: medium
+---
+
+## Description
+
+
+OrderService has TODO for sending confirmation emails after payment. Connect to Mailcow SMTP (mail.rmail.online:587) or email-relay API. Send order confirmation with items, total, and tracking link.
+
+
+## Implementation Notes
+
+
+EmailService created with aiosmtplib. Order confirmation email sent after successful Mollie payment. Shipping notification email sent when POD provider reports shipped status with tracking info. HTML templates with rSwag dark theme branding. SMTP via Mailcow (mail.rmail.online:587 STARTTLS). Non-blocking: failures logged but don't break order flow.
+
+Mailcow setup (2026-02-25): Created rswag.online domain with 2048-bit DKIM. Created noreply@rswag.online mailbox. DNS records (MX, SPF, DKIM, DMARC) added to Cloudflare. SMTP credentials stored in claude-ops /mail folder (RSWAG_SMTP_HOST, RSWAG_SMTP_USER, RSWAG_SMTP_PASSWORD). Entrypoint fetches from claude-ops at startup, overriding stale rSwag Infisical values. config.py uses AliasChoices to accept both SMTP_PASSWORD and SMTP_PASS env var names.
+
diff --git a/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md b/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md
new file mode 100644
index 0000000..e008a47
--- /dev/null
+++ b/backlog/tasks/task-7 - Set-up-auto-deploy-webhook-for-rSwag.md
@@ -0,0 +1,16 @@
+---
+id: TASK-7
+title: Set up auto-deploy webhook for rSwag
+status: Done
+assignee: []
+created_date: '2026-02-18 19:51'
+labels: []
+dependencies: []
+priority: medium
+---
+
+## Description
+
+
+Add rswag entry to /opt/deploy-webhook/webhook.py REPOS dict and create Gitea webhook so pushes to main auto-deploy. Currently requires manual git pull + docker compose rebuild.
+
diff --git a/backlog/tasks/task-8 - Standardize-SpaceSwitcher-across-all-rApps.md b/backlog/tasks/task-8 - Standardize-SpaceSwitcher-across-all-rApps.md
new file mode 100644
index 0000000..1a06038
--- /dev/null
+++ b/backlog/tasks/task-8 - Standardize-SpaceSwitcher-across-all-rApps.md
@@ -0,0 +1,16 @@
+---
+id: TASK-8
+title: Standardize SpaceSwitcher across all rApps
+status: Done
+assignee: []
+created_date: '2026-02-25 07:28'
+labels: []
+dependencies: []
+priority: high
+---
+
+## Description
+
+
+Deploy consistent SpaceSwitcher component across all 16 rApp repos. Space dropdown links now use subdomain URLs (..online) instead of rspace.online/ paths. Domain auto-derived from window.location.hostname.
+
diff --git a/backlog/tasks/task-9 - Interactive-Sankey-fund-flow-visualization.md b/backlog/tasks/task-9 - Interactive-Sankey-fund-flow-visualization.md
new file mode 100644
index 0000000..2038541
--- /dev/null
+++ b/backlog/tasks/task-9 - Interactive-Sankey-fund-flow-visualization.md
@@ -0,0 +1,16 @@
+---
+id: TASK-9
+title: Interactive Sankey fund flow visualization
+status: Done
+assignee: []
+created_date: '2026-02-25 07:28'
+labels: []
+dependencies: []
+priority: medium
+---
+
+## Description
+
+
+Built interactive SVG Sankey diagram for rSwag landing page showing revenue flow from sale price through printer (production), creator (design margin), and community (revenue fund). Drag sliders to adjust splits dynamically. Pure React+SVG, zero dependencies.
+
diff --git a/config/products.yaml b/config/products.yaml
new file mode 100644
index 0000000..570e974
--- /dev/null
+++ b/config/products.yaml
@@ -0,0 +1,232 @@
+# rSwag Product Catalog Configuration
+# Maps design types to POD provider products
+
+defaults:
+ color_profile: sRGB
+ resolution: 300
+ format: png
+
+# Sticker Products
+stickers:
+ small:
+ name: "3×3 Vinyl Sticker"
+ dimensions:
+ width: 3
+ height: 3
+ unit: inches
+ pixels:
+ width: 900
+ height: 900
+ providers:
+ prodigi:
+ sku: "GLOBAL-STI-KIS-3X3"
+ variants:
+ - id: matte
+ name: "Matte Finish"
+ - id: gloss
+ name: "Gloss Finish"
+ base_cost: 1.20
+ printful:
+ sku: 358 # Kiss-cut stickers
+ variants:
+ - id: white
+ name: "White"
+ base_cost: 1.50
+
+ medium:
+ name: "4×4 Vinyl Sticker"
+ dimensions:
+ width: 4
+ height: 4
+ unit: inches
+ pixels:
+ width: 1200
+ height: 1200
+ providers:
+ prodigi:
+ sku: "GLOBAL-STI-KIS-4X4"
+ variants:
+ - id: matte
+ name: "Matte Finish"
+ - id: gloss
+ name: "Gloss Finish"
+ base_cost: 1.80
+
+ large:
+ name: "6×6 Vinyl Sticker"
+ dimensions:
+ width: 6
+ height: 6
+ unit: inches
+ pixels:
+ width: 1800
+ height: 1800
+ providers:
+ prodigi:
+ sku: "GLOBAL-STI-KIS-6X6"
+ base_cost: 2.50
+
+# Apparel Products
+apparel:
+ tshirt:
+ name: "Unisex T-Shirt"
+ print_areas:
+ front:
+ dimensions:
+ width: 12
+ height: 16
+ unit: inches
+ pixels:
+ width: 3600
+ height: 4800
+ chest:
+ dimensions:
+ width: 4
+ height: 4
+ unit: inches
+ pixels:
+ width: 1200
+ height: 1200
+ providers:
+ printful:
+ sku: 71 # Bella + Canvas 3001
+ sizes: [S, M, L, XL, 2XL, 3XL]
+ colors:
+ - id: black
+ name: "Black"
+ hex: "#0a0a0a"
+ - id: white
+ name: "White"
+ hex: "#ffffff"
+ - id: heather_charcoal
+ name: "Heather Charcoal"
+ hex: "#4a4a4a"
+ - id: forest_green
+ name: "Forest Green"
+ hex: "#2d4a3e"
+ - id: maroon
+ name: "Maroon"
+ hex: "#5a2d2d"
+ base_cost:
+ S: 9.25
+ M: 9.25
+ L: 9.25
+ XL: 9.25
+ 2XL: 11.25
+ 3XL: 13.25
+
+ hoodie:
+ name: "Unisex Hoodie"
+ print_areas:
+ front:
+ dimensions:
+ width: 14
+ height: 16
+ unit: inches
+ pixels:
+ width: 4200
+ height: 4800
+ providers:
+ printful:
+ sku: 146 # Bella + Canvas 3719
+ sizes: [S, M, L, XL, 2XL]
+ colors:
+ - id: black
+ name: "Black"
+ - id: dark_grey_heather
+ name: "Dark Grey Heather"
+ base_cost:
+ S: 23.95
+ M: 23.95
+ L: 23.95
+ XL: 23.95
+ 2XL: 27.95
+
+# Art Prints
+prints:
+ small:
+ name: "8×10 Art Print"
+ dimensions:
+ width: 8
+ height: 10
+ unit: inches
+ pixels:
+ width: 2400
+ height: 3000
+ providers:
+ prodigi:
+ sku: "GLOBAL-FAP-8X10"
+ variants:
+ - id: matte
+ name: "Matte"
+ - id: lustre
+ name: "Lustre"
+ base_cost: 4.50
+
+ medium:
+ name: "11×14 Art Print"
+ dimensions:
+ width: 11
+ height: 14
+ unit: inches
+ pixels:
+ width: 3300
+ height: 4200
+ providers:
+ prodigi:
+ sku: "GLOBAL-FAP-11X14"
+ base_cost: 7.00
+
+ large:
+ name: "18×24 Art Print"
+ dimensions:
+ width: 18
+ height: 24
+ unit: inches
+ pixels:
+ width: 5400
+ height: 7200
+ providers:
+ prodigi:
+ sku: "GLOBAL-FAP-18X24"
+ base_cost: 12.00
+
+# Pricing Rules
+pricing:
+ default_markup: 2.0 # 100% markup (double cost)
+
+ rules:
+ stickers:
+ markup: 2.5 # Higher margin on low-cost items
+ minimum_price: 3.00
+
+ apparel:
+ markup: 1.8
+ minimum_price: 20.00
+
+ prints:
+ markup: 2.0
+ minimum_price: 15.00
+
+ # Round to nearest .99 or .50
+ rounding: nearest_99
+
+# Shipping Profiles
+shipping:
+ prodigi:
+ standard:
+ name: "Standard"
+ days: "5-10"
+ express:
+ name: "Express"
+ days: "2-5"
+ additional_cost: 5.00
+
+ printful:
+ standard:
+ name: "Standard"
+ days: "5-12"
+ express:
+ name: "Express"
+ days: "3-5"
+ additional_cost: 7.00
diff --git a/designs/.gitkeep b/designs/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/designs/misc/.gitkeep b/designs/misc/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/designs/shirts/.gitkeep b/designs/shirts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/designs/shirts/defectfi-dont-abuse-holes/dont-abuse-the-holes.png b/designs/shirts/defectfi-dont-abuse-holes/dont-abuse-the-holes.png
new file mode 100644
index 0000000..aa2259e
Binary files /dev/null and b/designs/shirts/defectfi-dont-abuse-holes/dont-abuse-the-holes.png differ
diff --git a/designs/shirts/defectfi-dont-abuse-holes/metadata.yaml b/designs/shirts/defectfi-dont-abuse-holes/metadata.yaml
new file mode 100644
index 0000000..42bda84
--- /dev/null
+++ b/designs/shirts/defectfi-dont-abuse-holes/metadata.yaml
@@ -0,0 +1,31 @@
+name: "Don't Abuse the Holes!"
+slug: defectfi-dont-abuse-holes
+description: "Defensive bug hunting meets punk resistance. Distressed typography with beetles crawling through the design — because the best defense is finding the vulnerabilities before they find you. Revenue from this design supports the DefectFi community. #DefectFi"
+tags: [defectfi, whistleblower, bug-bounty, punk, resistance, tee, community]
+space: all
+category: shirts
+created: "2026-02-24"
+author: defectfi
+
+source:
+ file: dont-abuse-the-holes.png
+ format: png
+ dimensions:
+ width: 1743
+ height: 1786
+ dpi: 300
+ color_profile: sRGB
+
+products:
+ - type: shirt
+ provider: printful
+ sku: "71"
+ variants: [S, M, L, XL, 2XL, 3XL]
+ retail_price: 29.99
+ - type: hoodie
+ provider: printful
+ sku: "146"
+ variants: [S, M, L, XL, 2XL]
+ retail_price: 49.99
+
+status: active
diff --git a/designs/stickers/.gitkeep b/designs/stickers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/designs/uploads/.gitkeep b/designs/uploads/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
new file mode 100644
index 0000000..839933f
--- /dev/null
+++ b/docker-compose.dev.yml
@@ -0,0 +1,39 @@
+# Development overrides - use with: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
+
+services:
+ db:
+ ports:
+ - "5432:5432"
+
+ redis:
+ ports:
+ - "6379:6379"
+
+ backend:
+ volumes:
+ # Mount source for hot reload
+ - ./backend/app:/app/app:ro
+ # Mount designs from in-repo designs dir
+ - ./designs:/app/designs:ro
+ - ./config:/app/config:ro
+ - ./spaces:/app/spaces:ro
+ - ./frontend/public/mockups:/app/mockups:ro
+ environment:
+ - DEBUG=true
+ - POD_SANDBOX_MODE=true
+ ports:
+ - "8000:8000"
+ command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+
+ frontend:
+ build:
+ args:
+ - NEXT_PUBLIC_API_URL=http://localhost:8000/api
+ ports:
+ - "3000:3000"
+
+networks:
+ rswag-internal:
+ driver: bridge
+ traefik-public:
+ driver: bridge # Override external for local dev
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..afbff7a
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,22 @@
+services:
+ backend:
+ volumes:
+ - /opt/apps/rswag/designs:/app/designs
+ - /opt/apps/rswag/config:/app/config:ro
+ - /opt/apps/rswag/spaces:/app/spaces:ro
+ environment:
+ - DESIGNS_PATH=/app/designs
+ - CONFIG_PATH=/app/config
+ - SPACES_PATH=/app/spaces
+
+ frontend:
+ build:
+ args:
+ - NEXT_PUBLIC_API_URL=https://rswag.online/api
+
+networks:
+ rswag-internal:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 10.200.1.0/24
diff --git a/docker-compose.yml b/docker-compose.yml
index b437cfc..63ff96c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,20 +1,106 @@
services:
- rswag:
- build: .
+ # PostgreSQL Database
+ db:
+ image: postgres:16-alpine
+ container_name: rswag-db
restart: unless-stopped
+ environment:
+ POSTGRES_USER: rswag
+ POSTGRES_PASSWORD: ${DB_PASSWORD:-devpassword}
+ POSTGRES_DB: rswag
+ volumes:
+ - rswag-db-data:/var/lib/postgresql/data
+ networks:
+ - rswag-internal
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U rswag"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ # Redis for sessions/cache
+ redis:
+ image: redis:7-alpine
+ container_name: rswag-redis
+ restart: unless-stopped
+ volumes:
+ - rswag-redis-data:/data
+ networks:
+ - rswag-internal
+
+ # FastAPI Backend
+ backend:
+ build:
+ context: ./backend
+ dockerfile: Dockerfile
+ container_name: rswag-backend
+ restart: unless-stopped
+ environment:
+ - INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
+ - INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
+ - INFISICAL_PROJECT_SLUG=rswag
+ - INFISICAL_ENV=prod
+ - INFISICAL_URL=http://infisical:8080
+ - DATABASE_URL=postgresql://rswag:${DB_PASSWORD:-devpassword}@db:5432/rswag
+ - REDIS_URL=redis://redis:6379
+ - DESIGNS_PATH=/app/designs
+ - CONFIG_PATH=/app/config
+ - SPACES_PATH=/app/spaces
+ - PRINTFUL_STORE_ID=${PRINTFUL_STORE_ID:-}
+ - PUBLIC_URL=${PUBLIC_URL:-https://rswag.online}
+ - SMTP_HOST=${SMTP_HOST:-mail.rmail.online}
+ - SMTP_PORT=${SMTP_PORT:-587}
+ - SMTP_USER=${SMTP_USER:-noreply@rswag.online}
+ - SMTP_PASSWORD=${SMTP_PASSWORD:-}
+ - SMTP_FROM_EMAIL=${SMTP_FROM_EMAIL:-noreply@rswag.online}
+ - SMTP_FROM_NAME=${SMTP_FROM_NAME:-rSwag}
+ volumes:
+ - ./designs:/app/designs
+ - ./config:/app/config:ro
+ - ./spaces:/app/spaces:ro
+ - ./frontend/public/mockups:/app/mockups:ro
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - rswag-internal
+ - traefik-public
labels:
- "traefik.enable=true"
- - "traefik.http.routers.rswag.rule=Host(`rswag.online`) || Host(`www.rswag.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)"
- - "traefik.http.services.rswag.loadbalancer.server.port=80"
- healthcheck:
- test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 15s
+ - "traefik.http.routers.rswag-api.rule=(Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)) && PathPrefix(`/api`)"
+ - "traefik.http.routers.rswag-api.entrypoints=web"
+ - "traefik.http.services.rswag-api.loadbalancer.server.port=8000"
+ - "traefik.docker.network=traefik-public"
+
+ # Next.js Frontend
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile
+ args:
+ - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8000/api}
+ container_name: rswag-frontend
+ restart: unless-stopped
+ environment:
+ - NODE_ENV=production
+ depends_on:
+ - backend
networks:
+ - rswag-internal
- traefik-public
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.rswag-web.rule=Host(`rswag.online`) || Host(`fungiswag.jeffemmett.com`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rswag.online`)"
+ - "traefik.http.routers.rswag-web.entrypoints=web"
+ - "traefik.http.services.rswag-web.loadbalancer.server.port=3000"
+ - "traefik.docker.network=traefik-public"
+
+volumes:
+ rswag-db-data:
+ rswag-redis-data:
networks:
+ rswag-internal:
+ driver: bridge
traefik-public:
external: true
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..dec4f54
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,47 @@
+# Stage 1: Dependencies
+FROM node:20-alpine AS deps
+RUN apk add --no-cache libc6-compat
+WORKDIR /app
+
+COPY package.json pnpm-lock.yaml* ./
+COPY vendor/ ./vendor/
+RUN corepack enable pnpm && pnpm i --frozen-lockfile || npm install
+
+# Stage 2: Builder
+FROM node:20-alpine AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+ENV NEXT_TELEMETRY_DISABLED=1
+
+ARG NEXT_PUBLIC_API_URL
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+
+# Ensure public directory exists
+RUN mkdir -p public
+
+RUN npm run build
+
+# Stage 3: Runner
+FROM node:20-alpine AS runner
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV NEXT_TELEMETRY_DISABLED=1
+
+RUN addgroup --system --gid 1001 nodejs && \
+ adduser --system --uid 1001 nextjs
+
+COPY --from=builder /app/public ./public
+COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
+COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+
+USER nextjs
+
+EXPOSE 3000
+
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+CMD ["node", "server.js"]
diff --git a/frontend/app/cart/page.tsx b/frontend/app/cart/page.tsx
new file mode 100644
index 0000000..7e92cd6
--- /dev/null
+++ b/frontend/app/cart/page.tsx
@@ -0,0 +1,296 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Link from "next/link";
+import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+interface CartItem {
+ id: string;
+ product_slug: string;
+ product_name: string;
+ variant_sku: string;
+ variant_name: string | null;
+ quantity: number;
+ unit_price: number;
+ subtotal: number;
+}
+
+interface Cart {
+ id: string;
+ items: CartItem[];
+ item_count: number;
+ subtotal: number;
+}
+
+export default function CartPage() {
+ const [cart, setCart] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [checkingOut, setCheckingOut] = useState(false);
+ const [updating, setUpdating] = useState(null);
+
+ const fetchCart = async () => {
+ const cartKey = getCartKey(getSpaceIdFromCookie());
+ const cartId = localStorage.getItem(cartKey);
+ if (cartId) {
+ try {
+ const res = await fetch(`${API_URL}/cart/${cartId}`);
+ if (res.ok) {
+ const data = await res.json();
+ setCart(data);
+ } else {
+ // Cart expired or deleted
+ localStorage.removeItem(cartKey);
+ setCart(null);
+ }
+ } catch {
+ setCart(null);
+ }
+ }
+ setLoading(false);
+ };
+
+ useEffect(() => {
+ fetchCart();
+ }, []);
+
+ const updateQuantity = async (itemId: string, newQuantity: number) => {
+ if (!cart || newQuantity < 1) return;
+
+ setUpdating(itemId);
+ try {
+ const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ quantity: newQuantity }),
+ });
+
+ if (res.ok) {
+ const updatedCart = await res.json();
+ setCart(updatedCart);
+ }
+ } catch {
+ console.error("Failed to update quantity");
+ } finally {
+ setUpdating(null);
+ }
+ };
+
+ const removeItem = async (itemId: string) => {
+ if (!cart) return;
+
+ setUpdating(itemId);
+ try {
+ const res = await fetch(`${API_URL}/cart/${cart.id}/items/${itemId}`, {
+ method: "DELETE",
+ });
+
+ if (res.ok) {
+ const updatedCart = await res.json();
+ setCart(updatedCart);
+ }
+ } catch {
+ console.error("Failed to remove item");
+ } finally {
+ setUpdating(null);
+ }
+ };
+
+ const handleCheckout = async () => {
+ if (!cart) return;
+ setCheckingOut(true);
+
+ try {
+ const res = await fetch(`${API_URL}/checkout/session`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ cart_id: cart.id,
+ success_url: `${window.location.origin}/checkout/success`,
+ cancel_url: `${window.location.origin}/cart`,
+ }),
+ });
+
+ if (res.ok) {
+ const { checkout_url } = await res.json();
+ window.location.href = checkout_url;
+ } else {
+ const data = await res.json();
+ alert(data.detail || "Failed to start checkout");
+ }
+ } catch (error) {
+ console.error("Checkout error:", error);
+ alert("Failed to start checkout");
+ } finally {
+ setCheckingOut(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!cart || cart.items.length === 0) {
+ return (
+
+
Your Cart
+
Your cart is empty.
+
+ Continue Shopping
+
+
+ );
+ }
+
+ return (
+
+
Your Cart
+
+
+
+ {cart.items.map((item) => (
+
+ {/* Product Image */}
+
+
+
+
+
+
+ {/* Product Info */}
+
+
+ {item.product_name}
+
+ {item.variant_name && (
+
+ {item.variant_name}
+
+ )}
+
+ ${item.unit_price.toFixed(2)} each
+
+
+ {/* Quantity Controls */}
+
+ updateQuantity(item.id, item.quantity - 1)}
+ disabled={updating === item.id || item.quantity <= 1}
+ className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
+ >
+ -
+
+ {item.quantity}
+ updateQuantity(item.id, item.quantity + 1)}
+ disabled={updating === item.id}
+ className="w-8 h-8 rounded border flex items-center justify-center hover:bg-muted transition-colors disabled:opacity-50"
+ >
+ +
+
+ removeItem(item.id)}
+ disabled={updating === item.id}
+ className="ml-4 text-sm text-red-600 hover:text-red-700 transition-colors disabled:opacity-50"
+ >
+ Remove
+
+
+
+
+ {/* Subtotal */}
+
+
${item.subtotal.toFixed(2)}
+
+
+ ))}
+
+
+ {/* Order Summary */}
+
+
+
Order Summary
+
+
+
+ Subtotal ({cart.item_count} item{cart.item_count !== 1 ? "s" : ""})
+
+ ${cart.subtotal.toFixed(2)}
+
+
+ Shipping
+ Calculated at checkout
+
+
+
+
+ Total
+ ${cart.subtotal.toFixed(2)}
+
+
+
+ {checkingOut ? (
+
+
+
+
+
+ Processing...
+
+ ) : (
+ "Proceed to Checkout"
+ )}
+
+
+ Continue Shopping
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/checkout/success/page.tsx b/frontend/app/checkout/success/page.tsx
new file mode 100644
index 0000000..991ad41
--- /dev/null
+++ b/frontend/app/checkout/success/page.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect } from "react";
+import Link from "next/link";
+import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
+
+export default function CheckoutSuccessPage() {
+ useEffect(() => {
+ // Clear cart after successful payment
+ const cartKey = getCartKey(getSpaceIdFromCookie());
+ localStorage.removeItem(cartKey);
+ }, []);
+
+ return (
+
+
+
+
Order Confirmed!
+
+ Thank you for your purchase. Your order is being processed and
+ you'll receive a confirmation email shortly.
+
+
+ Continue Shopping
+
+
+
+ );
+}
diff --git a/frontend/app/design/page.tsx b/frontend/app/design/page.tsx
new file mode 100644
index 0000000..b74d420
--- /dev/null
+++ b/frontend/app/design/page.tsx
@@ -0,0 +1,338 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Link from "next/link";
+import { getSpaceIdFromCookie } from "@/lib/spaces";
+import type { SpaceConfig } from "@/lib/spaces";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+interface GeneratedDesign {
+ slug: string;
+ name: string;
+ image_url: string;
+ status: string;
+}
+
+export default function DesignPage() {
+ const [name, setName] = useState("");
+ const [concept, setConcept] = useState("");
+ const [tags, setTags] = useState("");
+ const [isGenerating, setIsGenerating] = useState(false);
+ const [error, setError] = useState(null);
+ const [generatedDesign, setGeneratedDesign] = useState(null);
+ const [isActivating, setIsActivating] = useState(false);
+ const [spaceConfig, setSpaceConfig] = useState(null);
+
+ useEffect(() => {
+ const spaceId = getSpaceIdFromCookie();
+ fetch(`${API_URL}/spaces/${spaceId}`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then(setSpaceConfig)
+ .catch(() => {});
+ }, []);
+
+ const handleGenerate = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsGenerating(true);
+ setError(null);
+ setGeneratedDesign(null);
+
+ try {
+ const response = await fetch(`${API_URL}/design/generate`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ name,
+ concept,
+ tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
+ product_type: "sticker",
+ space: getSpaceIdFromCookie(),
+ }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || "Failed to generate design");
+ }
+
+ const design = await response.json();
+ setGeneratedDesign(design);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ } finally {
+ setIsGenerating(false);
+ }
+ };
+
+ const handleActivate = async () => {
+ if (!generatedDesign) return;
+
+ setIsActivating(true);
+ setError(null);
+
+ try {
+ const response = await fetch(
+ `${API_URL}/design/${generatedDesign.slug}/activate`,
+ {
+ method: "POST",
+ }
+ );
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || "Failed to activate design");
+ }
+
+ setGeneratedDesign({ ...generatedDesign, status: "active" });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ } finally {
+ setIsActivating(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!generatedDesign) return;
+
+ try {
+ const response = await fetch(
+ `${API_URL}/design/${generatedDesign.slug}`,
+ {
+ method: "DELETE",
+ }
+ );
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || "Failed to delete design");
+ }
+
+ setGeneratedDesign(null);
+ setName("");
+ setConcept("");
+ setTags("");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ }
+ };
+
+ return (
+
+
+
Design Swag
+
+ Create custom {spaceConfig?.name || "rSpace"} merchandise using AI. Describe your vision and
+ we'll generate a unique design.
+
+
+
+ {/* Form */}
+
+
+ {/* Preview */}
+
+
Preview
+
+ {generatedDesign ? (
+
+ ) : isGenerating ? (
+
+
+
+
+
+
Creating your design...
+
This may take a moment
+
+ ) : (
+
+ Your design will appear here
+
+ )}
+
+
+ {generatedDesign && (
+
+
+
{generatedDesign.name}
+
+ Status:{" "}
+
+ {generatedDesign.status}
+
+
+
+
+
+ {generatedDesign.status === "draft" ? (
+ <>
+
+ {isActivating ? "Activating..." : "Add to Store"}
+
+
+ Discard
+
+ >
+ ) : (
+
+ View in Store
+
+ )}
+
+
+ )}
+
+
+
+ {/* Tips */}
+
+
Design Tips
+
+ {(spaceConfig?.design_tips || [
+ "Be specific about text you want included",
+ "Mention colors, mood, and style preferences",
+ "Generated designs start as drafts — preview before adding to the store",
+ ]).map((tip, i) => (
+ • {tip}
+ ))}
+
+
+
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
new file mode 100644
index 0000000..eeb5cf6
--- /dev/null
+++ b/frontend/app/globals.css
@@ -0,0 +1,37 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 222 30% 6%;
+ --foreground: 180 10% 96%;
+ --card: 222 25% 8%;
+ --card-foreground: 180 10% 96%;
+ --popover: 222 25% 8%;
+ --popover-foreground: 180 10% 96%;
+ --primary: 195 80% 50%;
+ --primary-foreground: 0 0% 100%;
+ --secondary: 45 80% 55%;
+ --secondary-foreground: 180 10% 96%;
+ --muted: 222 20% 14%;
+ --muted-foreground: 215 20% 65%;
+ --accent: 222 20% 14%;
+ --accent-foreground: 180 10% 96%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 222 20% 16%;
+ --input: 222 20% 16%;
+ --ring: 195 80% 50%;
+ --radius: 0.625rem;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..1c03dc6
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,73 @@
+import type { Metadata } from "next";
+import { GeistSans } from "geist/font";
+import { cookies } from "next/headers";
+import "./globals.css";
+import type { SpaceConfig } from "@/lib/spaces";
+import { themeToCSS } from "@/lib/spaces";
+import { HeaderBar } from "@/components/HeaderBar";
+import { EcosystemFooter } from "@/components/EcosystemFooter";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+async function getSpaceConfig(spaceId: string): Promise {
+ try {
+ const res = await fetch(`${API_URL}/spaces/${spaceId}`, {
+ next: { revalidate: 300 },
+ });
+ if (res.ok) return res.json();
+ } catch {}
+ return null;
+}
+
+export async function generateMetadata(): Promise {
+ const cookieStore = await cookies();
+ const spaceId = cookieStore.get("space_id")?.value || "default";
+ const space = await getSpaceConfig(spaceId);
+
+ const name = space?.name || "rSwag";
+ const tagline = space?.tagline || "Merch for the rSpace Ecosystem";
+ return {
+ title: `${name} — ${tagline}`,
+ description: space?.description || "Design and order custom merchandise.",
+ };
+}
+
+
+export default async function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const cookieStore = await cookies();
+ const spaceId = cookieStore.get("space_id")?.value || "default";
+ const space = await getSpaceConfig(spaceId);
+
+ const name = space?.name || "rSwag";
+ const logoUrl = space?.logo_url;
+ const themeCSS = space?.theme ? themeToCSS(space.theme) : "";
+
+ return (
+
+
+ {themeCSS && (
+
+ )}
+
+
+
+
+
+ {/* ── Main Content ────────────────────────────────── */}
+ {children}
+
+ {/* ── Ecosystem Footer ────────────────────────────── */}
+
+
+
+
+ );
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..be943b7
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,565 @@
+import Link from "next/link";
+import { cookies } from "next/headers";
+import type { SpaceConfig } from "@/lib/spaces";
+import { RevenueFlowSankey } from "@/components/RevenueFlowSankey";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+interface Product {
+ slug: string;
+ name: string;
+ description: string;
+ category: string;
+ product_type: string;
+ image_url: string;
+ base_price: number;
+}
+
+async function getProducts(spaceId: string): Promise {
+ try {
+ const params = new URLSearchParams();
+ if (spaceId && spaceId !== "default") {
+ params.set("space", spaceId);
+ }
+ const url = `${API_URL}/products${params.toString() ? `?${params}` : ""}`;
+ const res = await fetch(url, { next: { revalidate: 60 } });
+ if (!res.ok) return [];
+ return res.json();
+ } catch {
+ return [];
+ }
+}
+
+async function getSpaceConfig(spaceId: string): Promise {
+ try {
+ const res = await fetch(`${API_URL}/spaces/${spaceId}`, {
+ next: { revalidate: 300 },
+ });
+ if (res.ok) return res.json();
+ } catch {}
+ return null;
+}
+
+function getMockupType(productType: string): string {
+ if (
+ productType.includes("shirt") ||
+ productType.includes("tee") ||
+ productType.includes("hoodie")
+ )
+ return "shirt";
+ if (productType.includes("sticker")) return "sticker";
+ if (productType.includes("print")) return "print";
+ return "shirt";
+}
+
+export default async function HomePage() {
+ const cookieStore = await cookies();
+ const spaceId = cookieStore.get("space_id")?.value || "default";
+ const [products, space] = await Promise.all([
+ getProducts(spaceId),
+ getSpaceConfig(spaceId),
+ ]);
+
+ const name = space?.name || "rSwag";
+ const isCustomSpace = spaceId !== "default" && !!space;
+
+ return (
+
+ {/* ── Hero Section ──────────────────────────────────────── */}
+
+
+
+
+
+
+
+ Part of the rSpace Ecosystem
+
+
+
+ {isCustomSpace ? (
+ name
+ ) : (
+ <>
+ Get Your Community{" "}
+
+ Noticed
+
+
+ with{" "}
+ (you)
+
+ rMerch
+
+ >
+ )}
+
+
+
+ {isCustomSpace ? (
+ space?.description ||
+ "Custom merchandise for your community."
+ ) : (
+ <>
+ A self-provisioned local design & print protocol that
+ generates{" "}
+ revenue (and
+ attention!) for your community.
+ >
+ )}
+
+
+
+
+ Browse the Shop
+
+
+
+
+
+ Upload a Design
+
+
+
+
+
+ {/* ── How It Works ──────────────────────────────────────── */}
+
+
+
+ How It Works
+
+
+ From Design to Revenue in Minutes
+
+
+ Create your community's
+ merch, sell it on demand,
+ and fund your
+ community's work — no inventory, no risk.
+
+
+
+
+ {/* Step 1 */}
+
+
+
+
1. Upload or Create a Design
+
+
+ Upload your own artwork or generate a unique design with our AI
+ studio. Logos, slogans, art — anything that represents your
+ community.
+
+ Your community's identity, on merch.
+
+
+
+
+ {/* Step 2 */}
+
+
+
+
2. Pick Products & Set Prices
+
+
+ Choose from t-shirts, hoodies, stickers, posters, mugs, and more.
+ Set your markup — every dollar above cost goes directly into your
+ community's funding stream.
+
+ You set the margin, you keep the revenue.
+
+
+
+
+ {/* Step 3 */}
+
+
+
+
3. Ship Locally — Worldwide
+
+
+ Printful prints locally from the nearest fulfillment center — less
+ shipping, less carbon. Delivered to your community members anywhere
+ in the world.
+
+ Local production, global reach.
+
+
+
+
+
+
+ {/* ── Community Revenue Model — Interactive Sankey ─────── */}
+
+
+
+
+ Revenue Model
+
+
+ Merch That Funds Your Mission
+
+
+ Every purchase feeds revenue directly into your community's
+ funding streams. Drag the sliders to explore how the money flows.
+
+
+
+
+
+
+
+ {/* Key benefits row */}
+
+
+
+
100%
+
Margin to Community
+
+
+
+
+
+
+
+ {/* ── Features Grid ─────────────────────────────────────── */}
+
+
+
+ Everything Your Community Needs
+
+
+ Tools to design, sell, and ship merch that funds your collective work
+
+
+
+
+
+
+
AI Design Studio
+
+ Generate unique designs with AI or upload your own. Instant
+ photorealistic product mockups.
+
+
+
+
+
+
Community Spaces
+
+ Each community gets its own branded storefront — custom domain,
+ theme, and product catalog.
+
+
+
+
+
+
Revenue Streams
+
+ Merch revenue flows directly to your community — connect to rFunds,
+ DAOs, treasuries, or any funding channel.
+
+
+
+
+
+
Realistic Mockups
+
+ See your design on real products via Printful's mockup engine
+ before you ever commit to an order.
+
+
+
+
+
+
Local Fulfillment
+
+ Printed at the nearest Printful facility — shorter shipping
+ distances, less carbon, faster delivery.
+
+
+
+
+
+
rSpace Ecosystem
+
+ Part of the r* suite — integrates with rFunds for treasury, rVote
+ for design governance, and more.
+
+
+
+
+
+ {/* ── Featured Products ─────────────────────────────────── */}
+ {products.length > 0 && (
+
+
+
+
+ Community Merch
+
+
+ Locally produced, print-on-demand — every sale supports the
+ community.
+
+
+
+
+ {products.slice(0, 6).map((product) => (
+
+
+
+
+
+
+ {product.product_type}
+
+
+
+
+
+ {product.name}
+
+
+ {product.description}
+
+
+
+ ${product.base_price.toFixed(2)}
+
+
+ View Details →
+
+
+
+
+
+ ))}
+
+
+ {products.length > 6 && (
+
+
+ View all {products.length} products →
+
+
+ )}
+
+
+ )}
+
+ {/* ── CTA Section ───────────────────────────────────────── */}
+
+
+
+
+
+
+ Fund Your Community
+
+
+ Turn your community's identity into a revenue stream
+
+
+ Create a Space, upload your designs, and start selling merch that
+ funds your community's work. Every sale flows directly into your
+ community's funding channels.
+
+
+
+ Get Started
+
+
+
+
+
+ Browse the Shop
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/products/[slug]/page.tsx b/frontend/app/products/[slug]/page.tsx
new file mode 100644
index 0000000..b40f0c8
--- /dev/null
+++ b/frontend/app/products/[slug]/page.tsx
@@ -0,0 +1,370 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useParams } from "next/navigation";
+import Link from "next/link";
+import { getSpaceIdFromCookie, getCartKey } from "@/lib/spaces";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+interface ProductVariant {
+ name: string;
+ sku: string;
+ provider: string;
+ price: number;
+}
+
+interface Product {
+ slug: string;
+ name: string;
+ description: string;
+ category: string;
+ product_type: string;
+ tags: string[];
+ image_url: string;
+ base_price: number;
+ variants: ProductVariant[];
+ is_active: boolean;
+}
+
+const MOCKUP_TYPES = [
+ { type: "shirt", label: "T-Shirt", icon: "👕" },
+ { type: "sticker", label: "Sticker", icon: "🏷️" },
+ { type: "print", label: "Art Print", icon: "🖼️" },
+];
+
+function getMockupType(productType: string): string {
+ if (productType.includes("shirt") || productType.includes("tee") || productType.includes("hoodie")) return "shirt";
+ if (productType.includes("sticker")) return "sticker";
+ if (productType.includes("print")) return "print";
+ return "shirt";
+}
+
+export default function ProductPage() {
+ const params = useParams();
+ const slug = params.slug as string;
+
+ const [product, setProduct] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedVariant, setSelectedVariant] = useState(null);
+ const [selectedMockup, setSelectedMockup] = useState("shirt");
+ const [quantity, setQuantity] = useState(1);
+ const [addingToCart, setAddingToCart] = useState(false);
+ const [addedToCart, setAddedToCart] = useState(false);
+ const [imageLoading, setImageLoading] = useState(true);
+
+ useEffect(() => {
+ async function fetchProduct() {
+ try {
+ const res = await fetch(`${API_URL}/products/${slug}`);
+ if (!res.ok) {
+ setError(res.status === 404 ? "Product not found" : "Failed to load product");
+ return;
+ }
+ const data = await res.json();
+ setProduct(data);
+ if (data.variants?.length > 0) {
+ setSelectedVariant(data.variants[0]);
+ }
+ setSelectedMockup(getMockupType(data.product_type));
+ } catch {
+ setError("Failed to load product");
+ } finally {
+ setLoading(false);
+ }
+ }
+ if (slug) fetchProduct();
+ }, [slug]);
+
+ const getOrCreateCart = async (): Promise => {
+ let cartId = localStorage.getItem(getCartKey(getSpaceIdFromCookie()));
+ if (cartId) {
+ try {
+ const res = await fetch(`${API_URL}/cart/${cartId}`);
+ if (res.ok) return cartId;
+ } catch { /* cart expired */ }
+ }
+ try {
+ const res = await fetch(`${API_URL}/cart`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+ if (res.ok) {
+ const data = await res.json();
+ cartId = data.id;
+ localStorage.setItem(getCartKey(getSpaceIdFromCookie()), cartId!);
+ return cartId;
+ }
+ } catch { return null; }
+ return null;
+ };
+
+ const handleAddToCart = async () => {
+ if (!product || !selectedVariant) return;
+ setAddingToCart(true);
+ try {
+ const cartId = await getOrCreateCart();
+ if (!cartId) { alert("Failed to create cart"); return; }
+
+ const res = await fetch(`${API_URL}/cart/${cartId}/items`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ product_slug: product.slug,
+ variant_sku: selectedVariant.sku,
+ quantity,
+ }),
+ });
+
+ if (res.ok) {
+ setAddedToCart(true);
+ setTimeout(() => setAddedToCart(false), 3000);
+ } else {
+ const data = await res.json();
+ alert(data.detail || "Failed to add to cart");
+ }
+ } catch { alert("Failed to add to cart"); }
+ finally { setAddingToCart(false); }
+ };
+
+ if (loading) {
+ return (
+
+
+
+ {/* Image skeleton */}
+
+ {/* Content skeleton */}
+
+
+
+
+ );
+ }
+
+ if (error || !product) {
+ return (
+
+
+
{error || "Product not found"}
+ Back to Products
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Breadcrumb */}
+
+ Home
+ /
+ Products
+ /
+ {product.name}
+
+
+
+ {/* Product Image Section */}
+
+ {/* Main mockup image */}
+
+ {imageLoading && (
+
+ )}
+
setImageLoading(false)}
+ onError={() => setImageLoading(false)}
+ />
+
+
+ {/* Mockup type switcher */}
+
+ {MOCKUP_TYPES.map((mt) => (
+ {
+ setSelectedMockup(mt.type);
+ setImageLoading(true);
+ }}
+ className={`flex-1 py-2.5 px-3 rounded-lg border text-sm font-medium transition-all duration-200 ${
+ selectedMockup === mt.type
+ ? "border-primary bg-primary/10 text-primary shadow-sm"
+ : "border-border hover:border-primary/50 text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ {mt.icon}
+ {mt.label}
+
+ ))}
+
+
+ {/* Raw design preview */}
+
+
Original Design
+
+
+
+
+
+
+ {/* Product Details Section */}
+
+
+
+ {product.category} — {product.product_type}
+
+
+
+
{product.name}
+
{product.description}
+
+
+ ${selectedVariant?.price.toFixed(2) || product.base_price.toFixed(2)}
+ + shipping
+
+
+ {/* Variant Selection */}
+ {product.variants && product.variants.length > 1 && (
+
+
Size
+
+ {product.variants.map((variant) => (
+ setSelectedVariant(variant)}
+ className={`min-w-[3.5rem] px-4 py-2.5 rounded-lg border text-sm font-medium transition-all duration-200 ${
+ selectedVariant?.sku === variant.sku
+ ? "border-primary bg-primary text-primary-foreground shadow-sm"
+ : "border-border hover:border-primary/50 text-foreground"
+ }`}
+ >
+ {variant.name.replace(/ \(printful\)$/i, "")}
+
+ ))}
+
+
+ )}
+
+ {/* Quantity */}
+
+
Quantity
+
+ setQuantity(Math.max(1, quantity - 1))}
+ className="w-12 h-12 flex items-center justify-center hover:bg-muted transition-colors text-lg font-medium"
+ >
+ −
+
+
+ {quantity}
+
+ setQuantity(quantity + 1)}
+ className="w-12 h-12 flex items-center justify-center hover:bg-muted transition-colors text-lg font-medium"
+ >
+ +
+
+
+
+
+ {/* Add to Cart */}
+
+ {addingToCart ? (
+
+
+
+
+
+ Adding to cart...
+
+ ) : addedToCart ? (
+
+
+
+
+ Added to Cart!
+
+ ) : (
+ `Add to Cart — $${((selectedVariant?.price || product.base_price) * quantity).toFixed(2)}`
+ )}
+
+
+ {addedToCart && (
+
+ View Cart →
+
+ )}
+
+ {/* Product info */}
+
+
+
+
+
+
Printed and shipped by Printful. Fulfilled on demand — no waste.
+
+
+
+
+
+
Standard shipping: 5–12 business days. Express available at checkout.
+
+
+
+
+
+
Bella + Canvas 3001 — premium 100% combed cotton, retail fit.
+
+
+
+ {/* Tags */}
+ {product.tags?.length > 0 && (
+
+
+ {product.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend/app/products/page.tsx b/frontend/app/products/page.tsx
new file mode 100644
index 0000000..38b645b
--- /dev/null
+++ b/frontend/app/products/page.tsx
@@ -0,0 +1,121 @@
+import Link from "next/link";
+import { cookies } from "next/headers";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+interface Product {
+ slug: string;
+ name: string;
+ description: string;
+ category: string;
+ product_type: string;
+ image_url: string;
+ base_price: number;
+}
+
+function getMockupType(productType: string): string {
+ if (productType.includes("shirt") || productType.includes("tee") || productType.includes("hoodie")) return "shirt";
+ if (productType.includes("sticker")) return "sticker";
+ if (productType.includes("print")) return "print";
+ return "shirt";
+}
+
+async function getProducts(spaceId: string): Promise {
+ try {
+ const params = new URLSearchParams();
+ if (spaceId && spaceId !== "default") {
+ params.set("space", spaceId);
+ }
+ const url = `${API_URL}/products${params.toString() ? `?${params}` : ""}`;
+ const res = await fetch(url, { next: { revalidate: 3600 } });
+ if (!res.ok) return [];
+ return res.json();
+ } catch {
+ return [];
+ }
+}
+
+export default async function ProductsPage() {
+ const cookieStore = await cookies();
+ const spaceId = cookieStore.get("space_id")?.value || "default";
+ const products = await getProducts(spaceId);
+
+ return (
+
+
+
+
Products
+
+ Print-on-demand merch — designed by the community, fulfilled by Printful.
+
+
+
+ {products.length === 0 ? (
+
+
+
No products yet
+
+ New designs are being added. Check back soon or create your own.
+
+
+ Upload a Design
+
+
+ ) : (
+
+ {products.map((product) => (
+
+
+ {/* Product image */}
+
+
+ {/* Category badge */}
+
+
+ {product.product_type}
+
+
+
+
+ {/* Product info */}
+
+
+ {product.name}
+
+
+ {product.description}
+
+
+
+ ${product.base_price.toFixed(2)}
+
+
+ View Details →
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/upload/page.tsx b/frontend/app/upload/page.tsx
new file mode 100644
index 0000000..952fd2e
--- /dev/null
+++ b/frontend/app/upload/page.tsx
@@ -0,0 +1,470 @@
+"use client";
+
+import { useState, useEffect, useRef, useCallback } from "react";
+import Link from "next/link";
+import { getSpaceIdFromCookie } from "@/lib/spaces";
+import { MOCKUP_CONFIGS, generateMockup } from "@/lib/mockups";
+import type { SpaceConfig } from "@/lib/spaces";
+
+const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api";
+
+interface UploadedDesign {
+ slug: string;
+ name: string;
+ image_url: string;
+ status: string;
+ products: { type: string; price: number }[];
+}
+
+export default function UploadPage() {
+ const [file, setFile] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [name, setName] = useState("");
+ const [tags, setTags] = useState("");
+ const [isDragging, setIsDragging] = useState(false);
+ const [mockups, setMockups] = useState>({});
+ const [isUploading, setIsUploading] = useState(false);
+ const [error, setError] = useState(null);
+ const [uploadedDesign, setUploadedDesign] = useState(null);
+ const [isActivating, setIsActivating] = useState(false);
+ const [spaceConfig, setSpaceConfig] = useState(null);
+ const fileInputRef = useRef(null);
+
+ useEffect(() => {
+ const spaceId = getSpaceIdFromCookie();
+ fetch(`${API_URL}/spaces/${spaceId}`)
+ .then((res) => (res.ok ? res.json() : null))
+ .then(setSpaceConfig)
+ .catch(() => {});
+ }, []);
+
+ // Generate mockups when preview changes
+ useEffect(() => {
+ if (!preview) {
+ setMockups({});
+ return;
+ }
+ MOCKUP_CONFIGS.forEach(async (config) => {
+ try {
+ const result = await generateMockup(preview, config);
+ setMockups((prev) => ({ ...prev, [config.productType]: result }));
+ } catch {
+ // Template load failure — fallback handled in render
+ }
+ });
+ }, [preview]);
+
+ const handleFile = useCallback((f: File) => {
+ if (!f.type.startsWith("image/")) {
+ setError("Please select an image file (PNG, JPEG, or WebP)");
+ return;
+ }
+ if (f.size > 10 * 1024 * 1024) {
+ setError("File must be under 10 MB");
+ return;
+ }
+ setError(null);
+ setFile(f);
+ setPreview(URL.createObjectURL(f));
+ if (!name) {
+ // Auto-fill name from filename
+ const baseName = f.name.replace(/\.[^.]+$/, "").replace(/[-_]/g, " ");
+ setName(baseName.charAt(0).toUpperCase() + baseName.slice(1));
+ }
+ }, [name]);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ const droppedFile = e.dataTransfer.files[0];
+ if (droppedFile) handleFile(droppedFile);
+ },
+ [handleFile]
+ );
+
+ const handleUpload = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!file || !name) return;
+
+ setIsUploading(true);
+ setError(null);
+
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("name", name);
+ formData.append("space", getSpaceIdFromCookie());
+ if (tags) formData.append("tags", tags);
+
+ const response = await fetch(`${API_URL}/design/upload`, {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || "Upload failed");
+ }
+
+ const design = await response.json();
+ setUploadedDesign(design);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const handleActivate = async () => {
+ if (!uploadedDesign) return;
+ setIsActivating(true);
+ setError(null);
+
+ try {
+ const response = await fetch(
+ `${API_URL}/design/${uploadedDesign.slug}/activate`,
+ { method: "POST" }
+ );
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || "Failed to activate design");
+ }
+ setUploadedDesign({ ...uploadedDesign, status: "active" });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ } finally {
+ setIsActivating(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!uploadedDesign) return;
+ try {
+ const response = await fetch(
+ `${API_URL}/design/${uploadedDesign.slug}`,
+ { method: "DELETE" }
+ );
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.detail || "Failed to delete design");
+ }
+ resetForm();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ }
+ };
+
+ const resetForm = () => {
+ setFile(null);
+ setPreview(null);
+ setName("");
+ setTags("");
+ setMockups({});
+ setUploadedDesign(null);
+ setError(null);
+ };
+
+ return (
+
+
+
Upload Swag
+
+ Upload your own design and preview it on{" "}
+ {spaceConfig?.name || "rSwag"} merchandise. See how it looks on
+ shirts, stickers, and prints before ordering.
+
+
+
+ {/* Left: Upload Form */}
+
+
+
+
+ Design Name
+
+ setName(e.target.value)}
+ placeholder="e.g., My Custom Logo"
+ className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
+ required
+ disabled={isUploading || !!uploadedDesign}
+ />
+
+
+ {/* Drag & Drop Zone */}
+
+
+ Design Image
+
+
{
+ e.preventDefault();
+ setIsDragging(true);
+ }}
+ onDragLeave={() => setIsDragging(false)}
+ onDrop={handleDrop}
+ onClick={() =>
+ !uploadedDesign && fileInputRef.current?.click()
+ }
+ className={`relative border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
+ isDragging
+ ? "border-primary bg-primary/5"
+ : preview
+ ? "border-primary/50"
+ : "border-muted-foreground/30 hover:border-primary/50"
+ } ${uploadedDesign ? "pointer-events-none opacity-60" : ""}`}
+ >
+
{
+ const f = e.target.files?.[0];
+ if (f) handleFile(f);
+ }}
+ className="hidden"
+ />
+ {preview ? (
+
+
+
+ {file?.name} (
+ {((file?.size || 0) / 1024 / 1024).toFixed(1)} MB)
+
+ {!uploadedDesign && (
+
{
+ e.stopPropagation();
+ setFile(null);
+ setPreview(null);
+ setMockups({});
+ }}
+ className="text-sm text-red-500 hover:text-red-700"
+ >
+ Remove
+
+ )}
+
+ ) : (
+
+
+
+
+
+ Drag & drop your design here, or{" "}
+ browse
+
+
+ PNG, JPEG, or WebP. Max 10 MB. Min 500x500px.
+
+
+ )}
+
+
+
+
+
+ Tags (optional)
+
+ setTags(e.target.value)}
+ placeholder="logo, custom, brand"
+ className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
+ disabled={isUploading || !!uploadedDesign}
+ />
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!uploadedDesign && (
+
+ {isUploading ? (
+
+
+
+
+
+ Uploading...
+
+ ) : (
+ "Upload & Save Design"
+ )}
+
+ )}
+
+
+ {/* Post-upload actions */}
+ {uploadedDesign && (
+
+
+
{uploadedDesign.name}
+
+ Status:{" "}
+
+ {uploadedDesign.status}
+
+
+
+
+
+ {uploadedDesign.status === "draft" ? (
+ <>
+
+ {isActivating ? "Activating..." : "Add to Store"}
+
+
+ Discard
+
+ >
+ ) : (
+ <>
+
+ View in Store
+
+
+ Upload Another
+
+ >
+ )}
+
+
+ )}
+
+
+ {/* Right: Mockup Previews */}
+
+
Product Previews
+ {preview ? (
+
+ {MOCKUP_CONFIGS.map((config) => (
+
+
+ {mockups[config.productType] ? (
+
+ ) : (
+
+ )}
+
+
+ {config.label}
+
+ from ${config.price.toFixed(2)}
+
+
+
+ ))}
+
+ ) : (
+
+
+ Upload a design to see product previews
+
+
+ )}
+
+
+
+ {/* Tips */}
+
+
Upload Tips
+
+
+ • Use a high-resolution image (at least 2000x2000px for best
+ print quality)
+
+
+ • PNG with transparency works best for stickers and shirts
+
+
+ • Keep important elements centered — edges may be cropped on
+ some products
+
+
+ • Designs start as drafts — preview before adding to the
+ store
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/AppSwitcher.tsx b/frontend/components/AppSwitcher.tsx
new file mode 100644
index 0000000..2544fca
--- /dev/null
+++ b/frontend/components/AppSwitcher.tsx
@@ -0,0 +1,239 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+
+export interface AppModule {
+ id: string;
+ name: string;
+ badge: string; // favicon-style abbreviation: rS, rN, rP, etc.
+ color: string; // Tailwind bg class for the pastel badge
+ emoji: string; // function emoji shown right of title
+ description: string;
+ domain?: string;
+}
+
+const MODULES: AppModule[] = [
+ // Creating
+ { id: 'space', name: 'rSpace', badge: 'rS', color: 'bg-teal-300', emoji: '🎨', description: 'Real-time collaborative canvas', domain: 'rspace.online' },
+ { id: 'notes', name: 'rNotes', badge: 'rN', color: 'bg-amber-300', emoji: '📝', description: 'Group note-taking & knowledge capture', domain: 'rnotes.online' },
+ { id: 'pubs', name: 'rPubs', badge: 'rP', color: 'bg-rose-300', emoji: '📰', description: 'Collaborative publishing platform', domain: 'rpubs.online' },
+ // Planning
+ { id: 'cal', name: 'rCal', badge: 'rC', color: 'bg-sky-300', emoji: '📅', description: 'Collaborative scheduling & events', domain: 'rcal.online' },
+ { id: 'trips', name: 'rTrips', badge: 'rT', color: 'bg-emerald-300', emoji: '✈️', description: 'Group travel planning in real time', domain: 'rtrips.online' },
+ { id: 'maps', name: 'rMaps', badge: 'rM', color: 'bg-green-300', emoji: '🗺️', description: 'Collaborative real-time mapping', domain: 'rmaps.online' },
+ // Communicating
+ { id: 'chats', name: 'rChats', badge: 'rCh', color: 'bg-emerald-200', emoji: '💬', description: 'Real-time encrypted messaging', domain: 'rchats.online' },
+ { id: 'inbox', name: 'rInbox', badge: 'rI', color: 'bg-indigo-300', emoji: '📬', description: 'Private group messaging', domain: 'rinbox.online' },
+ { id: 'mail', name: 'rMail', badge: 'rMa', color: 'bg-blue-200', emoji: '✉️', description: 'Community email & newsletters', domain: 'rmail.online' },
+ { id: 'forum', name: 'rForum', badge: 'rFo', color: 'bg-amber-200', emoji: '💭', description: 'Threaded community discussions', domain: 'rforum.online' },
+ // Deciding
+ { id: 'choices', name: 'rChoices', badge: 'rCo', color: 'bg-fuchsia-300', emoji: '🔀', description: 'Collaborative decision making', domain: 'rchoices.online' },
+ { id: 'vote', name: 'rVote', badge: 'rV', color: 'bg-violet-300', emoji: '🗳️', description: 'Real-time polls & governance', domain: 'rvote.online' },
+ // Funding & Commerce
+ { id: 'funds', name: 'rFunds', badge: 'rF', color: 'bg-lime-300', emoji: '💸', description: 'Collaborative fundraising & grants', domain: 'rfunds.online' },
+ { id: 'wallet', name: 'rWallet', badge: 'rW', color: 'bg-yellow-300', emoji: '💰', description: 'Multi-chain crypto wallet', domain: 'rwallet.online' },
+ { id: 'cart', name: 'rCart', badge: 'rCt', color: 'bg-orange-300', emoji: '🛒', description: 'Group commerce & shared shopping', domain: 'rcart.online' },
+ { id: 'auctions', name: 'rAuctions', badge: 'rA', color: 'bg-red-300', emoji: '🔨', description: 'Live auction platform', domain: 'rauctions.online' },
+ { id: 'swag', name: 'rSwag', badge: 'rSw', color: 'bg-red-200', emoji: '👕', description: 'Community merch & swag store', domain: 'rswag.online' },
+ // Social & Media
+ { id: 'photos', name: 'rPhotos', badge: 'rPh', color: 'bg-pink-200', emoji: '📸', description: 'Shared community photo albums', domain: 'rphotos.online' },
+ { id: 'tube', name: 'rTube', badge: 'rTu', color: 'bg-pink-300', emoji: '🎬', description: 'Group video platform', domain: 'rtube.online' },
+ { id: 'network', name: 'rNetwork', badge: 'rNe', color: 'bg-blue-300', emoji: '🌐', description: 'Community network & social graph', domain: 'rnetwork.online' },
+ { id: 'socials', name: 'rSocials', badge: 'rSo', color: 'bg-sky-200', emoji: '📢', description: 'Social media management', domain: 'rsocials.online' },
+ { id: 'files', name: 'rFiles', badge: 'rFi', color: 'bg-cyan-300', emoji: '📁', description: 'Collaborative file storage', domain: 'rfiles.online' },
+ { id: 'data', name: 'rData', badge: 'rD', color: 'bg-purple-300', emoji: '📊', description: 'Analytics & insights dashboard', domain: 'rdata.online' },
+ // Work & Productivity
+ { id: 'work', name: 'rWork', badge: 'rWo', color: 'bg-slate-300', emoji: '💼', description: 'Project & task management', domain: 'rwork.online' },
+ // Identity & Infrastructure
+ { id: 'ids', name: 'rIDs', badge: 'rId', color: 'bg-emerald-300', emoji: '🔑', description: 'Passkey identity & zero-knowledge auth', domain: 'ridentity.online' },
+ { id: 'stack', name: 'rStack', badge: 'r*', color: 'bg-gradient-to-br from-cyan-300 via-violet-300 to-rose-300', emoji: '📦', description: 'Open-source community infrastructure', domain: 'rstack.online' },
+];
+
+const MODULE_CATEGORIES: Record = {
+ space: 'Creating',
+ notes: 'Creating',
+ pubs: 'Creating',
+ cal: 'Planning',
+ trips: 'Planning',
+ maps: 'Planning',
+ chats: 'Communicating',
+ inbox: 'Communicating',
+ mail: 'Communicating',
+ forum: 'Communicating',
+ choices: 'Deciding',
+ vote: 'Deciding',
+ funds: 'Funding & Commerce',
+ wallet: 'Funding & Commerce',
+ cart: 'Funding & Commerce',
+ auctions: 'Funding & Commerce',
+ swag: 'Funding & Commerce',
+ photos: 'Social & Media',
+ tube: 'Social & Media',
+ network: 'Social & Media',
+ socials: 'Social & Media',
+ files: 'Social & Media',
+ data: 'Social & Media',
+ work: 'Work & Productivity',
+ ids: 'Identity & Infrastructure',
+ stack: 'Identity & Infrastructure',
+};
+
+const CATEGORY_ORDER = [
+ 'Creating',
+ 'Planning',
+ 'Communicating',
+ 'Deciding',
+ 'Funding & Commerce',
+ 'Social & Media',
+ 'Work & Productivity',
+ 'Identity & Infrastructure',
+];
+
+/** Build the URL for a module, using username subdomain if logged in */
+function getModuleUrl(m: AppModule, username: string | null): string {
+ if (!m.domain) return '#';
+ if (username) {
+ // Generate . URL
+ return `https://${username}.${m.domain}`;
+ }
+ return `https://${m.domain}`;
+}
+
+interface AppSwitcherProps {
+ current?: string;
+}
+
+export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
+ const [open, setOpen] = useState(false);
+ const [username, setUsername] = useState(null);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener('click', handleClick);
+ return () => document.removeEventListener('click', handleClick);
+ }, []);
+
+ // Fetch current user's username for subdomain links
+ useEffect(() => {
+ fetch('/api/me')
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.authenticated && data.user?.username) {
+ setUsername(data.user.username);
+ }
+ })
+ .catch(() => { /* not logged in */ });
+ }, []);
+
+ const currentMod = MODULES.find((m) => m.id === current);
+
+ // Group modules by category
+ const groups = new Map();
+ for (const m of MODULES) {
+ const cat = MODULE_CATEGORIES[m.id] || 'Other';
+ if (!groups.has(cat)) groups.set(cat, []);
+ groups.get(cat)!.push(m);
+ }
+
+ return (
+
+ {/* Trigger button */}
+
{ e.stopPropagation(); setOpen(!open); }}
+ className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm font-semibold bg-white/[0.08] hover:bg-white/[0.12] text-slate-200 transition-colors"
+ >
+ {currentMod && (
+
+ {currentMod.badge}
+
+ )}
+ {currentMod?.name || 'rStack'}
+ ▾
+
+
+ {/* Dropdown */}
+ {open && (
+
+ {/* rStack header */}
+
+
+ r*
+
+
+
rStack
+
Self-hosted community app suite
+
+
+
+ {/* Categories */}
+ {CATEGORY_ORDER.map((cat) => {
+ const items = groups.get(cat);
+ if (!items || items.length === 0) return null;
+ return (
+
+
+ {cat}
+
+ {items.map((m) => (
+
+ ))}
+
+ );
+ })}
+
+ {/* Footer */}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/components/AuthButton.tsx b/frontend/components/AuthButton.tsx
new file mode 100644
index 0000000..07b3c1b
--- /dev/null
+++ b/frontend/components/AuthButton.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { useState } from 'react';
+import { useAuthStore } from '@/stores/auth';
+
+export function AuthButton() {
+ const { isAuthenticated, username, did, loading, login, register, logout } = useAuthStore();
+ const [showRegister, setShowRegister] = useState(false);
+ const [regUsername, setRegUsername] = useState('');
+ const [error, setError] = useState('');
+
+ if (isAuthenticated) {
+ return (
+
+
+ Signed in as
+ {username || did?.slice(0, 16) + '...'}
+
+
+ Sign out
+
+
+ );
+ }
+
+ if (showRegister) {
+ return (
+
+ setRegUsername(e.target.value)}
+ placeholder="Choose a username"
+ className="text-sm py-1 px-2 w-36 rounded-md bg-white/10 border border-white/10 text-white placeholder:text-white/30 focus:outline-none focus:ring-1 focus:ring-primary"
+ maxLength={20}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && regUsername.trim()) {
+ setError('');
+ register(regUsername.trim()).catch((err: Error) => {
+ setError(err.message || 'Registration failed');
+ });
+ }
+ }}
+ />
+ {
+ if (!regUsername.trim()) return;
+ setError('');
+ try {
+ await register(regUsername.trim());
+ } catch (e: unknown) {
+ setError(e instanceof Error ? e.message : 'Registration failed');
+ }
+ }}
+ disabled={loading || !regUsername.trim()}
+ className="text-sm py-1 px-3 bg-primary text-primary-foreground rounded-md font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
+ >
+ {loading ? '...' : 'Register'}
+
+ setShowRegister(false)}
+ className="text-xs text-white/40 hover:text-white/60"
+ >
+ Cancel
+
+ {error && {error} }
+
+ );
+ }
+
+ return (
+
+
{
+ setError('');
+ try {
+ await login();
+ } catch (e: unknown) {
+ if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'SecurityError' || e.name === 'AbortError')) {
+ setShowRegister(true);
+ } else {
+ setError(e instanceof Error ? e.message : 'Sign in failed');
+ }
+ }
+ }}
+ disabled={loading}
+ className="text-sm text-white/60 hover:text-primary transition-colors flex items-center gap-1.5"
+ >
+
+
+
+
+
+
+ {loading ? 'Signing in...' : 'Sign in with Passkey'}
+
+ {error &&
{error} }
+
+ );
+}
diff --git a/frontend/components/EcosystemFooter.tsx b/frontend/components/EcosystemFooter.tsx
new file mode 100644
index 0000000..ac513c6
--- /dev/null
+++ b/frontend/components/EcosystemFooter.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+const FOOTER_LINKS = [
+ { name: 'rSpace', href: 'https://rspace.online' },
+ { name: 'rNotes', href: 'https://rnotes.online' },
+ { name: 'rPubs', href: 'https://rpubs.online' },
+ { name: 'rCal', href: 'https://rcal.online' },
+ { name: 'rTrips', href: 'https://rtrips.online' },
+ { name: 'rMaps', href: 'https://rmaps.online' },
+ { name: 'rChats', href: 'https://rchats.online' },
+ { name: 'rInbox', href: 'https://rinbox.online' },
+ { name: 'rMail', href: 'https://rmail.online' },
+ { name: 'rForum', href: 'https://rforum.online' },
+ { name: 'rChoices', href: 'https://rchoices.online' },
+ { name: 'rVote', href: 'https://rvote.online' },
+ { name: 'rFunds', href: 'https://rfunds.online' },
+ { name: 'rWallet', href: 'https://rwallet.online' },
+ { name: 'rCart', href: 'https://rcart.online' },
+ { name: 'rAuctions', href: 'https://rauctions.online' },
+ { name: 'rSwag', href: 'https://rswag.online' },
+ { name: 'rPhotos', href: 'https://rphotos.online' },
+ { name: 'rTube', href: 'https://rtube.online' },
+ { name: 'rNetwork', href: 'https://rnetwork.online' },
+ { name: 'rSocials', href: 'https://rsocials.online' },
+ { name: 'rFiles', href: 'https://rfiles.online' },
+ { name: 'rData', href: 'https://rdata.online' },
+ { name: 'rWork', href: 'https://rwork.online' },
+ { name: 'rIDs', href: 'https://ridentity.online' },
+ { name: 'rStack', href: 'https://rstack.online' },
+];
+
+interface EcosystemFooterProps {
+ current?: string;
+}
+
+export function EcosystemFooter({ current }: EcosystemFooterProps) {
+ return (
+
+ );
+}
diff --git a/frontend/components/HeaderBar.tsx b/frontend/components/HeaderBar.tsx
new file mode 100644
index 0000000..3923a48
--- /dev/null
+++ b/frontend/components/HeaderBar.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import Link from 'next/link';
+import { AppSwitcher } from '@/components/AppSwitcher';
+import { SpaceSwitcher } from '@/components/SpaceSwitcher';
+import { AuthButton } from '@/components/AuthButton';
+
+interface HeaderBarProps {
+ name: string;
+ logoUrl: string | null;
+}
+
+export function HeaderBar({ name, logoUrl }: HeaderBarProps) {
+ return (
+
+ );
+}
diff --git a/frontend/components/RevenueFlowSankey.tsx b/frontend/components/RevenueFlowSankey.tsx
new file mode 100644
index 0000000..647244d
--- /dev/null
+++ b/frontend/components/RevenueFlowSankey.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import { useState, useCallback } from "react";
+
+interface FlowSplits {
+ printer: number;
+ creator: number;
+ community: number;
+}
+
+const DEFAULT_SALE_PRICE = 29.99;
+const DEFAULT_PRODUCTION_COST = 9.25;
+
+// Min production cost as fraction of price (Printful cost floor)
+const MIN_PRINTER_FRACTION = 0.15;
+
+export function RevenueFlowSankey() {
+ const [salePrice] = useState(DEFAULT_SALE_PRICE);
+ const [splits, setSplits] = useState(() => {
+ const printer = DEFAULT_PRODUCTION_COST / DEFAULT_SALE_PRICE;
+ const remaining = 1 - printer;
+ return {
+ printer,
+ creator: remaining * 0.35,
+ community: remaining * 0.65,
+ };
+ });
+
+ const handleSplitChange = useCallback(
+ (key: keyof FlowSplits, newValue: number) => {
+ setSplits((prev) => {
+ const updated = { ...prev, [key]: newValue };
+
+ // Enforce minimum printer cost
+ if (updated.printer < MIN_PRINTER_FRACTION) {
+ updated.printer = MIN_PRINTER_FRACTION;
+ }
+
+ // Normalize so all splits sum to 1
+ const total = updated.printer + updated.creator + updated.community;
+ if (total === 0) return prev;
+
+ return {
+ printer: updated.printer / total,
+ creator: updated.creator / total,
+ community: updated.community / total,
+ };
+ });
+ },
+ []
+ );
+
+ const printerAmount = salePrice * splits.printer;
+ const creatorAmount = salePrice * splits.creator;
+ const communityAmount = salePrice * splits.community;
+
+ return (
+
+ {/* SVG Sankey Diagram */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Glow filters */}
+
+
+
+
+
+
+
+
+
+ {/* ── Source node: Sale ── */}
+
+
+ Sale
+
+
+ ${salePrice.toFixed(2)}
+
+
+ {/* ── Flow paths (Bezier curves) ── */}
+
+
+
+
+ {/* ── Target nodes ── */}
+ {/* Printer */}
+
+
+ P
+
+ Printer
+
+
+ ${printerAmount.toFixed(2)}
+
+
+ ({(splits.printer * 100).toFixed(0)}%)
+
+
+ {/* Creator */}
+
+
+ C
+
+ Creator
+
+
+ ${creatorAmount.toFixed(2)}
+
+
+ ({(splits.creator * 100).toFixed(0)}%)
+
+
+ {/* Community */}
+
+
+ $
+
+ Community
+
+
+ ${communityAmount.toFixed(2)}
+
+
+ ({(splits.community * 100).toFixed(0)}%)
+
+
+
+
+ {/* ── Interactive Sliders ── */}
+
+ handleSplitChange("printer", v)}
+ />
+ handleSplitChange("creator", v)}
+ />
+ handleSplitChange("community", v)}
+ />
+
+
+
+ Drag the sliders to see how revenue flows between production, creator, and
+ community. The community sets its own margin — every dollar above production
+ cost funds collective work.
+
+
+ );
+}
+
+/* ── Bezier flow path ── */
+function SankeyFlow({
+ startX,
+ startY,
+ endX,
+ endY,
+ width,
+ gradient,
+}: {
+ startX: number;
+ startY: number;
+ endX: number;
+ endY: number;
+ width: number;
+ gradient: string;
+}) {
+ const midX = (startX + endX) / 2;
+ const d = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
+
+ return (
+
+ );
+}
+
+/* ── Slider for a single flow channel ── */
+function FlowSlider({
+ label,
+ sublabel,
+ value,
+ amount,
+ color,
+ onChange,
+}: {
+ label: string;
+ sublabel: string;
+ value: number;
+ amount: number;
+ color: string;
+ onChange: (v: number) => void;
+}) {
+ return (
+
+
+
+
+ {label}
+
+
+ {sublabel}
+
+
+
+ ${amount.toFixed(2)}
+
+
+
+
onChange(Number(e.target.value) / 100)}
+ className="w-full h-2 rounded-full appearance-none cursor-pointer"
+ style={{
+ background: `linear-gradient(to right, ${color} ${value * 100}%, hsl(var(--muted)) ${value * 100}%)`,
+ accentColor: color,
+ }}
+ aria-label={`${label} share: ${(value * 100).toFixed(0)}%`}
+ />
+
+
+ {(value * 100).toFixed(0)}%
+
+
+ );
+}
diff --git a/frontend/components/SpaceSwitcher.tsx b/frontend/components/SpaceSwitcher.tsx
new file mode 100644
index 0000000..d20fc68
--- /dev/null
+++ b/frontend/components/SpaceSwitcher.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useState, useRef, useEffect } from 'react';
+
+interface SpaceInfo {
+ slug: string;
+ name: string;
+ icon?: string;
+ role?: string;
+}
+
+interface SpaceSwitcherProps {
+ /** Current app domain, e.g. 'rswag.online'. Space links become . */
+ domain?: string;
+}
+
+export function SpaceSwitcher({ domain }: SpaceSwitcherProps) {
+ const [open, setOpen] = useState(false);
+ const [spaces, setSpaces] = useState([]);
+ const [loaded, setLoaded] = useState(false);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+ const ref = useRef(null);
+
+ // Derive domain from window.location if not provided
+ const appDomain = domain || (typeof window !== 'undefined'
+ ? window.location.hostname.split('.').slice(-2).join('.')
+ : 'rspace.online');
+
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ }
+ document.addEventListener('click', handleClick);
+ return () => document.removeEventListener('click', handleClick);
+ }, []);
+
+ // Check auth status on mount
+ useEffect(() => {
+ fetch('/api/me')
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.authenticated) setIsAuthenticated(true);
+ })
+ .catch(() => {});
+ }, []);
+
+ const loadSpaces = async () => {
+ if (loaded) return;
+ try {
+ const res = await fetch('/api/spaces');
+ if (res.ok) {
+ const data = await res.json();
+ setSpaces(data.spaces || []);
+ }
+ } catch {
+ // API not available
+ }
+ setLoaded(true);
+ };
+
+ const handleOpen = async () => {
+ const nowOpen = !open;
+ setOpen(nowOpen);
+ if (nowOpen && !loaded) {
+ await loadSpaces();
+ }
+ };
+
+ /** Build URL for a space: . */
+ const spaceUrl = (slug: string) => `https://${slug}.${appDomain}`;
+
+ const mySpaces = spaces.filter((s) => s.role);
+ const publicSpaces = spaces.filter((s) => !s.role);
+
+ return (
+
+
{ e.stopPropagation(); handleOpen(); }}
+ className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-400 hover:bg-white/[0.05] transition-colors"
+ >
+ /
+ personal
+ ▾
+
+
+ {open && (
+
+ )}
+
+ );
+}
diff --git a/frontend/lib/mockups.ts b/frontend/lib/mockups.ts
new file mode 100644
index 0000000..2f4a6c6
--- /dev/null
+++ b/frontend/lib/mockups.ts
@@ -0,0 +1,99 @@
+/** Client-side Canvas mockup compositing for design previews. */
+
+export interface MockupConfig {
+ template: string;
+ designArea: { x: number; y: number; width: number; height: number };
+ label: string;
+ productType: string;
+ price: number;
+ blend?: "screen" | "normal";
+}
+
+export const MOCKUP_CONFIGS: MockupConfig[] = [
+ {
+ template: "/mockups/shirt-template.png",
+ designArea: { x: 262, y: 230, width: 500, height: 450 },
+ label: "T-Shirt",
+ productType: "shirt",
+ price: 29.99,
+ blend: "screen",
+ },
+ {
+ template: "/mockups/sticker-template.png",
+ designArea: { x: 270, y: 210, width: 470, height: 530 },
+ label: "Sticker",
+ productType: "sticker",
+ price: 3.50,
+ blend: "normal",
+ },
+ {
+ template: "/mockups/print-template.png",
+ designArea: { x: 225, y: 225, width: 575, height: 500 },
+ label: "Art Print",
+ productType: "print",
+ price: 12.99,
+ blend: "normal",
+ },
+];
+
+/**
+ * Composite a design image onto a photorealistic product template.
+ * For shirts: uses screen blending so designs look printed on fabric.
+ * For stickers/prints: direct paste into the blank area.
+ */
+export function generateMockup(
+ designDataUrl: string,
+ config: MockupConfig
+): Promise {
+ return new Promise((resolve, reject) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = 1024;
+ canvas.height = 1024;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return reject(new Error("Canvas not supported"));
+
+ const templateImg = new window.Image();
+ const designImg = new window.Image();
+
+ templateImg.crossOrigin = "anonymous";
+ designImg.crossOrigin = "anonymous";
+
+ let loaded = 0;
+ const onBothLoaded = () => {
+ loaded++;
+ if (loaded < 2) return;
+
+ // Draw photorealistic template as base
+ ctx.drawImage(templateImg, 0, 0, 1024, 1024);
+
+ const { x, y, width, height } = config.designArea;
+
+ // Maintain aspect ratio within the bounding box
+ const scale = Math.min(width / designImg.width, height / designImg.height);
+ const dw = designImg.width * scale;
+ const dh = designImg.height * scale;
+ const dx = x + (width - dw) / 2;
+ const dy = y + (height - dh) / 2;
+
+ if (config.blend === "screen") {
+ // Screen blend: makes light colors on dark fabric look printed
+ ctx.globalCompositeOperation = "screen";
+ }
+
+ ctx.drawImage(designImg, dx, dy, dw, dh);
+
+ // Reset composite operation
+ ctx.globalCompositeOperation = "source-over";
+
+ resolve(canvas.toDataURL("image/png"));
+ };
+
+ templateImg.onload = onBothLoaded;
+ designImg.onload = onBothLoaded;
+ templateImg.onerror = () => reject(new Error(`Failed to load template: ${config.template}`));
+ designImg.onerror = () => reject(new Error("Failed to load design image"));
+
+ templateImg.src = config.template;
+ designImg.src = designDataUrl;
+ });
+}
diff --git a/frontend/lib/spaces.ts b/frontend/lib/spaces.ts
new file mode 100644
index 0000000..645589b
--- /dev/null
+++ b/frontend/lib/spaces.ts
@@ -0,0 +1,80 @@
+export interface SpaceTheme {
+ primary: string;
+ primary_foreground: string;
+ secondary: string;
+ secondary_foreground: string;
+ background: string;
+ foreground: string;
+ card: string;
+ card_foreground: string;
+ popover: string;
+ popover_foreground: string;
+ muted: string;
+ muted_foreground: string;
+ accent: string;
+ accent_foreground: string;
+ destructive: string;
+ destructive_foreground: string;
+ border: string;
+ input: string;
+ ring: string;
+}
+
+export interface SpaceConfig {
+ id: string;
+ name: string;
+ tagline: string;
+ description: string;
+ domain: string;
+ footer_text: string;
+ theme: SpaceTheme;
+ design_filter: string;
+ logo_url: string | null;
+ design_tips: string[];
+}
+
+const THEME_VAR_MAP: Record = {
+ primary: "--primary",
+ primary_foreground: "--primary-foreground",
+ secondary: "--secondary",
+ secondary_foreground: "--secondary-foreground",
+ background: "--background",
+ foreground: "--foreground",
+ card: "--card",
+ card_foreground: "--card-foreground",
+ popover: "--popover",
+ popover_foreground: "--popover-foreground",
+ muted: "--muted",
+ muted_foreground: "--muted-foreground",
+ accent: "--accent",
+ accent_foreground: "--accent-foreground",
+ destructive: "--destructive",
+ destructive_foreground: "--destructive-foreground",
+ border: "--border",
+ input: "--input",
+ ring: "--ring",
+};
+
+export function themeToCSS(theme: SpaceTheme): string {
+ return Object.entries(THEME_VAR_MAP)
+ .filter(([key]) => theme[key as keyof SpaceTheme])
+ .map(([key, cssVar]) => `${cssVar}: ${theme[key as keyof SpaceTheme]};`)
+ .join("\n ");
+}
+
+/**
+ * Get the localStorage key for the cart, scoped by space.
+ */
+export function getCartKey(spaceId?: string): string {
+ if (!spaceId || spaceId === "default") return "cart_id";
+ return `cart_id_${spaceId}`;
+}
+
+/**
+ * Read space_id from document cookie (client-side only).
+ */
+export function getSpaceIdFromCookie(): string {
+ if (typeof document === "undefined") return "default";
+ const match = document.cookie.match(/(?:^|;\s*)space_id=([^;]*)/);
+ return match ? match[1] : "default";
+}
diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/frontend/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/frontend/middleware.ts b/frontend/middleware.ts
new file mode 100644
index 0000000..c74f5f1
--- /dev/null
+++ b/frontend/middleware.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+export function middleware(request: NextRequest) {
+ const host = request.headers.get("host") || "";
+ const hostname = host.split(":")[0].toLowerCase();
+
+ // Determine space from subdomain or custom domain
+ let spaceId = "default";
+ if (hostname.endsWith(".rswag.online")) {
+ spaceId = hostname.replace(".rswag.online", "");
+ } else if (hostname === "fungiswag.jeffemmett.com") {
+ spaceId = "fungiflows";
+ }
+ // Local dev: check for space query param as override
+ if (hostname === "localhost" || hostname === "127.0.0.1") {
+ const url = new URL(request.url);
+ const spaceParam = url.searchParams.get("_space");
+ if (spaceParam) {
+ spaceId = spaceParam;
+ }
+ }
+
+ const response = NextResponse.next();
+
+ // Set cookie so both server and client components can read the space
+ response.cookies.set("space_id", spaceId, {
+ path: "/",
+ sameSite: "lax",
+ httpOnly: false,
+ maxAge: 86400,
+ });
+
+ return response;
+}
+
+export const config = {
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
+};
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
new file mode 100644
index 0000000..c981dae
--- /dev/null
+++ b/frontend/next.config.mjs
@@ -0,0 +1,18 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ output: 'standalone',
+ images: {
+ remotePatterns: [
+ {
+ protocol: 'https',
+ hostname: 'rswag.online',
+ },
+ {
+ protocol: 'http',
+ hostname: 'localhost',
+ },
+ ],
+ },
+};
+
+export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..913199f
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,3322 @@
+{
+ "name": "rswag-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rswag-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "@encryptid/sdk": "file:./vendor/@encryptid/sdk",
+ "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-label": "^2.1.1",
+ "@radix-ui/react-radio-group": "^1.2.2",
+ "@radix-ui/react-select": "^2.1.4",
+ "@radix-ui/react-separator": "^1.1.1",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tabs": "^1.1.2",
+ "@radix-ui/react-toast": "^1.2.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "geist": "^1.3.0",
+ "lucide-react": "^0.469.0",
+ "next": "^15.1.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "zustand": "^5.0.11"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.0"
+ }
+ },
+ "../../encryptid-sdk": {
+ "name": "@encryptid/sdk",
+ "version": "0.1.0",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^2.0.1",
+ "@noble/hashes": "^2.0.1",
+ "hono": "^4.11.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "typescript": "^5.7.0"
+ },
+ "peerDependencies": {
+ "next": ">=14.0.0",
+ "react": ">=18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@encryptid/sdk": {
+ "resolved": "vendor/@encryptid/sdk",
+ "link": true
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
+ "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
+ "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.4",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
+ "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.5"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT"
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
+ "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
+ "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
+ "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
+ "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
+ "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
+ "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
+ "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
+ "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
+ "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
+ "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.0.1"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
+ "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
+ "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
+ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
+ "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-menu": "2.1.16",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
+ "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
+ "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
+ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-rect": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1",
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
+ "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
+ "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
+ "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.8",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
+ "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+ "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
+ "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.11",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-toast": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
+ "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
+ "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
+ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
+ "license": "MIT"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.11",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
+ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "license": "MIT"
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.24",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
+ "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001766",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001774",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
+ "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.302",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
+ "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/geist": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/geist/-/geist-1.7.0.tgz",
+ "integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==",
+ "license": "SIL OPEN FONT LICENSE",
+ "peerDependencies": {
+ "next": ">=13.2.0"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hono": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz",
+ "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.9.0"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "license": "MIT"
+ },
+ "node_modules/lucide-react": {
+ "version": "0.469.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz",
+ "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "15.5.12",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
+ "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.12",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.12",
+ "@next/swc-darwin-x64": "15.5.12",
+ "@next/swc-linux-arm64-gnu": "15.5.12",
+ "@next/swc-linux-arm64-musl": "15.5.12",
+ "@next/swc-linux-x64-gnu": "15.5.12",
+ "@next/swc-linux-x64-musl": "15.5.12",
+ "@next/swc-win32-arm64-msvc": "15.5.12",
+ "@next/swc-win32-x64-msvc": "15.5.12",
+ "sharp": "^0.34.3"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.27",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss-animate": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
+ "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "tailwindcss": ">=3.0.0 || insiders"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.11",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+ "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ },
+ "vendor/@encryptid/sdk": {
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/curves": "^2.0.1",
+ "@noble/hashes": "^2.0.1",
+ "hono": "^4.11.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "typescript": "^5.7.0"
+ },
+ "peerDependencies": {
+ "next": ">=14.0.0",
+ "react": ">=18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "next": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..0ed0806
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "rswag-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@encryptid/sdk": "file:./vendor/@encryptid/sdk",
+ "@radix-ui/react-dialog": "^1.1.4",
+ "@radix-ui/react-dropdown-menu": "^2.1.4",
+ "@radix-ui/react-label": "^2.1.1",
+ "@radix-ui/react-radio-group": "^1.2.2",
+ "@radix-ui/react-select": "^2.1.4",
+ "@radix-ui/react-separator": "^1.1.1",
+ "@radix-ui/react-slot": "^1.1.1",
+ "@radix-ui/react-tabs": "^1.1.2",
+ "@radix-ui/react-toast": "^1.2.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "geist": "^1.3.0",
+ "lucide-react": "^0.469.0",
+ "next": "^15.1.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss-animate": "^1.0.7",
+ "zustand": "^5.0.11"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.0"
+ }
+}
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000..2ef30fc
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+
+export default config;
diff --git a/frontend/public/.gitkeep b/frontend/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/public/mockups/print-template.png b/frontend/public/mockups/print-template.png
new file mode 100644
index 0000000..04ccd3a
Binary files /dev/null and b/frontend/public/mockups/print-template.png differ
diff --git a/frontend/public/mockups/shirt-template.png b/frontend/public/mockups/shirt-template.png
new file mode 100644
index 0000000..1ed1b5a
Binary files /dev/null and b/frontend/public/mockups/shirt-template.png differ
diff --git a/frontend/public/mockups/sticker-template.png b/frontend/public/mockups/sticker-template.png
new file mode 100644
index 0000000..aefd661
Binary files /dev/null and b/frontend/public/mockups/sticker-template.png differ
diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts
new file mode 100644
index 0000000..b27d3dc
--- /dev/null
+++ b/frontend/stores/auth.ts
@@ -0,0 +1,93 @@
+/**
+ * EncryptID Auth Store for rSwag
+ *
+ * Optional authentication via WebAuthn passkeys.
+ * Zustand with localStorage persistence, delegates WebAuthn ceremony to @encryptid/sdk.
+ */
+
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { EncryptIDClient } from '@encryptid/sdk/client';
+
+const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || 'https://auth.ridentity.online';
+const client = new EncryptIDClient(ENCRYPTID_SERVER);
+
+interface AuthState {
+ isAuthenticated: boolean;
+ token: string | null;
+ did: string | null;
+ username: string | null;
+ loading: boolean;
+
+ login: () => Promise;
+ register: (username: string) => Promise;
+ logout: () => void;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ isAuthenticated: false,
+ token: null,
+ did: null,
+ username: null,
+ loading: false,
+
+ login: async () => {
+ set({ loading: true });
+ try {
+ const result = await client.authenticate();
+ document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
+ set({
+ isAuthenticated: true,
+ token: result.token,
+ did: result.did,
+ username: result.username,
+ loading: false,
+ });
+ } catch (error) {
+ set({ loading: false });
+ throw error;
+ }
+ },
+
+ register: async (username: string) => {
+ set({ loading: true });
+ try {
+ const result = await client.register(username);
+ document.cookie = `encryptid_token=${result.token};path=/;max-age=900;SameSite=Lax`;
+ set({
+ isAuthenticated: true,
+ token: result.token,
+ did: result.did,
+ username,
+ loading: false,
+ });
+ } catch (error) {
+ set({ loading: false });
+ throw error;
+ }
+ },
+
+ logout: () => {
+ document.cookie = 'encryptid_token=;path=/;max-age=0;SameSite=Lax';
+ set({
+ isAuthenticated: false,
+ token: null,
+ did: null,
+ username: null,
+ loading: false,
+ });
+ },
+ }),
+ {
+ name: 'rswag-auth',
+ partialize: (state) => ({
+ isAuthenticated: state.isAuthenticated,
+ token: state.token,
+ did: state.did,
+ username: state.username,
+ }),
+ }
+ )
+);
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..5607f96
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,54 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: ["class"],
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx,mdx}",
+ "./components/**/*.{js,ts,jsx,tsx,mdx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ },
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+} satisfies Config;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..d8b9323
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/frontend/vendor/@encryptid/sdk/browser.d.ts b/frontend/vendor/@encryptid/sdk/browser.d.ts
new file mode 100644
index 0000000..1892ca9
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/browser.d.ts
@@ -0,0 +1,86 @@
+/**
+ * EncryptID Browser Bundle
+ *
+ * IIFE entry point that provides window.EncryptID for vanilla JS apps.
+ * Build: bun build ./src/browser.ts --outfile dist/encryptid.browser.js --target browser
+ *
+ * Usage:
+ *
+ *
+ */
+import { EncryptIDClient } from './client/api-client.js';
+import { detectCapabilities } from './client/webauthn.js';
+import { AuthLevel } from './client/session.js';
+import './ui/login-button.js';
+import './ui/guardian-setup.js';
+interface StoredUser {
+ did: string;
+ username: string;
+ token: string;
+}
+/**
+ * Authenticate with an existing passkey
+ */
+declare function authenticate(): Promise;
+/**
+ * Register a new passkey
+ */
+declare function register(username: string, displayName?: string): Promise;
+/**
+ * Log out — clear stored auth state
+ */
+declare function logout(): void;
+/**
+ * Check if user is currently authenticated
+ */
+declare function isAuthenticated(): boolean;
+/**
+ * Get stored user info
+ */
+declare function getUser(): StoredUser | null;
+/**
+ * Get the stored token
+ */
+declare function getToken(): string | null;
+/**
+ * Require authentication — redirects to home with login hint if not authenticated
+ */
+declare function requireAuth(redirectUrl?: string): boolean;
+/**
+ * Set a recovery email for the authenticated user
+ */
+declare function setRecoveryEmail(email: string): Promise;
+/**
+ * Request account recovery via email
+ */
+declare function requestRecovery(email: string): Promise;
+/**
+ * Render an auth button into a container element
+ */
+declare function renderAuthButton(containerId: string): void;
+/**
+ * Verify the stored token is still valid, refresh if needed
+ */
+declare function verifySession(): Promise;
+declare const EncryptID: {
+ client: EncryptIDClient;
+ authenticate: typeof authenticate;
+ register: typeof register;
+ logout: typeof logout;
+ isAuthenticated: typeof isAuthenticated;
+ getUser: typeof getUser;
+ getToken: typeof getToken;
+ requireAuth: typeof requireAuth;
+ setRecoveryEmail: typeof setRecoveryEmail;
+ requestRecovery: typeof requestRecovery;
+ renderAuthButton: typeof renderAuthButton;
+ verifySession: typeof verifySession;
+ detectCapabilities: typeof detectCapabilities;
+ AuthLevel: typeof AuthLevel;
+ VERSION: string;
+};
+export default EncryptID;
diff --git a/frontend/vendor/@encryptid/sdk/client/api-client.d.ts b/frontend/vendor/@encryptid/sdk/client/api-client.d.ts
new file mode 100644
index 0000000..e303ba8
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/api-client.d.ts
@@ -0,0 +1,69 @@
+/**
+ * EncryptID API Client
+ *
+ * HTTP client for communicating with the EncryptID server.
+ * Handles registration, authentication, session management.
+ */
+import type { RegistrationStartResponse, RegistrationCompleteResponse, AuthStartResponse, AuthCompleteResponse, SessionVerifyResponse, EmailRecoverySetResponse, EmailRecoveryRequestResponse, EmailRecoveryVerifyResponse } from '../types/index.js';
+export declare class EncryptIDClient {
+ private serverUrl;
+ constructor(serverUrl?: string);
+ /**
+ * Start registration — get challenge and options from server
+ */
+ registerStart(username: string, displayName?: string): Promise;
+ /**
+ * Complete registration — send credential to server
+ */
+ registerComplete(challenge: string, credential: PublicKeyCredential, userId: string, username: string): Promise;
+ /**
+ * Start authentication — get challenge from server
+ */
+ authStart(credentialId?: string): Promise;
+ /**
+ * Complete authentication — send assertion to server
+ */
+ authComplete(challenge: string, credential: PublicKeyCredential): Promise;
+ /**
+ * Verify a session token
+ */
+ verifySession(token: string): Promise;
+ /**
+ * Refresh a session token
+ */
+ refreshToken(token: string): Promise<{
+ token: string;
+ }>;
+ /**
+ * List user's credentials
+ */
+ listCredentials(token: string): Promise<{
+ credentials: any[];
+ }>;
+ /**
+ * Set recovery email for the authenticated user
+ */
+ setRecoveryEmail(token: string, email: string): Promise;
+ /**
+ * Request account recovery via email
+ */
+ requestEmailRecovery(email: string): Promise;
+ /**
+ * Verify a recovery token and get a temporary session
+ */
+ verifyRecoveryToken(recoveryToken: string): Promise;
+ /**
+ * Full registration flow: server challenge → WebAuthn create → server verify
+ */
+ register(username: string, displayName?: string, config?: {
+ rpId?: string;
+ }): Promise;
+ /**
+ * Full authentication flow: server challenge → WebAuthn get → server verify
+ */
+ authenticate(credentialId?: string, config?: {
+ rpId?: string;
+ }): Promise;
+}
diff --git a/frontend/vendor/@encryptid/sdk/client/index.d.ts b/frontend/vendor/@encryptid/sdk/client/index.d.ts
new file mode 100644
index 0000000..2ca0c9f
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/index.d.ts
@@ -0,0 +1,13 @@
+/**
+ * @encryptid/sdk/client — Browser-safe client module
+ */
+export { registerPasskey, authenticatePasskey, startConditionalUI, abortConditionalUI, isConditionalMediationAvailable, detectCapabilities, bufferToBase64url, base64urlToBuffer, generateChallenge, } from './webauthn.js';
+export type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities } from './webauthn.js';
+export { EncryptIDKeyManager, getKeyManager, resetKeyManager, encryptData, decryptData, decryptDataAsString, signData, verifySignature, wrapKeyForRecipient, unwrapSharedKey, } from './key-derivation.js';
+export type { DerivedKeys, EncryptedData, SignedData } from './key-derivation.js';
+export { SessionManager, getSessionManager, AuthLevel, OPERATION_PERMISSIONS, } from './session.js';
+export type { EncryptIDClaims, SessionState, OperationPermission } from './session.js';
+export { RecoveryManager, getRecoveryManager, GuardianType, getGuardianTypeInfo, } from './recovery.js';
+export type { Guardian, RecoveryConfig, RecoveryRequest } from './recovery.js';
+export { EncryptIDClient } from './api-client.js';
+export { shareTokenAcrossModules, clearTokenAcrossModules, initTokenRelayListener, getStoredToken, requestTokenFromDomain, MODULE_DOMAINS, } from './token-relay.js';
diff --git a/frontend/vendor/@encryptid/sdk/client/index.js b/frontend/vendor/@encryptid/sdk/client/index.js
new file mode 100644
index 0000000..53a5b6e
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/index.js
@@ -0,0 +1,66 @@
+import {
+ EncryptIDKeyManager,
+ OPERATION_PERMISSIONS,
+ RecoveryManager,
+ SessionManager,
+ decryptData,
+ decryptDataAsString,
+ encryptData,
+ getGuardianTypeInfo,
+ getKeyManager,
+ getRecoveryManager,
+ getSessionManager,
+ resetKeyManager,
+ signData,
+ unwrapSharedKey,
+ verifySignature,
+ wrapKeyForRecipient
+} from "../index-24r9wkfe.js";
+import {
+ EncryptIDClient
+} from "../index-7egxprg9.js";
+import {
+ abortConditionalUI,
+ authenticatePasskey,
+ base64urlToBuffer,
+ bufferToBase64url,
+ detectCapabilities,
+ generateChallenge,
+ isConditionalMediationAvailable,
+ registerPasskey,
+ startConditionalUI
+} from "../index-2cp5044h.js";
+import {
+ AuthLevel,
+ GuardianType
+} from "../index-5c1t4ftn.js";
+export {
+ wrapKeyForRecipient,
+ verifySignature,
+ unwrapSharedKey,
+ startConditionalUI,
+ signData,
+ resetKeyManager,
+ registerPasskey,
+ isConditionalMediationAvailable,
+ getSessionManager,
+ getRecoveryManager,
+ getKeyManager,
+ getGuardianTypeInfo,
+ generateChallenge,
+ encryptData,
+ detectCapabilities,
+ decryptDataAsString,
+ decryptData,
+ bufferToBase64url,
+ base64urlToBuffer,
+ authenticatePasskey,
+ abortConditionalUI,
+ SessionManager,
+ RecoveryManager,
+ OPERATION_PERMISSIONS,
+ GuardianType,
+ EncryptIDKeyManager,
+ EncryptIDClient,
+ AuthLevel
+};
diff --git a/frontend/vendor/@encryptid/sdk/client/key-derivation.d.ts b/frontend/vendor/@encryptid/sdk/client/key-derivation.d.ts
new file mode 100644
index 0000000..d1feda7
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/key-derivation.d.ts
@@ -0,0 +1,51 @@
+/**
+ * EncryptID Key Derivation Module
+ *
+ * Derives application-specific cryptographic keys from WebAuthn PRF output
+ * or passphrase fallback. Layer 2 of the EncryptID architecture.
+ */
+import type { DerivedKeys, EncryptedData, SignedData } from '../types/index.js';
+export type { DerivedKeys, EncryptedData, SignedData };
+export declare class EncryptIDKeyManager {
+ private masterKey;
+ private derivedKeys;
+ private fromPRF;
+ initFromPRF(prfOutput: ArrayBuffer): Promise;
+ initFromPassphrase(passphrase: string, salt: Uint8Array): Promise;
+ static generateSalt(): Uint8Array;
+ isInitialized(): boolean;
+ getKeys(): Promise;
+ private deriveEncryptionKey;
+ private deriveSigningKeyPair;
+ /**
+ * Derive deterministic secp256k1 keys from the master key via HKDF.
+ * This gives every EncryptID identity an Ethereum-compatible wallet address,
+ * enabling them to act as Gnosis Safe owners for multi-sig approvals.
+ */
+ private deriveEthereumKeys;
+ private deriveDIDSeed;
+ private generateDID;
+ clear(): void;
+}
+export declare function encryptData(key: CryptoKey, data: ArrayBuffer | Uint8Array | string): Promise;
+export declare function decryptData(key: CryptoKey, encrypted: EncryptedData): Promise;
+export declare function decryptDataAsString(key: CryptoKey, encrypted: EncryptedData): Promise;
+export declare function signData(keyPair: CryptoKeyPair, data: ArrayBuffer | Uint8Array | string): Promise;
+export declare function verifySignature(signed: SignedData): Promise;
+export declare function wrapKeyForRecipient(keyToWrap: CryptoKey, recipientPublicKey: CryptoKey): Promise;
+export declare function unwrapSharedKey(wrappedKey: ArrayBuffer, privateKey: CryptoKey): Promise;
+/**
+ * Sign an Ethereum-compatible message hash with a secp256k1 private key.
+ * Returns { r, s, v } components for Safe transaction signing.
+ *
+ * @param hash - 32-byte message hash (e.g. keccak256 of the message)
+ * @param privateKey - 32-byte secp256k1 private key
+ */
+export declare function signEthHash(hash: Uint8Array, privateKey: Uint8Array): {
+ r: string;
+ s: string;
+ v: number;
+ signature: Uint8Array;
+};
+export declare function getKeyManager(): EncryptIDKeyManager;
+export declare function resetKeyManager(): void;
diff --git a/frontend/vendor/@encryptid/sdk/client/recovery.d.ts b/frontend/vendor/@encryptid/sdk/client/recovery.d.ts
new file mode 100644
index 0000000..0a25706
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/recovery.d.ts
@@ -0,0 +1,37 @@
+/**
+ * EncryptID Social Recovery Module
+ *
+ * Guardian-based account recovery with NO SEED PHRASES.
+ */
+import type { Guardian, RecoveryConfig, RecoveryRequest } from '../types/index.js';
+import { GuardianType } from '../types/index.js';
+export { GuardianType };
+export type { Guardian, RecoveryConfig, RecoveryRequest };
+export declare class RecoveryManager {
+ private config;
+ private activeRequest;
+ constructor();
+ initializeRecovery(threshold?: number): Promise;
+ addGuardian(guardian: Omit): Promise;
+ removeGuardian(guardianId: string): Promise;
+ setThreshold(threshold: number): Promise;
+ setDelay(delaySeconds: number): Promise;
+ getConfig(): RecoveryConfig | null;
+ isConfigured(): boolean;
+ verifyGuardian(guardianId: string): Promise;
+ initiateRecovery(newCredentialId: string): Promise;
+ approveRecovery(guardianId: string, signature: string): Promise;
+ cancelRecovery(): Promise;
+ completeRecovery(): Promise;
+ getActiveRequest(): RecoveryRequest | null;
+ private hashGuardianList;
+ private saveConfig;
+ private loadConfig;
+}
+export declare function getRecoveryManager(): RecoveryManager;
+export declare function getGuardianTypeInfo(type: GuardianType): {
+ name: string;
+ description: string;
+ icon: string;
+ setupInstructions: string;
+};
diff --git a/frontend/vendor/@encryptid/sdk/client/session.d.ts b/frontend/vendor/@encryptid/sdk/client/session.d.ts
new file mode 100644
index 0000000..2989a78
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/session.d.ts
@@ -0,0 +1,35 @@
+/**
+ * EncryptID Session Management
+ *
+ * Handles session tokens, cross-app SSO, and authentication levels.
+ */
+import type { AuthenticationResult, EncryptIDClaims, SessionState, OperationPermission } from '../types/index.js';
+import { AuthLevel } from '../types/index.js';
+export { AuthLevel };
+export type { EncryptIDClaims, SessionState, OperationPermission };
+export declare const OPERATION_PERMISSIONS: Record;
+export declare class SessionManager {
+ private session;
+ private refreshTimer;
+ constructor();
+ createSession(authResult: AuthenticationResult, did: string, capabilities: EncryptIDClaims['eid']['capabilities'], walletAddress?: string, username?: string): Promise;
+ getSession(): SessionState | null;
+ getDID(): string | null;
+ getAccessToken(): string | null;
+ getAuthLevel(): AuthLevel;
+ canPerform(operation: string): {
+ allowed: boolean;
+ reason?: string;
+ };
+ requiresFreshAuth(operation: string): boolean;
+ upgradeAuthLevel(level?: AuthLevel): void;
+ clearSession(): void;
+ isValid(): boolean;
+ private createUnsignedToken;
+ private createRefreshToken;
+ private persistSession;
+ private restoreSession;
+ private scheduleRefresh;
+ private refreshTokens;
+}
+export declare function getSessionManager(): SessionManager;
diff --git a/frontend/vendor/@encryptid/sdk/client/token-relay.d.ts b/frontend/vendor/@encryptid/sdk/client/token-relay.d.ts
new file mode 100644
index 0000000..a95703f
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/token-relay.d.ts
@@ -0,0 +1,58 @@
+/**
+ * EncryptID Token Relay — Cross-Domain Authentication
+ *
+ * Since .online is a public suffix, cookies can't be shared across
+ * r*.online domains. This module uses postMessage via hidden iframes
+ * to relay the JWT token to sibling modules after authentication.
+ *
+ * Usage (on the authenticating domain, e.g., rspace.online):
+ *
+ * import { shareTokenAcrossModules, MODULE_DOMAINS } from '@encryptid/sdk/client/token-relay';
+ * await shareTokenAcrossModules(jwt, MODULE_DOMAINS);
+ *
+ * Usage (on each module, add a relay page at /auth/relay):
+ *
+ * import { initTokenRelayListener } from '@encryptid/sdk/client/token-relay';
+ * initTokenRelayListener(); // Listens for postMessage, stores token
+ */
+/** All r*.online module domains for token relay */
+export declare const MODULE_DOMAINS: readonly ["rvote.online", "rnotes.online", "rmaps.online", "rcal.online", "rfunds.online", "rtube.online", "rfiles.online", "rmail.online", "rtrips.online", "rnetwork.online", "rwallet.online", "rstack.online", "rspace.online"];
+interface RelayResult {
+ domain: string;
+ success: boolean;
+ error?: string;
+}
+/**
+ * Share an EncryptID JWT token across all r*.online module domains.
+ * Creates hidden iframes pointing to each module's /auth/relay page,
+ * then sends the token via postMessage.
+ *
+ * @param token - The JWT token to share
+ * @param domains - Array of domains to relay to (defaults to MODULE_DOMAINS)
+ * @param timeout - Timeout per domain in ms (default 5000)
+ * @returns Results for each domain
+ */
+export declare function shareTokenAcrossModules(token: string, domains?: readonly string[], timeout?: number): Promise;
+/**
+ * Clear the token from all module domains.
+ * Call this on sign-out.
+ */
+export declare function clearTokenAcrossModules(domains?: readonly string[], timeout?: number): Promise;
+/**
+ * Initialize the token relay listener on a module's /auth/relay page.
+ * Listens for postMessage from sibling r*.online domains and stores
+ * the token in localStorage.
+ *
+ * This should be called on a minimal page served at /auth/relay on each module.
+ */
+export declare function initTokenRelayListener(): void;
+/**
+ * Get the locally stored EncryptID token, if any.
+ */
+export declare function getStoredToken(): string | null;
+/**
+ * Request the token from a sibling domain if we don't have it locally.
+ * Creates a hidden iframe to the specified domain and asks for the token.
+ */
+export declare function requestTokenFromDomain(domain: string, timeout?: number): Promise;
+export {};
diff --git a/frontend/vendor/@encryptid/sdk/client/webauthn.d.ts b/frontend/vendor/@encryptid/sdk/client/webauthn.d.ts
new file mode 100644
index 0000000..6bf3152
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/client/webauthn.d.ts
@@ -0,0 +1,21 @@
+/**
+ * EncryptID WebAuthn Module
+ *
+ * Handles passkey registration, authentication, and PRF extension
+ * for key derivation. This is the foundation layer of EncryptID.
+ */
+import type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities } from '../types/index.js';
+export type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities };
+/**
+ * Abort any pending conditional UI request
+ */
+export declare function abortConditionalUI(): void;
+export declare function bufferToBase64url(buffer: ArrayBuffer): string;
+export declare function base64urlToBuffer(base64url: string): ArrayBuffer;
+export declare function generateChallenge(): ArrayBuffer;
+export declare function generatePRFSalt(purpose: string): Promise;
+export declare function registerPasskey(username: string, displayName: string, config?: Partial): Promise;
+export declare function authenticatePasskey(credentialId?: string, config?: Partial): Promise;
+export declare function isConditionalMediationAvailable(): Promise;
+export declare function startConditionalUI(config?: Partial): Promise;
+export declare function detectCapabilities(): Promise;
diff --git a/frontend/vendor/@encryptid/sdk/encryptid.browser.js b/frontend/vendor/@encryptid/sdk/encryptid.browser.js
new file mode 100644
index 0000000..72ee499
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/encryptid.browser.js
@@ -0,0 +1,71 @@
+var b={rpId:"jeffemmett.com",rpName:"EncryptID",origin:typeof window<"u"?window.location.origin:"",userVerification:"required",timeout:60000},u=null;function R(){if(u)u.abort(),u=null}function a(i){let t=new Uint8Array(i),o="";for(let r=0;r({error:"Registration start failed"}));throw Error(r.error||`HTTP ${o.status}`)}return o.json()}async registerComplete(i,t,o,r){let n=t.response,s=n.getPublicKey(),e=await fetch(`${this.serverUrl}/api/register/complete`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({challenge:i,userId:o,username:r,credential:{credentialId:a(t.rawId),publicKey:s?a(s):"",transports:n.getTransports?.()||[]}})});if(!e.ok){let d=await e.json().catch(()=>({error:"Registration complete failed"}));throw Error(d.error||`HTTP ${e.status}`)}return e.json()}async authStart(i){let t=await fetch(`${this.serverUrl}/api/auth/start`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i?{credentialId:i}:{})});if(!t.ok){let o=await t.json().catch(()=>({error:"Auth start failed"}));throw Error(o.error||`HTTP ${t.status}`)}return t.json()}async authComplete(i,t){let o=t.response,r=t.getClientExtensionResults()?.prf?.results,n=await fetch(`${this.serverUrl}/api/auth/complete`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({challenge:i,credential:{credentialId:a(t.rawId),signature:a(o.signature),authenticatorData:a(o.authenticatorData),prfOutput:r?.first?a(r.first):null}})});if(!n.ok){let s=await n.json().catch(()=>({error:"Auth complete failed"}));throw Error(s.error||`HTTP ${n.status}`)}return n.json()}async verifySession(i){return(await fetch(`${this.serverUrl}/api/session/verify`,{headers:{Authorization:`Bearer ${i}`}})).json()}async refreshToken(i){let t=await fetch(`${this.serverUrl}/api/session/refresh`,{method:"POST",headers:{Authorization:`Bearer ${i}`}});if(!t.ok){let o=await t.json().catch(()=>({error:"Token refresh failed"}));throw Error(o.error||`HTTP ${t.status}`)}return t.json()}async listCredentials(i){let t=await fetch(`${this.serverUrl}/api/user/credentials`,{headers:{Authorization:`Bearer ${i}`}});if(!t.ok)throw Error("Failed to list credentials");return t.json()}async setRecoveryEmail(i,t){let o=await fetch(`${this.serverUrl}/api/recovery/email/set`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${i}`},body:JSON.stringify({email:t})});if(!o.ok){let r=await o.json().catch(()=>({error:"Failed to set recovery email"}));throw Error(r.error||`HTTP ${o.status}`)}return o.json()}async requestEmailRecovery(i){let t=await fetch(`${this.serverUrl}/api/recovery/email/request`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:i})});if(!t.ok){let o=await t.json().catch(()=>({error:"Recovery request failed"}));throw Error(o.error||`HTTP ${t.status}`)}return t.json()}async verifyRecoveryToken(i){let t=await fetch(`${this.serverUrl}/api/recovery/email/verify`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:i})});if(!t.ok){let o=await t.json().catch(()=>({error:"Recovery verification failed"}));throw Error(o.error||`HTTP ${t.status}`)}return t.json()}async register(i,t,o){let{options:r,userId:n}=await this.registerStart(i,t),s={publicKey:{...r,challenge:l(r.challenge),user:{...r.user,id:l(r.user.id)},pubKeyCredParams:r.pubKeyCredParams,extensions:{credProps:!0,prf:{eval:{first:new Uint8Array(32)}}}}},e=await navigator.credentials.create(s);if(!e)throw Error("Failed to create credential");return this.registerComplete(r.challenge,e,n,i)}async authenticate(i,t){let{options:o}=await this.authStart(i),r={publicKey:{challenge:l(o.challenge),rpId:o.rpId,userVerification:o.userVerification,timeout:o.timeout,allowCredentials:o.allowCredentials?.map((d)=>({type:d.type,id:l(d.id),transports:d.transports})),extensions:{prf:{eval:{first:new Uint8Array(32)}}}}},n=await navigator.credentials.get(r);if(!n)throw Error("Authentication failed");let s=await this.authComplete(o.challenge,n),e=n.getClientExtensionResults()?.prf?.results;return{...s,prfOutput:e?.first}}}function l(i){let t=i.replace(/-/g,"+").replace(/_/g,"/"),o="=".repeat((4-t.length%4)%4),r=atob(t+o),n=new Uint8Array(r.length);for(let s=0;s{n[n.BASIC=1]="BASIC";n[n.STANDARD=2]="STANDARD";n[n.ELEVATED=3]="ELEVATED";n[n.CRITICAL=4]="CRITICAL"})(c||={});var Z={"rspace:view-public":{minAuthLevel:1},"rspace:view-private":{minAuthLevel:2},"rspace:edit-board":{minAuthLevel:2},"rspace:create-board":{minAuthLevel:2},"rspace:delete-board":{minAuthLevel:3,maxAgeSeconds:300},"rspace:encrypt-board":{minAuthLevel:3,requiresCapability:"encrypt"},"rwallet:view-balance":{minAuthLevel:1},"rwallet:view-history":{minAuthLevel:2},"rwallet:send-small":{minAuthLevel:2,requiresCapability:"wallet"},"rwallet:send-large":{minAuthLevel:3,requiresCapability:"wallet",maxAgeSeconds:60},"rwallet:add-guardian":{minAuthLevel:4,maxAgeSeconds:60},"rwallet:remove-guardian":{minAuthLevel:4,maxAgeSeconds:60},"rvote:view-proposals":{minAuthLevel:1},"rvote:cast-vote":{minAuthLevel:3,requiresCapability:"sign",maxAgeSeconds:300},"rvote:delegate":{minAuthLevel:3,requiresCapability:"wallet"},"rfiles:list-files":{minAuthLevel:2},"rfiles:download-own":{minAuthLevel:2,requiresCapability:"encrypt"},"rfiles:upload":{minAuthLevel:2,requiresCapability:"encrypt"},"rfiles:share":{minAuthLevel:3,requiresCapability:"encrypt"},"rfiles:delete":{minAuthLevel:3,maxAgeSeconds:300},"rfiles:export-keys":{minAuthLevel:4,maxAgeSeconds:60},"rmaps:view-public":{minAuthLevel:1},"rmaps:add-location":{minAuthLevel:2},"rmaps:edit-location":{minAuthLevel:2,requiresCapability:"sign"},"account:view-profile":{minAuthLevel:2},"account:edit-profile":{minAuthLevel:3},"account:export-data":{minAuthLevel:4,maxAgeSeconds:60},"account:delete":{minAuthLevel:4,maxAgeSeconds:60},"rspace:create-space":{minAuthLevel:2},"rspace:configure-space":{minAuthLevel:3,maxAgeSeconds:300},"rspace:delete-space":{minAuthLevel:4,maxAgeSeconds:60},"rspace:invite-member":{minAuthLevel:2},"rspace:remove-member":{minAuthLevel:3,maxAgeSeconds:300},"rspace:change-visibility":{minAuthLevel:3,maxAgeSeconds:300},"rfunds:create-space":{minAuthLevel:2},"rfunds:edit-flows":{minAuthLevel:2},"rfunds:share-space":{minAuthLevel:2}},x="encryptid_session",A=300000;class j{session=null;refreshTimer=null;constructor(){this.restoreSession()}async createSession(i,t,o){let r=Math.floor(Date.now()/1000),n={iss:"https://encryptid.jeffemmett.com",sub:t,aud:["rspace.online","rwallet.online","rvote.online","rfiles.online","rmaps.online"],iat:r,exp:r+900,jti:a(crypto.getRandomValues(new Uint8Array(16)).buffer),eid:{credentialId:i.credentialId,authLevel:3,authTime:r,capabilities:o,recoveryConfigured:!1}},s=this.createUnsignedToken(n),e=this.createRefreshToken(t);return this.session={accessToken:s,refreshToken:e,claims:n,lastAuthTime:Date.now()},this.persistSession(),this.scheduleRefresh(),this.session}getSession(){return this.session}getDID(){return this.session?.claims.sub??null}getAccessToken(){return this.session?.accessToken??null}getAuthLevel(){if(!this.session)return 1;let i=Math.floor(Date.now()/1000);if(i>=this.session.claims.exp)return 1;let t=i-this.session.claims.eid.authTime;if(t<60)return 3;if(t<900)return 2;return 1}canPerform(i){let t=Z[i];if(!t)return{allowed:!1,reason:"Unknown operation"};if(!this.session)return{allowed:!1,reason:"Not authenticated"};let o=this.getAuthLevel();if(ot.maxAgeSeconds)return{allowed:!1,reason:`Authentication too old (${r}s > ${t.maxAgeSeconds}s)`}}return{allowed:!0}}requiresFreshAuth(i){let t=Z[i];if(!t)return!0;if(t.minAuthLevel>=4)return!0;if(t.maxAgeSeconds&&t.maxAgeSeconds<=60)return!0;return!1}upgradeAuthLevel(i=3){if(!this.session)return;this.session.claims.eid.authLevel=i,this.session.claims.eid.authTime=Math.floor(Date.now()/1000),this.session.lastAuthTime=Date.now(),this.persistSession()}clearSession(){if(this.session=null,this.refreshTimer)clearTimeout(this.refreshTimer),this.refreshTimer=null;try{localStorage.removeItem(x)}catch{}}isValid(){if(!this.session)return!1;return Math.floor(Date.now()/1000)this.refreshTokens(),o)}async refreshTokens(){if(!this.session)return;let i=Math.floor(Date.now()/1000);this.session.claims.eid.authLevel=Math.min(this.session.claims.eid.authLevel,2),this.session.claims.iat=i,this.session.claims.exp=i+900,this.session.claims.jti=a(crypto.getRandomValues(new Uint8Array(16)).buffer),this.session.accessToken=this.createUnsignedToken(this.session.claims),this.persistSession(),this.scheduleRefresh()}}var $=null;function g(){if(!$)$=new j;return $}class F{masterKey=null;derivedKeys=null;fromPRF=!1;async initFromPRF(i){this.masterKey=await crypto.subtle.importKey("raw",i,{name:"HKDF"},!1,["deriveKey","deriveBits"]),this.fromPRF=!0,this.derivedKeys=null}async initFromPassphrase(i,t){let o=new TextEncoder,r=await crypto.subtle.importKey("raw",o.encode(i),{name:"PBKDF2"},!1,["deriveBits"]),n=await crypto.subtle.deriveBits({name:"PBKDF2",salt:t,iterations:600000,hash:"SHA-256"},r,256);this.masterKey=await crypto.subtle.importKey("raw",n,{name:"HKDF"},!1,["deriveKey","deriveBits"]),this.fromPRF=!1,this.derivedKeys=null}static generateSalt(){return crypto.getRandomValues(new Uint8Array(32))}isInitialized(){return this.masterKey!==null}async getKeys(){if(!this.masterKey)throw Error("Key manager not initialized");if(this.derivedKeys)return this.derivedKeys;let[i,t,o]=await Promise.all([this.deriveEncryptionKey(),this.deriveSigningKeyPair(),this.deriveDIDSeed()]),r=await this.generateDID(o);return this.derivedKeys={encryptionKey:i,signingKeyPair:t,didSeed:o,did:r,fromPRF:this.fromPRF},this.derivedKeys}async deriveEncryptionKey(){let i=new TextEncoder;return crypto.subtle.deriveKey({name:"HKDF",hash:"SHA-256",salt:i.encode("encryptid-encryption-key-v1"),info:i.encode("AES-256-GCM")},this.masterKey,{name:"AES-GCM",length:256},!1,["encrypt","decrypt","wrapKey","unwrapKey"])}async deriveSigningKeyPair(){return crypto.subtle.generateKey({name:"ECDSA",namedCurve:"P-256"},!1,["sign","verify"])}async deriveDIDSeed(){let i=new TextEncoder,t=await crypto.subtle.deriveBits({name:"HKDF",hash:"SHA-256",salt:i.encode("encryptid-did-key-v1"),info:i.encode("Ed25519-seed")},this.masterKey,256);return new Uint8Array(t)}async generateDID(i){let t=await crypto.subtle.digest("SHA-256",i),o=new Uint8Array(t).slice(0,32),r=new Uint8Array([237,1]),n=new Uint8Array(34);return n.set(r),n.set(o,2),`did:key:z${a(n.buffer).replace(/-/g,"").replace(/_/g,"")}`}clear(){this.masterKey=null,this.derivedKeys=null,this.fromPRF=!1}}var E=null;function y(){if(!E)E=new F;return E}var ii=' ',ti=`
+:host { --eid-primary: #06b6d4; --eid-primary-hover: #0891b2; --eid-bg: #0f172a; --eid-bg-hover: #1e293b; --eid-text: #f1f5f9; --eid-text-secondary: #94a3b8; --eid-radius: 8px; display: inline-block; font-family: system-ui, -apple-system, sans-serif; }
+.login-btn { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--eid-primary); color: white; border: none; border-radius: var(--eid-radius); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); }
+.login-btn:hover { background: var(--eid-primary-hover); transform: translateY(-1px); }
+.login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
+.login-btn.outline { background: transparent; border: 2px solid var(--eid-primary); color: var(--eid-primary); }
+.login-btn.outline:hover { background: var(--eid-primary); color: white; }
+.login-btn.small { padding: 8px 16px; font-size: 0.875rem; }
+.login-btn.large { padding: 16px 32px; font-size: 1.125rem; }
+.passkey-icon { width: 24px; height: 24px; }
+.user-info { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--eid-bg); border-radius: var(--eid-radius); color: var(--eid-text); cursor: pointer; }
+.user-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--eid-primary); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem; }
+.user-did { font-size: 0.75rem; color: var(--eid-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
+.auth-level { font-size: 0.625rem; padding: 2px 6px; border-radius: 4px; }
+.auth-level.elevated { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
+.auth-level.standard { background: rgba(234, 179, 8, 0.2); color: #eab308; }
+.dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; background: var(--eid-bg); border-radius: var(--eid-radius); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); min-width: 200px; z-index: 100; overflow: hidden; }
+.dropdown-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--eid-text); cursor: pointer; transition: background 0.2s; }
+.dropdown-item:hover { background: var(--eid-bg-hover); }
+.dropdown-item.danger { color: #ef4444; }
+.dropdown-divider { height: 1px; background: #334155; margin: 4px 0; }
+.loading-spinner { width: 20px; height: 20px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+`;class Y extends HTMLElement{shadow;loading=!1;showDropdown=!1;capabilities=null;static get observedAttributes(){return["size","variant","label","show-user"]}constructor(){super();this.shadow=this.attachShadow({mode:"open"})}async connectedCallback(){if(this.capabilities=await m(),this.capabilities.conditionalUI)this.startConditionalAuth();this.render(),document.addEventListener("click",(i)=>{if(!this.contains(i.target))this.showDropdown=!1,this.render()})}attributeChangedCallback(){this.render()}get size(){return this.getAttribute("size")||"medium"}get variant(){return this.getAttribute("variant")||"primary"}get label(){return this.getAttribute("label")||"Sign in with Passkey"}get showUser(){return this.hasAttribute("show-user")}render(){let i=g(),t=i.isValid(),o=i.getDID(),r=i.getAuthLevel();this.shadow.innerHTML=`
+ ${t&&this.showUser?this.renderUserInfo(o,r):this.renderLoginButton()}
+ ${this.showDropdown?this.renderDropdown():""}
+
`,this.attachEventListeners()}renderLoginButton(){let i=this.size==="medium"?"":this.size,t=this.variant==="primary"?"":this.variant;return`
+ ${this.loading?'
':ii}
+ ${this.loading?"Authenticating...":this.label} `}renderUserInfo(i,t){let o=i.slice(0,20)+"..."+i.slice(-8),r=i.slice(8,10).toUpperCase(),n=c[t].toLowerCase();return``}renderDropdown(){return`
+
Profile
+
Recovery Settings
+
Upgrade Auth Level
+
+
Sign Out
`}attachEventListeners(){if(g().isValid()&&this.showUser)this.shadow.querySelector(".user-info")?.addEventListener("click",()=>{this.showDropdown=!this.showDropdown,this.render()}),this.shadow.querySelectorAll(".dropdown-item").forEach((t)=>{t.addEventListener("click",(o)=>{o.stopPropagation(),this.handleDropdownAction(t.dataset.action)})});else this.shadow.querySelector(".login-btn")?.addEventListener("click",()=>this.handleLogin())}async handleLogin(){if(this.loading)return;this.loading=!0,this.render();try{let i=await D(),t=y();if(i.prfOutput)await t.initFromPRF(i.prfOutput);let o=await t.getKeys();await g().createSession(i,o.did,{encrypt:!0,sign:!0,wallet:!1}),this.dispatchEvent(new CustomEvent("login-success",{detail:{did:o.did,credentialId:i.credentialId,prfAvailable:!!i.prfOutput},bubbles:!0}))}catch(i){if(i.name==="NotAllowedError"||i.message?.includes("No credential"))this.dispatchEvent(new CustomEvent("login-register-needed",{bubbles:!0}));else this.dispatchEvent(new CustomEvent("login-error",{detail:{error:i.message},bubbles:!0}))}finally{this.loading=!1,this.render()}}async handleDropdownAction(i){if(this.showDropdown=!1,i==="logout")g().clearSession(),y().clear(),this.dispatchEvent(new CustomEvent("logout",{bubbles:!0}));else if(i==="upgrade")try{await D(),g().upgradeAuthLevel(3),this.dispatchEvent(new CustomEvent("auth-upgraded",{detail:{level:3},bubbles:!0}))}catch{}else this.dispatchEvent(new CustomEvent("navigate",{detail:{path:`/${i}`},bubbles:!0}));this.render()}async startConditionalAuth(){try{let i=await X();if(i){let t=y();if(i.prfOutput)await t.initFromPRF(i.prfOutput);let o=await t.getKeys();await g().createSession(i,o.did,{encrypt:!0,sign:!0,wallet:!1}),this.dispatchEvent(new CustomEvent("login-success",{detail:{did:o.did,credentialId:i.credentialId,viaConditionalUI:!0},bubbles:!0})),this.render()}}catch{}}async register(i,t){this.loading=!0,this.render();try{let o=await Q(i,t);this.dispatchEvent(new CustomEvent("register-success",{detail:{credentialId:o.credentialId,prfSupported:o.prfSupported},bubbles:!0})),await this.handleLogin()}catch(o){this.dispatchEvent(new CustomEvent("register-error",{detail:{error:o.message},bubbles:!0}))}finally{this.loading=!1,this.render()}}}customElements.define("encryptid-login",Y);class S{config=null;activeRequest=null;constructor(){this.loadConfig()}async initializeRecovery(i=3){return this.config={threshold:i,delaySeconds:172800,guardians:[],guardianListHash:"",updatedAt:Date.now()},await this.saveConfig(),this.config}async addGuardian(i){if(!this.config)throw Error("Recovery not initialized");if(this.config.guardians.length>=7)throw Error("Maximum of 7 guardians allowed");let t={...i,id:a(crypto.getRandomValues(new Uint8Array(16)).buffer),addedAt:Date.now()};return this.config.guardians.push(t),this.config.guardianListHash=await this.hashGuardianList(),this.config.updatedAt=Date.now(),await this.saveConfig(),t}async removeGuardian(i){if(!this.config)throw Error("Recovery not initialized");let t=this.config.guardians.findIndex((r)=>r.id===i);if(t===-1)throw Error("Guardian not found");if(this.config.guardians.filter((r)=>r.id!==i).reduce((r,n)=>r+n.weight,0)o+r.weight,0);if(i>t)throw Error("Threshold cannot exceed total guardian weight");if(i<1)throw Error("Threshold must be at least 1");this.config.threshold=i,this.config.updatedAt=Date.now(),await this.saveConfig()}async setDelay(i){if(!this.config)throw Error("Recovery not initialized");if(i<3600||i>604800)throw Error("Delay must be between 1 hour and 7 days");this.config.delaySeconds=i,this.config.updatedAt=Date.now(),await this.saveConfig()}getConfig(){return this.config}isConfigured(){if(!this.config)return!1;return this.config.guardians.reduce((i,t)=>i+t.weight,0)>=this.config.threshold}async verifyGuardian(i){if(!this.config)throw Error("Recovery not initialized");let t=this.config.guardians.find((o)=>o.id===i);if(!t)throw Error("Guardian not found");return t.lastVerified=Date.now(),await this.saveConfig(),!0}async initiateRecovery(i){if(!this.config)throw Error("Recovery not configured");if(this.activeRequest?.status==="pending")throw Error("Recovery already in progress");let t=Date.now();return this.activeRequest={id:a(crypto.getRandomValues(new Uint8Array(16)).buffer),accountDID:"",newCredentialId:i,initiatedAt:t,completesAt:t+this.config.delaySeconds*1000,status:"pending",approvals:[],approvalWeight:0},this.activeRequest}async approveRecovery(i,t){if(!this.activeRequest||this.activeRequest.status!=="pending")throw Error("No pending recovery request");if(!this.config)throw Error("Recovery not configured");let o=this.config.guardians.find((r)=>r.id===i);if(!o)throw Error("Guardian not found");if(this.activeRequest.approvals.some((r)=>r.guardianId===i))throw Error("Guardian already approved");if(this.activeRequest.approvals.push({guardianId:i,approvedAt:Date.now(),signature:t}),this.activeRequest.approvalWeight+=o.weight,this.activeRequest.approvalWeight>=this.config.threshold)this.activeRequest.status="approved";return this.activeRequest}async cancelRecovery(){if(!this.activeRequest||this.activeRequest.status!=="pending")throw Error("No pending recovery request to cancel");this.activeRequest.status="cancelled",this.activeRequest=null}async completeRecovery(){if(!this.activeRequest)throw Error("No recovery request");if(this.activeRequest.status!=="approved")throw Error("Recovery not approved");if(Date.now()o.id).sort().join(","),t=await crypto.subtle.digest("SHA-256",new TextEncoder().encode(i));return a(t)}async saveConfig(){if(!this.config)return;try{localStorage.setItem("encryptid_recovery",JSON.stringify(this.config))}catch{}}loadConfig(){try{let i=localStorage.getItem("encryptid_recovery");if(i)this.config=JSON.parse(i)}catch{}}}var k=null;function G(){if(!k)k=new S;return k}function N(i){switch(i){case"secondary_passkey":return{name:"Backup Passkey",description:"Another device you own (phone, YubiKey, etc.)",icon:"key",setupInstructions:"Register a passkey on a second device you control."};case"trusted_contact":return{name:"Trusted Contact",description:"A friend or family member with their own EncryptID",icon:"user",setupInstructions:"Ask a trusted person to create an EncryptID account."};case"hardware_key":return{name:"Hardware Security Key",description:"A YubiKey or similar device stored offline",icon:"shield",setupInstructions:"Register a hardware security key and store it safely."};case"institutional":return{name:"Recovery Service",description:"A professional recovery service provider",icon:"building",setupInstructions:"Connect with a trusted recovery service."};case"time_delayed_self":return{name:"Time-Delayed Self",description:"Recover yourself after a waiting period",icon:"clock",setupInstructions:"Set up a recovery option that requires waiting before completing."};default:return{name:"Unknown",description:"Unknown guardian type",icon:"question",setupInstructions:""}}}class I extends HTMLElement{shadow;constructor(){super();this.shadow=this.attachShadow({mode:"open"})}connectedCallback(){let i=G();if(!i.getConfig())i.initializeRecovery(3).then(()=>this.render());else this.render()}render(){let t=G().getConfig(),o=t?.guardians??[],r=t?.threshold??3,n=o.reduce((e,d)=>e+d.weight,0),s=n>=r;this.shadow.innerHTML=`
+
+
+
Social Recovery
+
Set up guardians to recover your account without seed phrases
+
+
+
+
${s?"Recovery Configured":"Setup Incomplete"}
+
${n}/${r} guardians
+
+
+ ${o.map((e)=>{let d=N(e.type);return`
${d.icon==="key"?"\uD83D\uDD11":d.icon==="user"?"\uD83D\uDC64":d.icon==="shield"?"\uD83D\uDEE1️":d.icon==="building"?"\uD83C\uDFE2":"⏰"}
`}).join("")}
+
+ `}}customElements.define("encryptid-guardian-setup",I);var w="encryptid_token",f="encryptid_user",oi="https://encryptid.jeffemmett.com",h=new z(oi);async function U(){let i=await h.authenticate(),t={did:i.did,username:i.username,token:i.token};return localStorage.setItem(w,i.token),localStorage.setItem(f,JSON.stringify(t)),t}async function B(i,t){let o=await h.register(i,t),r={did:o.did,username:i,token:o.token};return localStorage.setItem(w,o.token),localStorage.setItem(f,JSON.stringify(r)),r}function P(){localStorage.removeItem(w),localStorage.removeItem(f),localStorage.removeItem("encryptid_session")}function O(){return!!localStorage.getItem(w)}function W(){let i=localStorage.getItem(f);if(!i)return null;try{return JSON.parse(i)}catch{return null}}function H(){return localStorage.getItem(w)}function ri(i){if(O())return!0;let t=i||window.location.href;return window.location.href=`/?login=required&return=${encodeURIComponent(t)}`,!1}async function ni(i){let t=H();if(!t)throw Error("Not authenticated");await h.setRecoveryEmail(t,i)}async function si(i){await h.requestEmailRecovery(i)}function ei(i){let t=document.getElementById(i);if(!t){console.warn(`EncryptID: Container #${i} not found`);return}function o(){let r=W();if(r)t.innerHTML=`
+
+ ${r.username||r.did.slice(0,16)+"..."}
+ Sign Out
+
`,document.getElementById("eid-signout")?.addEventListener("click",()=>{P(),o(),t.dispatchEvent(new CustomEvent("encryptid-logout",{bubbles:!0}))});else t.innerHTML=`
+
+
+
+
+ Sign in with Passkey
+ `,document.getElementById("eid-signin")?.addEventListener("click",async()=>{try{let n=await U();o(),t.dispatchEvent(new CustomEvent("encryptid-login",{bubbles:!0,detail:n}))}catch(n){if(n.name==="NotAllowedError"||n.message?.includes("No credential")){let s=prompt("No passkey found. Register with a username:");if(s)try{let e=await B(s);o(),t.dispatchEvent(new CustomEvent("encryptid-login",{bubbles:!0,detail:e}))}catch(e){alert(`Registration failed: ${e.message}`)}}else console.error("EncryptID auth error:",n)}})}o()}async function ai(){let i=H();if(!i)return!1;try{if((await h.verifySession(i)).valid)return!0;try{let o=await h.refreshToken(i);localStorage.setItem(w,o.token);let r=W();if(r)r.token=o.token,localStorage.setItem(f,JSON.stringify(r));return!0}catch{return P(),!1}}catch{return!1}}var _={client:h,authenticate:U,register:B,logout:P,isAuthenticated:O,getUser:W,getToken:H,requireAuth:ri,setRecoveryEmail:ni,requestRecovery:si,renderAuthButton:ei,verifySession:ai,detectCapabilities:m,AuthLevel:c,VERSION:"0.1.0"};if(typeof window<"u")window.EncryptID=_;var Xi=_;export{Xi as default};
diff --git a/frontend/vendor/@encryptid/sdk/index-24r9wkfe.js b/frontend/vendor/@encryptid/sdk/index-24r9wkfe.js
new file mode 100644
index 0000000..203b289
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-24r9wkfe.js
@@ -0,0 +1,529 @@
+import {
+ bufferToBase64url
+} from "./index-2cp5044h.js";
+import {
+ AuthLevel
+} from "./index-5c1t4ftn.js";
+
+// src/client/key-derivation.ts
+class EncryptIDKeyManager {
+ masterKey = null;
+ derivedKeys = null;
+ fromPRF = false;
+ async initFromPRF(prfOutput) {
+ this.masterKey = await crypto.subtle.importKey("raw", prfOutput, { name: "HKDF" }, false, ["deriveKey", "deriveBits"]);
+ this.fromPRF = true;
+ this.derivedKeys = null;
+ }
+ async initFromPassphrase(passphrase, salt) {
+ const encoder = new TextEncoder;
+ const passphraseKey = await crypto.subtle.importKey("raw", encoder.encode(passphrase), { name: "PBKDF2" }, false, ["deriveBits"]);
+ const masterKeyMaterial = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" }, passphraseKey, 256);
+ this.masterKey = await crypto.subtle.importKey("raw", masterKeyMaterial, { name: "HKDF" }, false, ["deriveKey", "deriveBits"]);
+ this.fromPRF = false;
+ this.derivedKeys = null;
+ }
+ static generateSalt() {
+ return crypto.getRandomValues(new Uint8Array(32));
+ }
+ isInitialized() {
+ return this.masterKey !== null;
+ }
+ async getKeys() {
+ if (!this.masterKey)
+ throw new Error("Key manager not initialized");
+ if (this.derivedKeys)
+ return this.derivedKeys;
+ const [encryptionKey, signingKeyPair, didSeed] = await Promise.all([
+ this.deriveEncryptionKey(),
+ this.deriveSigningKeyPair(),
+ this.deriveDIDSeed()
+ ]);
+ const did = await this.generateDID(didSeed);
+ this.derivedKeys = { encryptionKey, signingKeyPair, didSeed, did, fromPRF: this.fromPRF };
+ return this.derivedKeys;
+ }
+ async deriveEncryptionKey() {
+ const encoder = new TextEncoder;
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("encryptid-encryption-key-v1"), info: encoder.encode("AES-256-GCM") }, this.masterKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt", "wrapKey", "unwrapKey"]);
+ }
+ async deriveSigningKeyPair() {
+ return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
+ }
+ async deriveDIDSeed() {
+ const encoder = new TextEncoder;
+ const seed = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("encryptid-did-key-v1"), info: encoder.encode("Ed25519-seed") }, this.masterKey, 256);
+ return new Uint8Array(seed);
+ }
+ async generateDID(seed) {
+ const publicKeyHash = await crypto.subtle.digest("SHA-256", seed);
+ const publicKeyBytes = new Uint8Array(publicKeyHash).slice(0, 32);
+ const multicodecPrefix = new Uint8Array([237, 1]);
+ const multicodecKey = new Uint8Array(34);
+ multicodecKey.set(multicodecPrefix);
+ multicodecKey.set(publicKeyBytes, 2);
+ const base58Encoded = bufferToBase64url(multicodecKey.buffer).replace(/-/g, "").replace(/_/g, "");
+ return `did:key:z${base58Encoded}`;
+ }
+ clear() {
+ this.masterKey = null;
+ this.derivedKeys = null;
+ this.fromPRF = false;
+ }
+}
+async function encryptData(key, data) {
+ let plaintext;
+ if (typeof data === "string")
+ plaintext = new TextEncoder().encode(data).buffer;
+ else if (data instanceof Uint8Array)
+ plaintext = data.buffer;
+ else
+ plaintext = data;
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
+ return { ciphertext, iv };
+}
+async function decryptData(key, encrypted) {
+ return crypto.subtle.decrypt({ name: "AES-GCM", iv: encrypted.iv }, key, encrypted.ciphertext);
+}
+async function decryptDataAsString(key, encrypted) {
+ return new TextDecoder().decode(await decryptData(key, encrypted));
+}
+async function signData(keyPair, data) {
+ let dataBuffer;
+ if (typeof data === "string")
+ dataBuffer = new TextEncoder().encode(data).buffer;
+ else if (data instanceof Uint8Array)
+ dataBuffer = data.buffer;
+ else
+ dataBuffer = data;
+ const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, keyPair.privateKey, dataBuffer);
+ const publicKey = await crypto.subtle.exportKey("raw", keyPair.publicKey);
+ return { data: dataBuffer, signature, publicKey };
+}
+async function verifySignature(signed) {
+ const publicKey = await crypto.subtle.importKey("raw", signed.publicKey, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
+ return crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, publicKey, signed.signature, signed.data);
+}
+async function wrapKeyForRecipient(keyToWrap, recipientPublicKey) {
+ return crypto.subtle.wrapKey("raw", keyToWrap, recipientPublicKey, { name: "RSA-OAEP" });
+}
+async function unwrapSharedKey(wrappedKey, privateKey) {
+ return crypto.subtle.unwrapKey("raw", wrappedKey, privateKey, { name: "RSA-OAEP" }, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
+}
+var keyManagerInstance = null;
+function getKeyManager() {
+ if (!keyManagerInstance)
+ keyManagerInstance = new EncryptIDKeyManager;
+ return keyManagerInstance;
+}
+function resetKeyManager() {
+ if (keyManagerInstance) {
+ keyManagerInstance.clear();
+ keyManagerInstance = null;
+ }
+}
+
+// src/client/session.ts
+var OPERATION_PERMISSIONS = {
+ "rspace:view-public": { minAuthLevel: 1 /* BASIC */ },
+ "rspace:view-private": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:edit-board": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:create-board": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:delete-board": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rspace:encrypt-board": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "encrypt" },
+ "rwallet:view-balance": { minAuthLevel: 1 /* BASIC */ },
+ "rwallet:view-history": { minAuthLevel: 2 /* STANDARD */ },
+ "rwallet:send-small": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "wallet" },
+ "rwallet:send-large": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "wallet", maxAgeSeconds: 60 },
+ "rwallet:add-guardian": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rwallet:remove-guardian": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rvote:view-proposals": { minAuthLevel: 1 /* BASIC */ },
+ "rvote:cast-vote": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "sign", maxAgeSeconds: 300 },
+ "rvote:delegate": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "wallet" },
+ "rfiles:list-files": { minAuthLevel: 2 /* STANDARD */ },
+ "rfiles:download-own": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "encrypt" },
+ "rfiles:upload": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "encrypt" },
+ "rfiles:share": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "encrypt" },
+ "rfiles:delete": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rfiles:export-keys": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rmaps:view-public": { minAuthLevel: 1 /* BASIC */ },
+ "rmaps:add-location": { minAuthLevel: 2 /* STANDARD */ },
+ "rmaps:edit-location": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "sign" },
+ "account:view-profile": { minAuthLevel: 2 /* STANDARD */ },
+ "account:edit-profile": { minAuthLevel: 3 /* ELEVATED */ },
+ "account:export-data": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "account:delete": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rspace:create-space": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:configure-space": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rspace:delete-space": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rspace:invite-member": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:remove-member": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rspace:change-visibility": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rfunds:create-space": { minAuthLevel: 2 /* STANDARD */ },
+ "rfunds:edit-flows": { minAuthLevel: 2 /* STANDARD */ },
+ "rfunds:share-space": { minAuthLevel: 2 /* STANDARD */ }
+};
+var SESSION_STORAGE_KEY = "encryptid_session";
+var TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000;
+
+class SessionManager {
+ session = null;
+ refreshTimer = null;
+ constructor() {
+ this.restoreSession();
+ }
+ async createSession(authResult, did, capabilities) {
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ iss: "https://encryptid.jeffemmett.com",
+ sub: did,
+ aud: ["rspace.online", "rwallet.online", "rvote.online", "rfiles.online", "rmaps.online"],
+ iat: now,
+ exp: now + 15 * 60,
+ jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
+ eid: {
+ credentialId: authResult.credentialId,
+ authLevel: 3 /* ELEVATED */,
+ authTime: now,
+ capabilities,
+ recoveryConfigured: false
+ }
+ };
+ const accessToken = this.createUnsignedToken(claims);
+ const refreshToken = this.createRefreshToken(did);
+ this.session = { accessToken, refreshToken, claims, lastAuthTime: Date.now() };
+ this.persistSession();
+ this.scheduleRefresh();
+ return this.session;
+ }
+ getSession() {
+ return this.session;
+ }
+ getDID() {
+ return this.session?.claims.sub ?? null;
+ }
+ getAccessToken() {
+ return this.session?.accessToken ?? null;
+ }
+ getAuthLevel() {
+ if (!this.session)
+ return 1 /* BASIC */;
+ const now = Math.floor(Date.now() / 1000);
+ if (now >= this.session.claims.exp)
+ return 1 /* BASIC */;
+ const authAge = now - this.session.claims.eid.authTime;
+ if (authAge < 60)
+ return 3 /* ELEVATED */;
+ if (authAge < 15 * 60)
+ return 2 /* STANDARD */;
+ return 1 /* BASIC */;
+ }
+ canPerform(operation) {
+ const permission = OPERATION_PERMISSIONS[operation];
+ if (!permission)
+ return { allowed: false, reason: "Unknown operation" };
+ if (!this.session)
+ return { allowed: false, reason: "Not authenticated" };
+ const currentLevel = this.getAuthLevel();
+ if (currentLevel < permission.minAuthLevel) {
+ return { allowed: false, reason: `Requires ${AuthLevel[permission.minAuthLevel]} auth level (current: ${AuthLevel[currentLevel]})` };
+ }
+ if (permission.requiresCapability) {
+ if (!this.session.claims.eid.capabilities[permission.requiresCapability]) {
+ return { allowed: false, reason: `Requires ${permission.requiresCapability} capability` };
+ }
+ }
+ if (permission.maxAgeSeconds) {
+ const authAge = Math.floor(Date.now() / 1000) - this.session.claims.eid.authTime;
+ if (authAge > permission.maxAgeSeconds) {
+ return { allowed: false, reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)` };
+ }
+ }
+ return { allowed: true };
+ }
+ requiresFreshAuth(operation) {
+ const permission = OPERATION_PERMISSIONS[operation];
+ if (!permission)
+ return true;
+ if (permission.minAuthLevel >= 4 /* CRITICAL */)
+ return true;
+ if (permission.maxAgeSeconds && permission.maxAgeSeconds <= 60)
+ return true;
+ return false;
+ }
+ upgradeAuthLevel(level = 3 /* ELEVATED */) {
+ if (!this.session)
+ return;
+ this.session.claims.eid.authLevel = level;
+ this.session.claims.eid.authTime = Math.floor(Date.now() / 1000);
+ this.session.lastAuthTime = Date.now();
+ this.persistSession();
+ }
+ clearSession() {
+ this.session = null;
+ if (this.refreshTimer) {
+ clearTimeout(this.refreshTimer);
+ this.refreshTimer = null;
+ }
+ try {
+ localStorage.removeItem(SESSION_STORAGE_KEY);
+ } catch {}
+ }
+ isValid() {
+ if (!this.session)
+ return false;
+ return Math.floor(Date.now() / 1000) < this.session.claims.exp;
+ }
+ createUnsignedToken(claims) {
+ const header = { alg: "none", typ: "JWT" };
+ return `${btoa(JSON.stringify(header))}.${btoa(JSON.stringify(claims))}.`;
+ }
+ createRefreshToken(did) {
+ return btoa(JSON.stringify({
+ sub: did,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
+ jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer)
+ }));
+ }
+ persistSession() {
+ if (!this.session)
+ return;
+ try {
+ localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.session));
+ } catch {}
+ }
+ restoreSession() {
+ try {
+ const stored = localStorage.getItem(SESSION_STORAGE_KEY);
+ if (stored) {
+ const session = JSON.parse(stored);
+ if (Math.floor(Date.now() / 1000) < session.claims.exp) {
+ this.session = session;
+ this.scheduleRefresh();
+ } else {
+ localStorage.removeItem(SESSION_STORAGE_KEY);
+ }
+ }
+ } catch {}
+ }
+ scheduleRefresh() {
+ if (!this.session)
+ return;
+ if (this.refreshTimer)
+ clearTimeout(this.refreshTimer);
+ const expiresAt = this.session.claims.exp * 1000;
+ const refreshAt = expiresAt - TOKEN_REFRESH_THRESHOLD;
+ const delay = Math.max(refreshAt - Date.now(), 0);
+ this.refreshTimer = setTimeout(() => this.refreshTokens(), delay);
+ }
+ async refreshTokens() {
+ if (!this.session)
+ return;
+ const now = Math.floor(Date.now() / 1000);
+ this.session.claims.eid.authLevel = Math.min(this.session.claims.eid.authLevel, 2 /* STANDARD */);
+ this.session.claims.iat = now;
+ this.session.claims.exp = now + 15 * 60;
+ this.session.claims.jti = bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
+ this.session.accessToken = this.createUnsignedToken(this.session.claims);
+ this.persistSession();
+ this.scheduleRefresh();
+ }
+}
+var sessionManagerInstance = null;
+function getSessionManager() {
+ if (!sessionManagerInstance)
+ sessionManagerInstance = new SessionManager;
+ return sessionManagerInstance;
+}
+
+// src/client/recovery.ts
+class RecoveryManager {
+ config = null;
+ activeRequest = null;
+ constructor() {
+ this.loadConfig();
+ }
+ async initializeRecovery(threshold = 3) {
+ this.config = {
+ threshold,
+ delaySeconds: 48 * 60 * 60,
+ guardians: [],
+ guardianListHash: "",
+ updatedAt: Date.now()
+ };
+ await this.saveConfig();
+ return this.config;
+ }
+ async addGuardian(guardian) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ if (this.config.guardians.length >= 7)
+ throw new Error("Maximum of 7 guardians allowed");
+ const newGuardian = {
+ ...guardian,
+ id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
+ addedAt: Date.now()
+ };
+ this.config.guardians.push(newGuardian);
+ this.config.guardianListHash = await this.hashGuardianList();
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ return newGuardian;
+ }
+ async removeGuardian(guardianId) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ const index = this.config.guardians.findIndex((g) => g.id === guardianId);
+ if (index === -1)
+ throw new Error("Guardian not found");
+ const remainingWeight = this.config.guardians.filter((g) => g.id !== guardianId).reduce((sum, g) => sum + g.weight, 0);
+ if (remainingWeight < this.config.threshold)
+ throw new Error("Cannot remove guardian: would make recovery impossible");
+ this.config.guardians.splice(index, 1);
+ this.config.guardianListHash = await this.hashGuardianList();
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ }
+ async setThreshold(threshold) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0);
+ if (threshold > totalWeight)
+ throw new Error("Threshold cannot exceed total guardian weight");
+ if (threshold < 1)
+ throw new Error("Threshold must be at least 1");
+ this.config.threshold = threshold;
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ }
+ async setDelay(delaySeconds) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ if (delaySeconds < 3600 || delaySeconds > 7 * 24 * 3600)
+ throw new Error("Delay must be between 1 hour and 7 days");
+ this.config.delaySeconds = delaySeconds;
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ }
+ getConfig() {
+ return this.config;
+ }
+ isConfigured() {
+ if (!this.config)
+ return false;
+ return this.config.guardians.reduce((sum, g) => sum + g.weight, 0) >= this.config.threshold;
+ }
+ async verifyGuardian(guardianId) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ const guardian = this.config.guardians.find((g) => g.id === guardianId);
+ if (!guardian)
+ throw new Error("Guardian not found");
+ guardian.lastVerified = Date.now();
+ await this.saveConfig();
+ return true;
+ }
+ async initiateRecovery(newCredentialId) {
+ if (!this.config)
+ throw new Error("Recovery not configured");
+ if (this.activeRequest?.status === "pending")
+ throw new Error("Recovery already in progress");
+ const now = Date.now();
+ this.activeRequest = {
+ id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
+ accountDID: "",
+ newCredentialId,
+ initiatedAt: now,
+ completesAt: now + this.config.delaySeconds * 1000,
+ status: "pending",
+ approvals: [],
+ approvalWeight: 0
+ };
+ return this.activeRequest;
+ }
+ async approveRecovery(guardianId, signature) {
+ if (!this.activeRequest || this.activeRequest.status !== "pending")
+ throw new Error("No pending recovery request");
+ if (!this.config)
+ throw new Error("Recovery not configured");
+ const guardian = this.config.guardians.find((g) => g.id === guardianId);
+ if (!guardian)
+ throw new Error("Guardian not found");
+ if (this.activeRequest.approvals.some((a) => a.guardianId === guardianId))
+ throw new Error("Guardian already approved");
+ this.activeRequest.approvals.push({ guardianId, approvedAt: Date.now(), signature });
+ this.activeRequest.approvalWeight += guardian.weight;
+ if (this.activeRequest.approvalWeight >= this.config.threshold) {
+ this.activeRequest.status = "approved";
+ }
+ return this.activeRequest;
+ }
+ async cancelRecovery() {
+ if (!this.activeRequest || this.activeRequest.status !== "pending")
+ throw new Error("No pending recovery request to cancel");
+ this.activeRequest.status = "cancelled";
+ this.activeRequest = null;
+ }
+ async completeRecovery() {
+ if (!this.activeRequest)
+ throw new Error("No recovery request");
+ if (this.activeRequest.status !== "approved")
+ throw new Error("Recovery not approved");
+ if (Date.now() < this.activeRequest.completesAt) {
+ const remaining = this.activeRequest.completesAt - Date.now();
+ throw new Error(`Time-lock not expired. ${Math.ceil(remaining / 1000 / 60)} minutes remaining.`);
+ }
+ this.activeRequest.status = "completed";
+ this.activeRequest = null;
+ }
+ getActiveRequest() {
+ return this.activeRequest;
+ }
+ async hashGuardianList() {
+ if (!this.config)
+ return "";
+ const sortedIds = this.config.guardians.map((g) => g.id).sort().join(",");
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sortedIds));
+ return bufferToBase64url(hash);
+ }
+ async saveConfig() {
+ if (!this.config)
+ return;
+ try {
+ localStorage.setItem("encryptid_recovery", JSON.stringify(this.config));
+ } catch {}
+ }
+ loadConfig() {
+ try {
+ const stored = localStorage.getItem("encryptid_recovery");
+ if (stored)
+ this.config = JSON.parse(stored);
+ } catch {}
+ }
+}
+var recoveryManagerInstance = null;
+function getRecoveryManager() {
+ if (!recoveryManagerInstance)
+ recoveryManagerInstance = new RecoveryManager;
+ return recoveryManagerInstance;
+}
+function getGuardianTypeInfo(type) {
+ switch (type) {
+ case "secondary_passkey" /* SECONDARY_PASSKEY */:
+ return { name: "Backup Passkey", description: "Another device you own (phone, YubiKey, etc.)", icon: "key", setupInstructions: "Register a passkey on a second device you control." };
+ case "trusted_contact" /* TRUSTED_CONTACT */:
+ return { name: "Trusted Contact", description: "A friend or family member with their own EncryptID", icon: "user", setupInstructions: "Ask a trusted person to create an EncryptID account." };
+ case "hardware_key" /* HARDWARE_KEY */:
+ return { name: "Hardware Security Key", description: "A YubiKey or similar device stored offline", icon: "shield", setupInstructions: "Register a hardware security key and store it safely." };
+ case "institutional" /* INSTITUTIONAL */:
+ return { name: "Recovery Service", description: "A professional recovery service provider", icon: "building", setupInstructions: "Connect with a trusted recovery service." };
+ case "time_delayed_self" /* TIME_DELAYED_SELF */:
+ return { name: "Time-Delayed Self", description: "Recover yourself after a waiting period", icon: "clock", setupInstructions: "Set up a recovery option that requires waiting before completing." };
+ default:
+ return { name: "Unknown", description: "Unknown guardian type", icon: "question", setupInstructions: "" };
+ }
+}
+
+export { EncryptIDKeyManager, encryptData, decryptData, decryptDataAsString, signData, verifySignature, wrapKeyForRecipient, unwrapSharedKey, getKeyManager, resetKeyManager, OPERATION_PERMISSIONS, SessionManager, getSessionManager, RecoveryManager, getRecoveryManager, getGuardianTypeInfo };
diff --git a/frontend/vendor/@encryptid/sdk/index-2cp5044h.js b/frontend/vendor/@encryptid/sdk/index-2cp5044h.js
new file mode 100644
index 0000000..a119d8c
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-2cp5044h.js
@@ -0,0 +1,194 @@
+// src/client/webauthn.ts
+var DEFAULT_CONFIG = {
+ rpId: "jeffemmett.com",
+ rpName: "EncryptID",
+ origin: typeof window !== "undefined" ? window.location.origin : "",
+ userVerification: "required",
+ timeout: 60000
+};
+var conditionalUIAbortController = null;
+function abortConditionalUI() {
+ if (conditionalUIAbortController) {
+ conditionalUIAbortController.abort();
+ conditionalUIAbortController = null;
+ }
+}
+function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0;i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
+}
+function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
+ const binary = atob(base64 + padding);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0;i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+function generateChallenge() {
+ return crypto.getRandomValues(new Uint8Array(32)).buffer;
+}
+async function generatePRFSalt(purpose) {
+ const encoder = new TextEncoder;
+ const data = encoder.encode(`encryptid-prf-salt-${purpose}-v1`);
+ return crypto.subtle.digest("SHA-256", data);
+}
+async function registerPasskey(username, displayName, config = {}) {
+ abortConditionalUI();
+ const cfg = { ...DEFAULT_CONFIG, ...config };
+ if (!window.PublicKeyCredential) {
+ throw new Error("WebAuthn is not supported in this browser");
+ }
+ const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ const userId = crypto.getRandomValues(new Uint8Array(32));
+ const challenge = generateChallenge();
+ const prfSalt = await generatePRFSalt("master-key");
+ const createOptions = {
+ publicKey: {
+ challenge: new Uint8Array(challenge),
+ rp: { id: cfg.rpId, name: cfg.rpName },
+ user: { id: userId, name: username, displayName },
+ pubKeyCredParams: [
+ { alg: -7, type: "public-key" },
+ { alg: -257, type: "public-key" }
+ ],
+ authenticatorSelection: {
+ residentKey: "required",
+ requireResidentKey: true,
+ userVerification: cfg.userVerification,
+ authenticatorAttachment: platformAvailable ? "platform" : undefined
+ },
+ attestation: "none",
+ timeout: cfg.timeout,
+ extensions: {
+ prf: { eval: { first: new Uint8Array(prfSalt) } },
+ credProps: true
+ }
+ }
+ };
+ const credential = await navigator.credentials.create(createOptions);
+ if (!credential)
+ throw new Error("Failed to create credential");
+ const response = credential.response;
+ const prfSupported = credential.getClientExtensionResults()?.prf?.enabled === true;
+ const publicKey = response.getPublicKey();
+ if (!publicKey)
+ throw new Error("Failed to get public key from credential");
+ return {
+ credentialId: bufferToBase64url(credential.rawId),
+ publicKey,
+ userId: bufferToBase64url(userId.buffer),
+ username,
+ createdAt: Date.now(),
+ prfSupported,
+ transports: response.getTransports?.()
+ };
+}
+async function authenticatePasskey(credentialId, config = {}) {
+ abortConditionalUI();
+ const cfg = { ...DEFAULT_CONFIG, ...config };
+ if (!window.PublicKeyCredential) {
+ throw new Error("WebAuthn is not supported in this browser");
+ }
+ const challenge = generateChallenge();
+ const prfSalt = await generatePRFSalt("master-key");
+ const allowCredentials = credentialId ? [{ type: "public-key", id: new Uint8Array(base64urlToBuffer(credentialId)) }] : undefined;
+ const getOptions = {
+ publicKey: {
+ challenge: new Uint8Array(challenge),
+ rpId: cfg.rpId,
+ allowCredentials,
+ userVerification: cfg.userVerification,
+ timeout: cfg.timeout,
+ extensions: {
+ prf: { eval: { first: new Uint8Array(prfSalt) } }
+ }
+ }
+ };
+ const credential = await navigator.credentials.get(getOptions);
+ if (!credential)
+ throw new Error("Authentication failed");
+ const response = credential.response;
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ return {
+ credentialId: bufferToBase64url(credential.rawId),
+ userId: response.userHandle ? bufferToBase64url(response.userHandle) : "",
+ prfOutput: prfResults?.first,
+ signature: response.signature,
+ authenticatorData: response.authenticatorData
+ };
+}
+async function isConditionalMediationAvailable() {
+ if (!window.PublicKeyCredential)
+ return false;
+ if (typeof PublicKeyCredential.isConditionalMediationAvailable === "function") {
+ return PublicKeyCredential.isConditionalMediationAvailable();
+ }
+ return false;
+}
+async function startConditionalUI(config = {}) {
+ const available = await isConditionalMediationAvailable();
+ if (!available)
+ return null;
+ abortConditionalUI();
+ conditionalUIAbortController = new AbortController;
+ const cfg = { ...DEFAULT_CONFIG, ...config };
+ const challenge = generateChallenge();
+ const prfSalt = await generatePRFSalt("master-key");
+ try {
+ const credential = await navigator.credentials.get({
+ publicKey: {
+ challenge: new Uint8Array(challenge),
+ rpId: cfg.rpId,
+ userVerification: cfg.userVerification,
+ timeout: cfg.timeout,
+ extensions: {
+ prf: { eval: { first: new Uint8Array(prfSalt) } }
+ }
+ },
+ mediation: "conditional",
+ signal: conditionalUIAbortController.signal
+ });
+ conditionalUIAbortController = null;
+ if (!credential)
+ return null;
+ const response = credential.response;
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ return {
+ credentialId: bufferToBase64url(credential.rawId),
+ userId: response.userHandle ? bufferToBase64url(response.userHandle) : "",
+ prfOutput: prfResults?.first,
+ signature: response.signature,
+ authenticatorData: response.authenticatorData
+ };
+ } catch {
+ return null;
+ }
+}
+async function detectCapabilities() {
+ const capabilities = {
+ webauthn: false,
+ platformAuthenticator: false,
+ conditionalUI: false,
+ prfExtension: false
+ };
+ if (!window.PublicKeyCredential)
+ return capabilities;
+ capabilities.webauthn = true;
+ try {
+ capabilities.platformAuthenticator = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ } catch {
+ capabilities.platformAuthenticator = false;
+ }
+ capabilities.conditionalUI = await isConditionalMediationAvailable();
+ capabilities.prfExtension = true;
+ return capabilities;
+}
+
+export { abortConditionalUI, bufferToBase64url, base64urlToBuffer, generateChallenge, registerPasskey, authenticatePasskey, isConditionalMediationAvailable, startConditionalUI, detectCapabilities };
diff --git a/frontend/vendor/@encryptid/sdk/index-2yszamrn.js b/frontend/vendor/@encryptid/sdk/index-2yszamrn.js
new file mode 100644
index 0000000..4e11d3f
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-2yszamrn.js
@@ -0,0 +1,38 @@
+import {
+ verifyEncryptIDToken
+} from "./index-stg63j73.js";
+
+// src/server/ws-auth.ts
+async function authenticateWSUpgrade(request, options = {}) {
+ const url = new URL(request.url);
+ const queryToken = url.searchParams.get("token");
+ if (queryToken) {
+ try {
+ return await verifyEncryptIDToken(queryToken, options);
+ } catch {
+ return null;
+ }
+ }
+ const protocols = request.headers.get("Sec-WebSocket-Protocol") || "";
+ const tokenProtocol = protocols.split(",").map((p) => p.trim()).find((p) => p.startsWith("encryptid."));
+ if (tokenProtocol) {
+ const token = tokenProtocol.slice("encryptid.".length);
+ try {
+ return await verifyEncryptIDToken(token, options);
+ } catch {
+ return null;
+ }
+ }
+ const cookie = request.headers.get("Cookie") || "";
+ const match = cookie.match(/encryptid_token=([^;]+)/);
+ if (match) {
+ try {
+ return await verifyEncryptIDToken(match[1], options);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+export { authenticateWSUpgrade };
diff --git a/frontend/vendor/@encryptid/sdk/index-5c1t4ftn.js b/frontend/vendor/@encryptid/sdk/index-5c1t4ftn.js
new file mode 100644
index 0000000..6a64368
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-5c1t4ftn.js
@@ -0,0 +1,25 @@
+// src/types/index.ts
+var AuthLevel;
+((AuthLevel2) => {
+ AuthLevel2[AuthLevel2["BASIC"] = 1] = "BASIC";
+ AuthLevel2[AuthLevel2["STANDARD"] = 2] = "STANDARD";
+ AuthLevel2[AuthLevel2["ELEVATED"] = 3] = "ELEVATED";
+ AuthLevel2[AuthLevel2["CRITICAL"] = 4] = "CRITICAL";
+})(AuthLevel ||= {});
+var GuardianType;
+((GuardianType2) => {
+ GuardianType2["SECONDARY_PASSKEY"] = "secondary_passkey";
+ GuardianType2["TRUSTED_CONTACT"] = "trusted_contact";
+ GuardianType2["HARDWARE_KEY"] = "hardware_key";
+ GuardianType2["INSTITUTIONAL"] = "institutional";
+ GuardianType2["TIME_DELAYED_SELF"] = "time_delayed_self";
+})(GuardianType ||= {});
+var SpaceVisibility;
+((SpaceVisibility2) => {
+ SpaceVisibility2["PUBLIC"] = "public";
+ SpaceVisibility2["PUBLIC_READ"] = "public_read";
+ SpaceVisibility2["AUTHENTICATED"] = "authenticated";
+ SpaceVisibility2["MEMBERS_ONLY"] = "members_only";
+})(SpaceVisibility ||= {});
+
+export { AuthLevel, GuardianType, SpaceVisibility };
diff --git a/frontend/vendor/@encryptid/sdk/index-7egxprg9.js b/frontend/vendor/@encryptid/sdk/index-7egxprg9.js
new file mode 100644
index 0000000..4230e47
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-7egxprg9.js
@@ -0,0 +1,170 @@
+import {
+ bufferToBase64url
+} from "./index-2cp5044h.js";
+
+// src/client/api-client.ts
+var DEFAULT_SERVER_URL = "https://encryptid.jeffemmett.com";
+
+class EncryptIDClient {
+ serverUrl;
+ constructor(serverUrl = DEFAULT_SERVER_URL) {
+ this.serverUrl = serverUrl.replace(/\/$/, "");
+ }
+ async registerStart(username, displayName) {
+ const res = await fetch(`${this.serverUrl}/api/register/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, displayName: displayName || username })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Registration start failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async registerComplete(challenge, credential, userId, username) {
+ const response = credential.response;
+ const publicKey = response.getPublicKey();
+ const res = await fetch(`${this.serverUrl}/api/register/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ challenge,
+ userId,
+ username,
+ credential: {
+ credentialId: bufferToBase64url(credential.rawId),
+ publicKey: publicKey ? bufferToBase64url(publicKey) : "",
+ transports: response.getTransports?.() || []
+ }
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Registration complete failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async authStart(credentialId) {
+ const res = await fetch(`${this.serverUrl}/api/auth/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(credentialId ? { credentialId } : {})
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Auth start failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async authComplete(challenge, credential) {
+ const response = credential.response;
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ const res = await fetch(`${this.serverUrl}/api/auth/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ challenge,
+ credential: {
+ credentialId: bufferToBase64url(credential.rawId),
+ signature: bufferToBase64url(response.signature),
+ authenticatorData: bufferToBase64url(response.authenticatorData),
+ prfOutput: prfResults?.first ? bufferToBase64url(prfResults.first) : null
+ }
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Auth complete failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async verifySession(token) {
+ const res = await fetch(`${this.serverUrl}/api/session/verify`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ return res.json();
+ }
+ async refreshToken(token) {
+ const res = await fetch(`${this.serverUrl}/api/session/refresh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Token refresh failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async listCredentials(token) {
+ const res = await fetch(`${this.serverUrl}/api/user/credentials`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (!res.ok) {
+ throw new Error("Failed to list credentials");
+ }
+ return res.json();
+ }
+ async register(username, displayName, config) {
+ const { options, userId } = await this.registerStart(username, displayName);
+ const createOptions = {
+ publicKey: {
+ ...options,
+ challenge: base64urlToUint8Array(options.challenge),
+ user: {
+ ...options.user,
+ id: base64urlToUint8Array(options.user.id)
+ },
+ pubKeyCredParams: options.pubKeyCredParams,
+ extensions: {
+ credProps: true,
+ prf: { eval: { first: new Uint8Array(32) } }
+ }
+ }
+ };
+ const credential = await navigator.credentials.create(createOptions);
+ if (!credential)
+ throw new Error("Failed to create credential");
+ return this.registerComplete(options.challenge, credential, userId, username);
+ }
+ async authenticate(credentialId, config) {
+ const { options } = await this.authStart(credentialId);
+ const getOptions = {
+ publicKey: {
+ challenge: base64urlToUint8Array(options.challenge),
+ rpId: options.rpId,
+ userVerification: options.userVerification,
+ timeout: options.timeout,
+ allowCredentials: options.allowCredentials?.map((c) => ({
+ type: c.type,
+ id: base64urlToUint8Array(c.id),
+ transports: c.transports
+ })),
+ extensions: {
+ prf: { eval: { first: new Uint8Array(32) } }
+ }
+ }
+ };
+ const credential = await navigator.credentials.get(getOptions);
+ if (!credential)
+ throw new Error("Authentication failed");
+ const result = await this.authComplete(options.challenge, credential);
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ return {
+ ...result,
+ prfOutput: prfResults?.first
+ };
+ }
+}
+function base64urlToUint8Array(base64url) {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
+ const binary = atob(base64 + padding);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0;i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+
+export { EncryptIDClient };
diff --git a/frontend/vendor/@encryptid/sdk/index-j6kh1974.js b/frontend/vendor/@encryptid/sdk/index-j6kh1974.js
new file mode 100644
index 0000000..8e5266c
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-j6kh1974.js
@@ -0,0 +1,72 @@
+import {
+ verifyEncryptIDToken
+} from "./index-stg63j73.js";
+
+// src/server/space-auth.ts
+async function evaluateSpaceAccess(spaceSlug, token, method, options) {
+ const config = await options.getSpaceConfig(spaceSlug);
+ if (!config) {
+ return { allowed: false, claims: null, reason: "Space not found", isOwner: false, readOnly: false };
+ }
+ let claims = null;
+ if (token) {
+ try {
+ claims = await verifyEncryptIDToken(token, options);
+ } catch {}
+ }
+ const isRead = method === "GET" || method === "HEAD" || method === "OPTIONS";
+ const isOwner = !!(claims && config.ownerDID && claims.sub === config.ownerDID);
+ switch (config.visibility) {
+ case "public" /* PUBLIC */:
+ return { allowed: true, claims, isOwner, readOnly: false };
+ case "public_read" /* PUBLIC_READ */:
+ if (isRead) {
+ return { allowed: true, claims, isOwner, readOnly: !claims };
+ }
+ if (!claims) {
+ return {
+ allowed: false,
+ claims: null,
+ reason: "Authentication required to modify this space",
+ isOwner: false,
+ readOnly: true
+ };
+ }
+ return { allowed: true, claims, isOwner, readOnly: false };
+ case "authenticated" /* AUTHENTICATED */:
+ if (!claims) {
+ return { allowed: false, claims: null, reason: "Authentication required", isOwner: false, readOnly: false };
+ }
+ return { allowed: true, claims, isOwner, readOnly: false };
+ case "members_only" /* MEMBERS_ONLY */:
+ if (!claims) {
+ return { allowed: false, claims: null, reason: "Authentication required", isOwner: false, readOnly: false };
+ }
+ return { allowed: true, claims, isOwner, readOnly: false };
+ default:
+ return { allowed: false, claims: null, reason: "Unknown visibility setting", isOwner: false, readOnly: false };
+ }
+}
+function extractToken(headers) {
+ if (typeof headers.get === "function") {
+ const auth = headers.get("Authorization") || headers.get("authorization");
+ if (auth?.startsWith("Bearer "))
+ return auth.slice(7);
+ const cookie = headers.get("Cookie") || headers.get("cookie") || "";
+ const match = cookie.match(/encryptid_token=([^;]+)/);
+ if (match)
+ return match[1];
+ }
+ if (typeof headers.authorization === "string") {
+ if (headers.authorization.startsWith("Bearer "))
+ return headers.authorization.slice(7);
+ }
+ if (typeof headers.cookie === "string") {
+ const match = headers.cookie.match(/encryptid_token=([^;]+)/);
+ if (match)
+ return match[1];
+ }
+ return null;
+}
+
+export { evaluateSpaceAccess, extractToken };
diff --git a/frontend/vendor/@encryptid/sdk/index-stg63j73.js b/frontend/vendor/@encryptid/sdk/index-stg63j73.js
new file mode 100644
index 0000000..fc9aa73
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index-stg63j73.js
@@ -0,0 +1,118 @@
+// src/server/jwt-verify.ts
+var ENCRYPTID_SERVER = "https://encryptid.jeffemmett.com";
+async function verifyEncryptIDToken(token, options = {}) {
+ const { secret, serverUrl = ENCRYPTID_SERVER, audience, clockTolerance = 30 } = options;
+ if (secret) {
+ return verifyLocally(token, secret, audience, clockTolerance);
+ }
+ return verifyRemotely(token, serverUrl);
+}
+async function verifyLocally(token, secret, audience, clockTolerance = 30) {
+ const parts = token.split(".");
+ if (parts.length !== 3) {
+ throw new Error("Invalid JWT format");
+ }
+ const [headerB64, payloadB64, signatureB64] = parts;
+ const encoder = new TextEncoder;
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
+ const data = encoder.encode(`${headerB64}.${payloadB64}`);
+ const signature = base64urlDecode(signatureB64);
+ const valid = await crypto.subtle.verify("HMAC", key, signature, data);
+ if (!valid) {
+ throw new Error("Invalid JWT signature");
+ }
+ const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadB64)));
+ const now = Math.floor(Date.now() / 1000);
+ if (payload.exp && now > payload.exp + clockTolerance) {
+ throw new Error("Token expired");
+ }
+ if (audience && payload.aud) {
+ const auds = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
+ if (!auds.some((a) => a.includes(audience))) {
+ throw new Error(`Token audience mismatch: expected ${audience}`);
+ }
+ }
+ return payload;
+}
+async function verifyRemotely(token, serverUrl) {
+ const res = await fetch(`${serverUrl}/api/session/verify`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ const data = await res.json();
+ if (!data.valid) {
+ throw new Error(data.error || "Invalid token");
+ }
+ const parts = token.split(".");
+ if (parts.length >= 2) {
+ try {
+ const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1])));
+ return payload;
+ } catch {}
+ }
+ return {
+ iss: serverUrl,
+ sub: data.userId,
+ aud: [],
+ iat: 0,
+ exp: data.exp || 0,
+ jti: "",
+ username: data.username,
+ did: data.did,
+ eid: {
+ authLevel: 2,
+ authTime: 0,
+ capabilities: { encrypt: true, sign: true, wallet: false },
+ recoveryConfigured: false
+ }
+ };
+}
+function getAuthLevel(claims) {
+ if (!claims.eid)
+ return 1;
+ const authAge = Math.floor(Date.now() / 1000) - claims.eid.authTime;
+ if (authAge < 60)
+ return 3;
+ if (authAge < 15 * 60)
+ return 2;
+ return 1;
+}
+function checkPermission(claims, permission) {
+ const currentLevel = getAuthLevel(claims);
+ if (currentLevel < permission.minAuthLevel) {
+ return {
+ allowed: false,
+ reason: `Requires auth level ${permission.minAuthLevel} (current: ${currentLevel})`
+ };
+ }
+ if (permission.requiresCapability) {
+ const has = claims.eid?.capabilities?.[permission.requiresCapability];
+ if (!has) {
+ return {
+ allowed: false,
+ reason: `Requires ${permission.requiresCapability} capability`
+ };
+ }
+ }
+ if (permission.maxAgeSeconds) {
+ const authAge = Math.floor(Date.now() / 1000) - (claims.eid?.authTime || 0);
+ if (authAge > permission.maxAgeSeconds) {
+ return {
+ allowed: false,
+ reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)`
+ };
+ }
+ }
+ return { allowed: true };
+}
+function base64urlDecode(str) {
+ const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
+ const binary = atob(base64 + padding);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0;i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+
+export { verifyEncryptIDToken, getAuthLevel, checkPermission };
diff --git a/frontend/vendor/@encryptid/sdk/index.d.ts b/frontend/vendor/@encryptid/sdk/index.d.ts
new file mode 100644
index 0000000..81b19bc
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index.d.ts
@@ -0,0 +1,15 @@
+/**
+ * @encryptid/sdk — Self-Sovereign Identity SDK
+ *
+ * WebAuthn passkey authentication with derived keys, social recovery,
+ * and cross-app SSO for the r-ecosystem.
+ */
+export type { EncryptIDCredential, AuthenticationResult, EncryptIDConfig, WebAuthnCapabilities, DerivedKeys, EncryptedData, SignedData, EncryptIDClaims, SessionState, OperationPermission, Guardian, RecoveryConfig, RecoveryRequest, RegistrationStartResponse, RegistrationCompleteResponse, AuthStartResponse, AuthCompleteResponse, SessionVerifyResponse, EmailRecoverySetResponse, EmailRecoveryRequestResponse, EmailRecoveryVerifyResponse, } from './types/index.js';
+export { AuthLevel, GuardianType } from './types/index.js';
+export { EncryptIDClient } from './client/api-client.js';
+export { registerPasskey, authenticatePasskey, startConditionalUI, detectCapabilities, bufferToBase64url, base64urlToBuffer, } from './client/webauthn.js';
+export { EncryptIDKeyManager, getKeyManager, signEthHash } from './client/key-derivation.js';
+export { SessionManager, getSessionManager, OPERATION_PERMISSIONS } from './client/session.js';
+export { RecoveryManager, getRecoveryManager, getGuardianTypeInfo } from './client/recovery.js';
+export declare const VERSION = "0.1.0";
+export declare const SPEC_VERSION = "2026-02";
diff --git a/frontend/vendor/@encryptid/sdk/index.js b/frontend/vendor/@encryptid/sdk/index.js
new file mode 100644
index 0000000..f1d678a
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/index.js
@@ -0,0 +1,3618 @@
+// src/types/roles.ts
+var SPACE_ROLE_LEVEL = {
+ ["viewer" /* VIEWER */]: 0,
+ ["participant" /* PARTICIPANT */]: 1,
+ ["moderator" /* MODERATOR */]: 2,
+ ["admin" /* ADMIN */]: 3
+};
+
+// src/types/index.ts
+var AuthLevel;
+((AuthLevel2) => {
+ AuthLevel2[AuthLevel2["BASIC"] = 1] = "BASIC";
+ AuthLevel2[AuthLevel2["STANDARD"] = 2] = "STANDARD";
+ AuthLevel2[AuthLevel2["ELEVATED"] = 3] = "ELEVATED";
+ AuthLevel2[AuthLevel2["CRITICAL"] = 4] = "CRITICAL";
+})(AuthLevel ||= {});
+var GuardianType;
+((GuardianType2) => {
+ GuardianType2["SECONDARY_PASSKEY"] = "secondary_passkey";
+ GuardianType2["TRUSTED_CONTACT"] = "trusted_contact";
+ GuardianType2["HARDWARE_KEY"] = "hardware_key";
+ GuardianType2["INSTITUTIONAL"] = "institutional";
+ GuardianType2["TIME_DELAYED_SELF"] = "time_delayed_self";
+})(GuardianType ||= {});
+// src/client/webauthn.ts
+var DEFAULT_CONFIG = {
+ rpId: "jeffemmett.com",
+ rpName: "EncryptID",
+ origin: typeof window !== "undefined" ? window.location.origin : "",
+ userVerification: "required",
+ timeout: 60000
+};
+var conditionalUIAbortController = null;
+function abortConditionalUI() {
+ if (conditionalUIAbortController) {
+ conditionalUIAbortController.abort();
+ conditionalUIAbortController = null;
+ }
+}
+function bufferToBase64url(buffer) {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (let i = 0;i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
+}
+function base64urlToBuffer(base64url) {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
+ const binary = atob(base64 + padding);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0;i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes.buffer;
+}
+function generateChallenge() {
+ return crypto.getRandomValues(new Uint8Array(32)).buffer;
+}
+async function generatePRFSalt(purpose) {
+ const encoder = new TextEncoder;
+ const data = encoder.encode(`encryptid-prf-salt-${purpose}-v1`);
+ return crypto.subtle.digest("SHA-256", data);
+}
+async function registerPasskey(username, displayName, config = {}) {
+ abortConditionalUI();
+ const cfg = { ...DEFAULT_CONFIG, ...config };
+ if (!window.PublicKeyCredential) {
+ throw new Error("WebAuthn is not supported in this browser");
+ }
+ const platformAvailable = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ const userId = crypto.getRandomValues(new Uint8Array(32));
+ const challenge = generateChallenge();
+ const prfSalt = await generatePRFSalt("master-key");
+ const createOptions = {
+ publicKey: {
+ challenge: new Uint8Array(challenge),
+ rp: { id: cfg.rpId, name: cfg.rpName },
+ user: { id: userId, name: username, displayName },
+ pubKeyCredParams: [
+ { alg: -7, type: "public-key" },
+ { alg: -257, type: "public-key" }
+ ],
+ authenticatorSelection: {
+ residentKey: "required",
+ requireResidentKey: true,
+ userVerification: cfg.userVerification,
+ authenticatorAttachment: platformAvailable ? "platform" : undefined
+ },
+ attestation: "none",
+ timeout: cfg.timeout,
+ extensions: {
+ prf: { eval: { first: new Uint8Array(prfSalt) } },
+ credProps: true
+ }
+ }
+ };
+ const credential = await navigator.credentials.create(createOptions);
+ if (!credential)
+ throw new Error("Failed to create credential");
+ const response = credential.response;
+ const prfSupported = credential.getClientExtensionResults()?.prf?.enabled === true;
+ const publicKey = response.getPublicKey();
+ if (!publicKey)
+ throw new Error("Failed to get public key from credential");
+ return {
+ credentialId: bufferToBase64url(credential.rawId),
+ publicKey,
+ userId: bufferToBase64url(userId.buffer),
+ username,
+ createdAt: Date.now(),
+ prfSupported,
+ transports: response.getTransports?.()
+ };
+}
+async function authenticatePasskey(credentialId, config = {}) {
+ abortConditionalUI();
+ const cfg = { ...DEFAULT_CONFIG, ...config };
+ if (!window.PublicKeyCredential) {
+ throw new Error("WebAuthn is not supported in this browser");
+ }
+ const challenge = generateChallenge();
+ const prfSalt = await generatePRFSalt("master-key");
+ const allowCredentials = credentialId ? [{ type: "public-key", id: new Uint8Array(base64urlToBuffer(credentialId)) }] : undefined;
+ const getOptions = {
+ publicKey: {
+ challenge: new Uint8Array(challenge),
+ rpId: cfg.rpId,
+ allowCredentials,
+ userVerification: cfg.userVerification,
+ timeout: cfg.timeout,
+ extensions: {
+ prf: { eval: { first: new Uint8Array(prfSalt) } }
+ }
+ }
+ };
+ const credential = await navigator.credentials.get(getOptions);
+ if (!credential)
+ throw new Error("Authentication failed");
+ const response = credential.response;
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ return {
+ credentialId: bufferToBase64url(credential.rawId),
+ userId: response.userHandle ? bufferToBase64url(response.userHandle) : "",
+ prfOutput: prfResults?.first,
+ signature: response.signature,
+ authenticatorData: response.authenticatorData
+ };
+}
+async function isConditionalMediationAvailable() {
+ if (!window.PublicKeyCredential)
+ return false;
+ if (typeof PublicKeyCredential.isConditionalMediationAvailable === "function") {
+ return PublicKeyCredential.isConditionalMediationAvailable();
+ }
+ return false;
+}
+async function startConditionalUI(config = {}) {
+ const available = await isConditionalMediationAvailable();
+ if (!available)
+ return null;
+ abortConditionalUI();
+ conditionalUIAbortController = new AbortController;
+ const cfg = { ...DEFAULT_CONFIG, ...config };
+ const challenge = generateChallenge();
+ const prfSalt = await generatePRFSalt("master-key");
+ try {
+ const credential = await navigator.credentials.get({
+ publicKey: {
+ challenge: new Uint8Array(challenge),
+ rpId: cfg.rpId,
+ userVerification: cfg.userVerification,
+ timeout: cfg.timeout,
+ extensions: {
+ prf: { eval: { first: new Uint8Array(prfSalt) } }
+ }
+ },
+ mediation: "conditional",
+ signal: conditionalUIAbortController.signal
+ });
+ conditionalUIAbortController = null;
+ if (!credential)
+ return null;
+ const response = credential.response;
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ return {
+ credentialId: bufferToBase64url(credential.rawId),
+ userId: response.userHandle ? bufferToBase64url(response.userHandle) : "",
+ prfOutput: prfResults?.first,
+ signature: response.signature,
+ authenticatorData: response.authenticatorData
+ };
+ } catch {
+ return null;
+ }
+}
+async function detectCapabilities() {
+ const capabilities = {
+ webauthn: false,
+ platformAuthenticator: false,
+ conditionalUI: false,
+ prfExtension: false
+ };
+ if (!window.PublicKeyCredential)
+ return capabilities;
+ capabilities.webauthn = true;
+ try {
+ capabilities.platformAuthenticator = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ } catch {
+ capabilities.platformAuthenticator = false;
+ }
+ capabilities.conditionalUI = await isConditionalMediationAvailable();
+ capabilities.prfExtension = true;
+ return capabilities;
+}
+
+// src/client/api-client.ts
+var DEFAULT_SERVER_URL = "https://encryptid.jeffemmett.com";
+
+class EncryptIDClient {
+ serverUrl;
+ constructor(serverUrl = DEFAULT_SERVER_URL) {
+ this.serverUrl = serverUrl.replace(/\/$/, "");
+ }
+ async registerStart(username, displayName) {
+ const res = await fetch(`${this.serverUrl}/api/register/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, displayName: displayName || username })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Registration start failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async registerComplete(challenge, credential, userId, username) {
+ const response = credential.response;
+ const publicKey = response.getPublicKey();
+ const res = await fetch(`${this.serverUrl}/api/register/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ challenge,
+ userId,
+ username,
+ credential: {
+ credentialId: bufferToBase64url(credential.rawId),
+ publicKey: publicKey ? bufferToBase64url(publicKey) : "",
+ transports: response.getTransports?.() || []
+ }
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Registration complete failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async authStart(credentialId) {
+ const res = await fetch(`${this.serverUrl}/api/auth/start`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(credentialId ? { credentialId } : {})
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Auth start failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async authComplete(challenge, credential) {
+ const response = credential.response;
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ const res = await fetch(`${this.serverUrl}/api/auth/complete`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ challenge,
+ credential: {
+ credentialId: bufferToBase64url(credential.rawId),
+ signature: bufferToBase64url(response.signature),
+ authenticatorData: bufferToBase64url(response.authenticatorData),
+ prfOutput: prfResults?.first ? bufferToBase64url(prfResults.first) : null
+ }
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Auth complete failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async verifySession(token) {
+ const res = await fetch(`${this.serverUrl}/api/session/verify`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ return res.json();
+ }
+ async refreshToken(token) {
+ const res = await fetch(`${this.serverUrl}/api/session/refresh`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Token refresh failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async listCredentials(token) {
+ const res = await fetch(`${this.serverUrl}/api/user/credentials`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (!res.ok) {
+ throw new Error("Failed to list credentials");
+ }
+ return res.json();
+ }
+ async setRecoveryEmail(token, email) {
+ const res = await fetch(`${this.serverUrl}/api/recovery/email/set`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`
+ },
+ body: JSON.stringify({ email })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Failed to set recovery email" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async requestEmailRecovery(email) {
+ const res = await fetch(`${this.serverUrl}/api/recovery/email/request`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Recovery request failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async verifyRecoveryToken(recoveryToken) {
+ const res = await fetch(`${this.serverUrl}/api/recovery/email/verify`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: recoveryToken })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: "Recovery verification failed" }));
+ throw new Error(err.error || `HTTP ${res.status}`);
+ }
+ return res.json();
+ }
+ async register(username, displayName, config) {
+ const { options, userId } = await this.registerStart(username, displayName);
+ const createOptions = {
+ publicKey: {
+ ...options,
+ challenge: base64urlToUint8Array(options.challenge),
+ user: {
+ ...options.user,
+ id: base64urlToUint8Array(options.user.id)
+ },
+ pubKeyCredParams: options.pubKeyCredParams,
+ extensions: {
+ credProps: true,
+ prf: { eval: { first: new Uint8Array(32) } }
+ }
+ }
+ };
+ const credential = await navigator.credentials.create(createOptions);
+ if (!credential)
+ throw new Error("Failed to create credential");
+ return this.registerComplete(options.challenge, credential, userId, username);
+ }
+ async authenticate(credentialId, config) {
+ const { options } = await this.authStart(credentialId);
+ const getOptions = {
+ publicKey: {
+ challenge: base64urlToUint8Array(options.challenge),
+ rpId: options.rpId,
+ userVerification: options.userVerification,
+ timeout: options.timeout,
+ allowCredentials: options.allowCredentials?.map((c) => ({
+ type: c.type,
+ id: base64urlToUint8Array(c.id),
+ transports: c.transports
+ })),
+ extensions: {
+ prf: { eval: { first: new Uint8Array(32) } }
+ }
+ }
+ };
+ const credential = await navigator.credentials.get(getOptions);
+ if (!credential)
+ throw new Error("Authentication failed");
+ const result = await this.authComplete(options.challenge, credential);
+ const prfResults = credential.getClientExtensionResults()?.prf?.results;
+ return {
+ ...result,
+ prfOutput: prfResults?.first
+ };
+ }
+}
+function base64urlToUint8Array(base64url) {
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
+ const padding = "=".repeat((4 - base64.length % 4) % 4);
+ const binary = atob(base64 + padding);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0;i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return bytes;
+}
+// node_modules/@noble/hashes/utils.js
+/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */
+function isBytes(a) {
+ return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array";
+}
+function anumber(n, title = "") {
+ if (!Number.isSafeInteger(n) || n < 0) {
+ const prefix = title && `"${title}" `;
+ throw new Error(`${prefix}expected integer >= 0, got ${n}`);
+ }
+}
+function abytes(value, length, title = "") {
+ const bytes = isBytes(value);
+ const len = value?.length;
+ const needsLen = length !== undefined;
+ if (!bytes || needsLen && len !== length) {
+ const prefix = title && `"${title}" `;
+ const ofLen = needsLen ? ` of length ${length}` : "";
+ const got = bytes ? `length=${len}` : `type=${typeof value}`;
+ throw new Error(prefix + "expected Uint8Array" + ofLen + ", got " + got);
+ }
+ return value;
+}
+function ahash(h) {
+ if (typeof h !== "function" || typeof h.create !== "function")
+ throw new Error("Hash must wrapped by utils.createHasher");
+ anumber(h.outputLen);
+ anumber(h.blockLen);
+}
+function aexists(instance, checkFinished = true) {
+ if (instance.destroyed)
+ throw new Error("Hash instance has been destroyed");
+ if (checkFinished && instance.finished)
+ throw new Error("Hash#digest() has already been called");
+}
+function aoutput(out, instance) {
+ abytes(out, undefined, "digestInto() output");
+ const min = instance.outputLen;
+ if (out.length < min) {
+ throw new Error('"digestInto() output" expected to be of length >=' + min);
+ }
+}
+function u32(arr) {
+ return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4));
+}
+function clean(...arrays) {
+ for (let i = 0;i < arrays.length; i++) {
+ arrays[i].fill(0);
+ }
+}
+function createView(arr) {
+ return new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
+}
+function rotr(word, shift) {
+ return word << 32 - shift | word >>> shift;
+}
+var isLE = /* @__PURE__ */ (() => new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68)();
+function byteSwap(word) {
+ return word << 24 & 4278190080 | word << 8 & 16711680 | word >>> 8 & 65280 | word >>> 24 & 255;
+}
+function byteSwap32(arr) {
+ for (let i = 0;i < arr.length; i++) {
+ arr[i] = byteSwap(arr[i]);
+ }
+ return arr;
+}
+var swap32IfBE = isLE ? (u) => u : byteSwap32;
+var hasHexBuiltin = /* @__PURE__ */ (() => typeof Uint8Array.from([]).toHex === "function" && typeof Uint8Array.fromHex === "function")();
+var hexes = /* @__PURE__ */ Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
+function bytesToHex(bytes) {
+ abytes(bytes);
+ if (hasHexBuiltin)
+ return bytes.toHex();
+ let hex = "";
+ for (let i = 0;i < bytes.length; i++) {
+ hex += hexes[bytes[i]];
+ }
+ return hex;
+}
+var asciis = { _0: 48, _9: 57, A: 65, F: 70, a: 97, f: 102 };
+function asciiToBase16(ch) {
+ if (ch >= asciis._0 && ch <= asciis._9)
+ return ch - asciis._0;
+ if (ch >= asciis.A && ch <= asciis.F)
+ return ch - (asciis.A - 10);
+ if (ch >= asciis.a && ch <= asciis.f)
+ return ch - (asciis.a - 10);
+ return;
+}
+function hexToBytes(hex) {
+ if (typeof hex !== "string")
+ throw new Error("hex string expected, got " + typeof hex);
+ if (hasHexBuiltin)
+ return Uint8Array.fromHex(hex);
+ const hl = hex.length;
+ const al = hl / 2;
+ if (hl % 2)
+ throw new Error("hex string expected, got unpadded hex of length " + hl);
+ const array = new Uint8Array(al);
+ for (let ai = 0, hi = 0;ai < al; ai++, hi += 2) {
+ const n1 = asciiToBase16(hex.charCodeAt(hi));
+ const n2 = asciiToBase16(hex.charCodeAt(hi + 1));
+ if (n1 === undefined || n2 === undefined) {
+ const char = hex[hi] + hex[hi + 1];
+ throw new Error('hex string expected, got non-hex character "' + char + '" at index ' + hi);
+ }
+ array[ai] = n1 * 16 + n2;
+ }
+ return array;
+}
+function concatBytes(...arrays) {
+ let sum = 0;
+ for (let i = 0;i < arrays.length; i++) {
+ const a = arrays[i];
+ abytes(a);
+ sum += a.length;
+ }
+ const res = new Uint8Array(sum);
+ for (let i = 0, pad = 0;i < arrays.length; i++) {
+ const a = arrays[i];
+ res.set(a, pad);
+ pad += a.length;
+ }
+ return res;
+}
+function createHasher(hashCons, info = {}) {
+ const hashC = (msg, opts) => hashCons(opts).update(msg).digest();
+ const tmp = hashCons(undefined);
+ hashC.outputLen = tmp.outputLen;
+ hashC.blockLen = tmp.blockLen;
+ hashC.create = (opts) => hashCons(opts);
+ Object.assign(hashC, info);
+ return Object.freeze(hashC);
+}
+function randomBytes(bytesLength = 32) {
+ const cr = typeof globalThis === "object" ? globalThis.crypto : null;
+ if (typeof cr?.getRandomValues !== "function")
+ throw new Error("crypto.getRandomValues must be defined");
+ return cr.getRandomValues(new Uint8Array(bytesLength));
+}
+var oidNist = (suffix) => ({
+ oid: Uint8Array.from([6, 9, 96, 134, 72, 1, 101, 3, 4, 2, suffix])
+});
+
+// node_modules/@noble/hashes/_md.js
+function Chi(a, b, c) {
+ return a & b ^ ~a & c;
+}
+function Maj(a, b, c) {
+ return a & b ^ a & c ^ b & c;
+}
+
+class HashMD {
+ blockLen;
+ outputLen;
+ padOffset;
+ isLE;
+ buffer;
+ view;
+ finished = false;
+ length = 0;
+ pos = 0;
+ destroyed = false;
+ constructor(blockLen, outputLen, padOffset, isLE2) {
+ this.blockLen = blockLen;
+ this.outputLen = outputLen;
+ this.padOffset = padOffset;
+ this.isLE = isLE2;
+ this.buffer = new Uint8Array(blockLen);
+ this.view = createView(this.buffer);
+ }
+ update(data) {
+ aexists(this);
+ abytes(data);
+ const { view, buffer, blockLen } = this;
+ const len = data.length;
+ for (let pos = 0;pos < len; ) {
+ const take = Math.min(blockLen - this.pos, len - pos);
+ if (take === blockLen) {
+ const dataView = createView(data);
+ for (;blockLen <= len - pos; pos += blockLen)
+ this.process(dataView, pos);
+ continue;
+ }
+ buffer.set(data.subarray(pos, pos + take), this.pos);
+ this.pos += take;
+ pos += take;
+ if (this.pos === blockLen) {
+ this.process(view, 0);
+ this.pos = 0;
+ }
+ }
+ this.length += data.length;
+ this.roundClean();
+ return this;
+ }
+ digestInto(out) {
+ aexists(this);
+ aoutput(out, this);
+ this.finished = true;
+ const { buffer, view, blockLen, isLE: isLE2 } = this;
+ let { pos } = this;
+ buffer[pos++] = 128;
+ clean(this.buffer.subarray(pos));
+ if (this.padOffset > blockLen - pos) {
+ this.process(view, 0);
+ pos = 0;
+ }
+ for (let i = pos;i < blockLen; i++)
+ buffer[i] = 0;
+ view.setBigUint64(blockLen - 8, BigInt(this.length * 8), isLE2);
+ this.process(view, 0);
+ const oview = createView(out);
+ const len = this.outputLen;
+ if (len % 4)
+ throw new Error("_sha2: outputLen must be aligned to 32bit");
+ const outLen = len / 4;
+ const state = this.get();
+ if (outLen > state.length)
+ throw new Error("_sha2: outputLen bigger than state");
+ for (let i = 0;i < outLen; i++)
+ oview.setUint32(4 * i, state[i], isLE2);
+ }
+ digest() {
+ const { buffer, outputLen } = this;
+ this.digestInto(buffer);
+ const res = buffer.slice(0, outputLen);
+ this.destroy();
+ return res;
+ }
+ _cloneInto(to) {
+ to ||= new this.constructor;
+ to.set(...this.get());
+ const { blockLen, buffer, length, finished, destroyed, pos } = this;
+ to.destroyed = destroyed;
+ to.finished = finished;
+ to.length = length;
+ to.pos = pos;
+ if (length % blockLen)
+ to.buffer.set(buffer);
+ return to;
+ }
+ clone() {
+ return this._cloneInto();
+ }
+}
+var SHA256_IV = /* @__PURE__ */ Uint32Array.from([
+ 1779033703,
+ 3144134277,
+ 1013904242,
+ 2773480762,
+ 1359893119,
+ 2600822924,
+ 528734635,
+ 1541459225
+]);
+var SHA224_IV = /* @__PURE__ */ Uint32Array.from([
+ 3238371032,
+ 914150663,
+ 812702999,
+ 4144912697,
+ 4290775857,
+ 1750603025,
+ 1694076839,
+ 3204075428
+]);
+var SHA384_IV = /* @__PURE__ */ Uint32Array.from([
+ 3418070365,
+ 3238371032,
+ 1654270250,
+ 914150663,
+ 2438529370,
+ 812702999,
+ 355462360,
+ 4144912697,
+ 1731405415,
+ 4290775857,
+ 2394180231,
+ 1750603025,
+ 3675008525,
+ 1694076839,
+ 1203062813,
+ 3204075428
+]);
+var SHA512_IV = /* @__PURE__ */ Uint32Array.from([
+ 1779033703,
+ 4089235720,
+ 3144134277,
+ 2227873595,
+ 1013904242,
+ 4271175723,
+ 2773480762,
+ 1595750129,
+ 1359893119,
+ 2917565137,
+ 2600822924,
+ 725511199,
+ 528734635,
+ 4215389547,
+ 1541459225,
+ 327033209
+]);
+
+// node_modules/@noble/hashes/_u64.js
+var U32_MASK64 = /* @__PURE__ */ BigInt(2 ** 32 - 1);
+var _32n = /* @__PURE__ */ BigInt(32);
+function fromBig(n, le = false) {
+ if (le)
+ return { h: Number(n & U32_MASK64), l: Number(n >> _32n & U32_MASK64) };
+ return { h: Number(n >> _32n & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 };
+}
+function split(lst, le = false) {
+ const len = lst.length;
+ let Ah = new Uint32Array(len);
+ let Al = new Uint32Array(len);
+ for (let i = 0;i < len; i++) {
+ const { h, l } = fromBig(lst[i], le);
+ [Ah[i], Al[i]] = [h, l];
+ }
+ return [Ah, Al];
+}
+var shrSH = (h, _l, s) => h >>> s;
+var shrSL = (h, l, s) => h << 32 - s | l >>> s;
+var rotrSH = (h, l, s) => h >>> s | l << 32 - s;
+var rotrSL = (h, l, s) => h << 32 - s | l >>> s;
+var rotrBH = (h, l, s) => h << 64 - s | l >>> s - 32;
+var rotrBL = (h, l, s) => h >>> s - 32 | l << 64 - s;
+var rotlSH = (h, l, s) => h << s | l >>> 32 - s;
+var rotlSL = (h, l, s) => l << s | h >>> 32 - s;
+var rotlBH = (h, l, s) => l << s - 32 | h >>> 64 - s;
+var rotlBL = (h, l, s) => h << s - 32 | l >>> 64 - s;
+function add(Ah, Al, Bh, Bl) {
+ const l = (Al >>> 0) + (Bl >>> 0);
+ return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 };
+}
+var add3L = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0);
+var add3H = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0;
+var add4L = (Al, Bl, Cl, Dl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0);
+var add4H = (low, Ah, Bh, Ch, Dh) => Ah + Bh + Ch + Dh + (low / 2 ** 32 | 0) | 0;
+var add5L = (Al, Bl, Cl, Dl, El) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0) + (Dl >>> 0) + (El >>> 0);
+var add5H = (low, Ah, Bh, Ch, Dh, Eh) => Ah + Bh + Ch + Dh + Eh + (low / 2 ** 32 | 0) | 0;
+
+// node_modules/@noble/hashes/sha2.js
+var SHA256_K = /* @__PURE__ */ Uint32Array.from([
+ 1116352408,
+ 1899447441,
+ 3049323471,
+ 3921009573,
+ 961987163,
+ 1508970993,
+ 2453635748,
+ 2870763221,
+ 3624381080,
+ 310598401,
+ 607225278,
+ 1426881987,
+ 1925078388,
+ 2162078206,
+ 2614888103,
+ 3248222580,
+ 3835390401,
+ 4022224774,
+ 264347078,
+ 604807628,
+ 770255983,
+ 1249150122,
+ 1555081692,
+ 1996064986,
+ 2554220882,
+ 2821834349,
+ 2952996808,
+ 3210313671,
+ 3336571891,
+ 3584528711,
+ 113926993,
+ 338241895,
+ 666307205,
+ 773529912,
+ 1294757372,
+ 1396182291,
+ 1695183700,
+ 1986661051,
+ 2177026350,
+ 2456956037,
+ 2730485921,
+ 2820302411,
+ 3259730800,
+ 3345764771,
+ 3516065817,
+ 3600352804,
+ 4094571909,
+ 275423344,
+ 430227734,
+ 506948616,
+ 659060556,
+ 883997877,
+ 958139571,
+ 1322822218,
+ 1537002063,
+ 1747873779,
+ 1955562222,
+ 2024104815,
+ 2227730452,
+ 2361852424,
+ 2428436474,
+ 2756734187,
+ 3204031479,
+ 3329325298
+]);
+var SHA256_W = /* @__PURE__ */ new Uint32Array(64);
+
+class SHA2_32B extends HashMD {
+ constructor(outputLen) {
+ super(64, outputLen, 8, false);
+ }
+ get() {
+ const { A, B, C, D, E, F, G, H } = this;
+ return [A, B, C, D, E, F, G, H];
+ }
+ set(A, B, C, D, E, F, G, H) {
+ this.A = A | 0;
+ this.B = B | 0;
+ this.C = C | 0;
+ this.D = D | 0;
+ this.E = E | 0;
+ this.F = F | 0;
+ this.G = G | 0;
+ this.H = H | 0;
+ }
+ process(view, offset) {
+ for (let i = 0;i < 16; i++, offset += 4)
+ SHA256_W[i] = view.getUint32(offset, false);
+ for (let i = 16;i < 64; i++) {
+ const W15 = SHA256_W[i - 15];
+ const W2 = SHA256_W[i - 2];
+ const s0 = rotr(W15, 7) ^ rotr(W15, 18) ^ W15 >>> 3;
+ const s1 = rotr(W2, 17) ^ rotr(W2, 19) ^ W2 >>> 10;
+ SHA256_W[i] = s1 + SHA256_W[i - 7] + s0 + SHA256_W[i - 16] | 0;
+ }
+ let { A, B, C, D, E, F, G, H } = this;
+ for (let i = 0;i < 64; i++) {
+ const sigma1 = rotr(E, 6) ^ rotr(E, 11) ^ rotr(E, 25);
+ const T1 = H + sigma1 + Chi(E, F, G) + SHA256_K[i] + SHA256_W[i] | 0;
+ const sigma0 = rotr(A, 2) ^ rotr(A, 13) ^ rotr(A, 22);
+ const T2 = sigma0 + Maj(A, B, C) | 0;
+ H = G;
+ G = F;
+ F = E;
+ E = D + T1 | 0;
+ D = C;
+ C = B;
+ B = A;
+ A = T1 + T2 | 0;
+ }
+ A = A + this.A | 0;
+ B = B + this.B | 0;
+ C = C + this.C | 0;
+ D = D + this.D | 0;
+ E = E + this.E | 0;
+ F = F + this.F | 0;
+ G = G + this.G | 0;
+ H = H + this.H | 0;
+ this.set(A, B, C, D, E, F, G, H);
+ }
+ roundClean() {
+ clean(SHA256_W);
+ }
+ destroy() {
+ this.set(0, 0, 0, 0, 0, 0, 0, 0);
+ clean(this.buffer);
+ }
+}
+
+class _SHA256 extends SHA2_32B {
+ A = SHA256_IV[0] | 0;
+ B = SHA256_IV[1] | 0;
+ C = SHA256_IV[2] | 0;
+ D = SHA256_IV[3] | 0;
+ E = SHA256_IV[4] | 0;
+ F = SHA256_IV[5] | 0;
+ G = SHA256_IV[6] | 0;
+ H = SHA256_IV[7] | 0;
+ constructor() {
+ super(32);
+ }
+}
+
+class _SHA224 extends SHA2_32B {
+ A = SHA224_IV[0] | 0;
+ B = SHA224_IV[1] | 0;
+ C = SHA224_IV[2] | 0;
+ D = SHA224_IV[3] | 0;
+ E = SHA224_IV[4] | 0;
+ F = SHA224_IV[5] | 0;
+ G = SHA224_IV[6] | 0;
+ H = SHA224_IV[7] | 0;
+ constructor() {
+ super(28);
+ }
+}
+var K512 = /* @__PURE__ */ (() => split([
+ "0x428a2f98d728ae22",
+ "0x7137449123ef65cd",
+ "0xb5c0fbcfec4d3b2f",
+ "0xe9b5dba58189dbbc",
+ "0x3956c25bf348b538",
+ "0x59f111f1b605d019",
+ "0x923f82a4af194f9b",
+ "0xab1c5ed5da6d8118",
+ "0xd807aa98a3030242",
+ "0x12835b0145706fbe",
+ "0x243185be4ee4b28c",
+ "0x550c7dc3d5ffb4e2",
+ "0x72be5d74f27b896f",
+ "0x80deb1fe3b1696b1",
+ "0x9bdc06a725c71235",
+ "0xc19bf174cf692694",
+ "0xe49b69c19ef14ad2",
+ "0xefbe4786384f25e3",
+ "0x0fc19dc68b8cd5b5",
+ "0x240ca1cc77ac9c65",
+ "0x2de92c6f592b0275",
+ "0x4a7484aa6ea6e483",
+ "0x5cb0a9dcbd41fbd4",
+ "0x76f988da831153b5",
+ "0x983e5152ee66dfab",
+ "0xa831c66d2db43210",
+ "0xb00327c898fb213f",
+ "0xbf597fc7beef0ee4",
+ "0xc6e00bf33da88fc2",
+ "0xd5a79147930aa725",
+ "0x06ca6351e003826f",
+ "0x142929670a0e6e70",
+ "0x27b70a8546d22ffc",
+ "0x2e1b21385c26c926",
+ "0x4d2c6dfc5ac42aed",
+ "0x53380d139d95b3df",
+ "0x650a73548baf63de",
+ "0x766a0abb3c77b2a8",
+ "0x81c2c92e47edaee6",
+ "0x92722c851482353b",
+ "0xa2bfe8a14cf10364",
+ "0xa81a664bbc423001",
+ "0xc24b8b70d0f89791",
+ "0xc76c51a30654be30",
+ "0xd192e819d6ef5218",
+ "0xd69906245565a910",
+ "0xf40e35855771202a",
+ "0x106aa07032bbd1b8",
+ "0x19a4c116b8d2d0c8",
+ "0x1e376c085141ab53",
+ "0x2748774cdf8eeb99",
+ "0x34b0bcb5e19b48a8",
+ "0x391c0cb3c5c95a63",
+ "0x4ed8aa4ae3418acb",
+ "0x5b9cca4f7763e373",
+ "0x682e6ff3d6b2b8a3",
+ "0x748f82ee5defb2fc",
+ "0x78a5636f43172f60",
+ "0x84c87814a1f0ab72",
+ "0x8cc702081a6439ec",
+ "0x90befffa23631e28",
+ "0xa4506cebde82bde9",
+ "0xbef9a3f7b2c67915",
+ "0xc67178f2e372532b",
+ "0xca273eceea26619c",
+ "0xd186b8c721c0c207",
+ "0xeada7dd6cde0eb1e",
+ "0xf57d4f7fee6ed178",
+ "0x06f067aa72176fba",
+ "0x0a637dc5a2c898a6",
+ "0x113f9804bef90dae",
+ "0x1b710b35131c471b",
+ "0x28db77f523047d84",
+ "0x32caab7b40c72493",
+ "0x3c9ebe0a15c9bebc",
+ "0x431d67c49c100d4c",
+ "0x4cc5d4becb3e42b6",
+ "0x597f299cfc657e2a",
+ "0x5fcb6fab3ad6faec",
+ "0x6c44198c4a475817"
+].map((n) => BigInt(n))))();
+var SHA512_Kh = /* @__PURE__ */ (() => K512[0])();
+var SHA512_Kl = /* @__PURE__ */ (() => K512[1])();
+var SHA512_W_H = /* @__PURE__ */ new Uint32Array(80);
+var SHA512_W_L = /* @__PURE__ */ new Uint32Array(80);
+
+class SHA2_64B extends HashMD {
+ constructor(outputLen) {
+ super(128, outputLen, 16, false);
+ }
+ get() {
+ const { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
+ return [Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl];
+ }
+ set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl) {
+ this.Ah = Ah | 0;
+ this.Al = Al | 0;
+ this.Bh = Bh | 0;
+ this.Bl = Bl | 0;
+ this.Ch = Ch | 0;
+ this.Cl = Cl | 0;
+ this.Dh = Dh | 0;
+ this.Dl = Dl | 0;
+ this.Eh = Eh | 0;
+ this.El = El | 0;
+ this.Fh = Fh | 0;
+ this.Fl = Fl | 0;
+ this.Gh = Gh | 0;
+ this.Gl = Gl | 0;
+ this.Hh = Hh | 0;
+ this.Hl = Hl | 0;
+ }
+ process(view, offset) {
+ for (let i = 0;i < 16; i++, offset += 4) {
+ SHA512_W_H[i] = view.getUint32(offset);
+ SHA512_W_L[i] = view.getUint32(offset += 4);
+ }
+ for (let i = 16;i < 80; i++) {
+ const W15h = SHA512_W_H[i - 15] | 0;
+ const W15l = SHA512_W_L[i - 15] | 0;
+ const s0h = rotrSH(W15h, W15l, 1) ^ rotrSH(W15h, W15l, 8) ^ shrSH(W15h, W15l, 7);
+ const s0l = rotrSL(W15h, W15l, 1) ^ rotrSL(W15h, W15l, 8) ^ shrSL(W15h, W15l, 7);
+ const W2h = SHA512_W_H[i - 2] | 0;
+ const W2l = SHA512_W_L[i - 2] | 0;
+ const s1h = rotrSH(W2h, W2l, 19) ^ rotrBH(W2h, W2l, 61) ^ shrSH(W2h, W2l, 6);
+ const s1l = rotrSL(W2h, W2l, 19) ^ rotrBL(W2h, W2l, 61) ^ shrSL(W2h, W2l, 6);
+ const SUMl = add4L(s0l, s1l, SHA512_W_L[i - 7], SHA512_W_L[i - 16]);
+ const SUMh = add4H(SUMl, s0h, s1h, SHA512_W_H[i - 7], SHA512_W_H[i - 16]);
+ SHA512_W_H[i] = SUMh | 0;
+ SHA512_W_L[i] = SUMl | 0;
+ }
+ let { Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl } = this;
+ for (let i = 0;i < 80; i++) {
+ const sigma1h = rotrSH(Eh, El, 14) ^ rotrSH(Eh, El, 18) ^ rotrBH(Eh, El, 41);
+ const sigma1l = rotrSL(Eh, El, 14) ^ rotrSL(Eh, El, 18) ^ rotrBL(Eh, El, 41);
+ const CHIh = Eh & Fh ^ ~Eh & Gh;
+ const CHIl = El & Fl ^ ~El & Gl;
+ const T1ll = add5L(Hl, sigma1l, CHIl, SHA512_Kl[i], SHA512_W_L[i]);
+ const T1h = add5H(T1ll, Hh, sigma1h, CHIh, SHA512_Kh[i], SHA512_W_H[i]);
+ const T1l = T1ll | 0;
+ const sigma0h = rotrSH(Ah, Al, 28) ^ rotrBH(Ah, Al, 34) ^ rotrBH(Ah, Al, 39);
+ const sigma0l = rotrSL(Ah, Al, 28) ^ rotrBL(Ah, Al, 34) ^ rotrBL(Ah, Al, 39);
+ const MAJh = Ah & Bh ^ Ah & Ch ^ Bh & Ch;
+ const MAJl = Al & Bl ^ Al & Cl ^ Bl & Cl;
+ Hh = Gh | 0;
+ Hl = Gl | 0;
+ Gh = Fh | 0;
+ Gl = Fl | 0;
+ Fh = Eh | 0;
+ Fl = El | 0;
+ ({ h: Eh, l: El } = add(Dh | 0, Dl | 0, T1h | 0, T1l | 0));
+ Dh = Ch | 0;
+ Dl = Cl | 0;
+ Ch = Bh | 0;
+ Cl = Bl | 0;
+ Bh = Ah | 0;
+ Bl = Al | 0;
+ const All = add3L(T1l, sigma0l, MAJl);
+ Ah = add3H(All, T1h, sigma0h, MAJh);
+ Al = All | 0;
+ }
+ ({ h: Ah, l: Al } = add(this.Ah | 0, this.Al | 0, Ah | 0, Al | 0));
+ ({ h: Bh, l: Bl } = add(this.Bh | 0, this.Bl | 0, Bh | 0, Bl | 0));
+ ({ h: Ch, l: Cl } = add(this.Ch | 0, this.Cl | 0, Ch | 0, Cl | 0));
+ ({ h: Dh, l: Dl } = add(this.Dh | 0, this.Dl | 0, Dh | 0, Dl | 0));
+ ({ h: Eh, l: El } = add(this.Eh | 0, this.El | 0, Eh | 0, El | 0));
+ ({ h: Fh, l: Fl } = add(this.Fh | 0, this.Fl | 0, Fh | 0, Fl | 0));
+ ({ h: Gh, l: Gl } = add(this.Gh | 0, this.Gl | 0, Gh | 0, Gl | 0));
+ ({ h: Hh, l: Hl } = add(this.Hh | 0, this.Hl | 0, Hh | 0, Hl | 0));
+ this.set(Ah, Al, Bh, Bl, Ch, Cl, Dh, Dl, Eh, El, Fh, Fl, Gh, Gl, Hh, Hl);
+ }
+ roundClean() {
+ clean(SHA512_W_H, SHA512_W_L);
+ }
+ destroy() {
+ clean(this.buffer);
+ this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
+ }
+}
+
+class _SHA512 extends SHA2_64B {
+ Ah = SHA512_IV[0] | 0;
+ Al = SHA512_IV[1] | 0;
+ Bh = SHA512_IV[2] | 0;
+ Bl = SHA512_IV[3] | 0;
+ Ch = SHA512_IV[4] | 0;
+ Cl = SHA512_IV[5] | 0;
+ Dh = SHA512_IV[6] | 0;
+ Dl = SHA512_IV[7] | 0;
+ Eh = SHA512_IV[8] | 0;
+ El = SHA512_IV[9] | 0;
+ Fh = SHA512_IV[10] | 0;
+ Fl = SHA512_IV[11] | 0;
+ Gh = SHA512_IV[12] | 0;
+ Gl = SHA512_IV[13] | 0;
+ Hh = SHA512_IV[14] | 0;
+ Hl = SHA512_IV[15] | 0;
+ constructor() {
+ super(64);
+ }
+}
+
+class _SHA384 extends SHA2_64B {
+ Ah = SHA384_IV[0] | 0;
+ Al = SHA384_IV[1] | 0;
+ Bh = SHA384_IV[2] | 0;
+ Bl = SHA384_IV[3] | 0;
+ Ch = SHA384_IV[4] | 0;
+ Cl = SHA384_IV[5] | 0;
+ Dh = SHA384_IV[6] | 0;
+ Dl = SHA384_IV[7] | 0;
+ Eh = SHA384_IV[8] | 0;
+ El = SHA384_IV[9] | 0;
+ Fh = SHA384_IV[10] | 0;
+ Fl = SHA384_IV[11] | 0;
+ Gh = SHA384_IV[12] | 0;
+ Gl = SHA384_IV[13] | 0;
+ Hh = SHA384_IV[14] | 0;
+ Hl = SHA384_IV[15] | 0;
+ constructor() {
+ super(48);
+ }
+}
+var T224_IV = /* @__PURE__ */ Uint32Array.from([
+ 2352822216,
+ 424955298,
+ 1944164710,
+ 2312950998,
+ 502970286,
+ 855612546,
+ 1738396948,
+ 1479516111,
+ 258812777,
+ 2077511080,
+ 2011393907,
+ 79989058,
+ 1067287976,
+ 1780299464,
+ 286451373,
+ 2446758561
+]);
+var T256_IV = /* @__PURE__ */ Uint32Array.from([
+ 573645204,
+ 4230739756,
+ 2673172387,
+ 3360449730,
+ 596883563,
+ 1867755857,
+ 2520282905,
+ 1497426621,
+ 2519219938,
+ 2827943907,
+ 3193839141,
+ 1401305490,
+ 721525244,
+ 746961066,
+ 246885852,
+ 2177182882
+]);
+
+class _SHA512_224 extends SHA2_64B {
+ Ah = T224_IV[0] | 0;
+ Al = T224_IV[1] | 0;
+ Bh = T224_IV[2] | 0;
+ Bl = T224_IV[3] | 0;
+ Ch = T224_IV[4] | 0;
+ Cl = T224_IV[5] | 0;
+ Dh = T224_IV[6] | 0;
+ Dl = T224_IV[7] | 0;
+ Eh = T224_IV[8] | 0;
+ El = T224_IV[9] | 0;
+ Fh = T224_IV[10] | 0;
+ Fl = T224_IV[11] | 0;
+ Gh = T224_IV[12] | 0;
+ Gl = T224_IV[13] | 0;
+ Hh = T224_IV[14] | 0;
+ Hl = T224_IV[15] | 0;
+ constructor() {
+ super(28);
+ }
+}
+
+class _SHA512_256 extends SHA2_64B {
+ Ah = T256_IV[0] | 0;
+ Al = T256_IV[1] | 0;
+ Bh = T256_IV[2] | 0;
+ Bl = T256_IV[3] | 0;
+ Ch = T256_IV[4] | 0;
+ Cl = T256_IV[5] | 0;
+ Dh = T256_IV[6] | 0;
+ Dl = T256_IV[7] | 0;
+ Eh = T256_IV[8] | 0;
+ El = T256_IV[9] | 0;
+ Fh = T256_IV[10] | 0;
+ Fl = T256_IV[11] | 0;
+ Gh = T256_IV[12] | 0;
+ Gl = T256_IV[13] | 0;
+ Hh = T256_IV[14] | 0;
+ Hl = T256_IV[15] | 0;
+ constructor() {
+ super(32);
+ }
+}
+var sha256 = /* @__PURE__ */ createHasher(() => new _SHA256, /* @__PURE__ */ oidNist(1));
+
+// node_modules/@noble/curves/utils.js
+/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
+var _0n = /* @__PURE__ */ BigInt(0);
+var _1n = /* @__PURE__ */ BigInt(1);
+function abool(value, title = "") {
+ if (typeof value !== "boolean") {
+ const prefix = title && `"${title}" `;
+ throw new Error(prefix + "expected boolean, got type=" + typeof value);
+ }
+ return value;
+}
+function abignumber(n) {
+ if (typeof n === "bigint") {
+ if (!isPosBig(n))
+ throw new Error("positive bigint expected, got " + n);
+ } else
+ anumber(n);
+ return n;
+}
+function numberToHexUnpadded(num) {
+ const hex = abignumber(num).toString(16);
+ return hex.length & 1 ? "0" + hex : hex;
+}
+function hexToNumber(hex) {
+ if (typeof hex !== "string")
+ throw new Error("hex string expected, got " + typeof hex);
+ return hex === "" ? _0n : BigInt("0x" + hex);
+}
+function bytesToNumberBE(bytes) {
+ return hexToNumber(bytesToHex(bytes));
+}
+function bytesToNumberLE(bytes) {
+ return hexToNumber(bytesToHex(copyBytes(abytes(bytes)).reverse()));
+}
+function numberToBytesBE(n, len) {
+ anumber(len);
+ n = abignumber(n);
+ const res = hexToBytes(n.toString(16).padStart(len * 2, "0"));
+ if (res.length !== len)
+ throw new Error("number too large");
+ return res;
+}
+function numberToBytesLE(n, len) {
+ return numberToBytesBE(n, len).reverse();
+}
+function copyBytes(bytes) {
+ return Uint8Array.from(bytes);
+}
+var isPosBig = (n) => typeof n === "bigint" && _0n <= n;
+function inRange(n, min, max) {
+ return isPosBig(n) && isPosBig(min) && isPosBig(max) && min <= n && n < max;
+}
+function aInRange(title, n, min, max) {
+ if (!inRange(n, min, max))
+ throw new Error("expected valid " + title + ": " + min + " <= n < " + max + ", got " + n);
+}
+function bitLen(n) {
+ let len;
+ for (len = 0;n > _0n; n >>= _1n, len += 1)
+ ;
+ return len;
+}
+var bitMask = (n) => (_1n << BigInt(n)) - _1n;
+function createHmacDrbg(hashLen, qByteLen, hmacFn) {
+ anumber(hashLen, "hashLen");
+ anumber(qByteLen, "qByteLen");
+ if (typeof hmacFn !== "function")
+ throw new Error("hmacFn must be a function");
+ const u8n = (len) => new Uint8Array(len);
+ const NULL = Uint8Array.of();
+ const byte0 = Uint8Array.of(0);
+ const byte1 = Uint8Array.of(1);
+ const _maxDrbgIters = 1000;
+ let v = u8n(hashLen);
+ let k = u8n(hashLen);
+ let i = 0;
+ const reset = () => {
+ v.fill(1);
+ k.fill(0);
+ i = 0;
+ };
+ const h = (...msgs) => hmacFn(k, concatBytes(v, ...msgs));
+ const reseed = (seed = NULL) => {
+ k = h(byte0, seed);
+ v = h();
+ if (seed.length === 0)
+ return;
+ k = h(byte1, seed);
+ v = h();
+ };
+ const gen = () => {
+ if (i++ >= _maxDrbgIters)
+ throw new Error("drbg: tried max amount of iterations");
+ let len = 0;
+ const out = [];
+ while (len < qByteLen) {
+ v = h();
+ const sl = v.slice();
+ out.push(sl);
+ len += v.length;
+ }
+ return concatBytes(...out);
+ };
+ const genUntil = (seed, pred) => {
+ reset();
+ reseed(seed);
+ let res = undefined;
+ while (!(res = pred(gen())))
+ reseed();
+ reset();
+ return res;
+ };
+ return genUntil;
+}
+function validateObject(object, fields = {}, optFields = {}) {
+ if (!object || typeof object !== "object")
+ throw new Error("expected valid options object");
+ function checkField(fieldName, expectedType, isOpt) {
+ const val = object[fieldName];
+ if (isOpt && val === undefined)
+ return;
+ const current = typeof val;
+ if (current !== expectedType || val === null)
+ throw new Error(`param "${fieldName}" is invalid: expected ${expectedType}, got ${current}`);
+ }
+ const iter = (f, isOpt) => Object.entries(f).forEach(([k, v]) => checkField(k, v, isOpt));
+ iter(fields, false);
+ iter(optFields, true);
+}
+function memoized(fn) {
+ const map = new WeakMap;
+ return (arg, ...args) => {
+ const val = map.get(arg);
+ if (val !== undefined)
+ return val;
+ const computed = fn(arg, ...args);
+ map.set(arg, computed);
+ return computed;
+ };
+}
+
+// node_modules/@noble/curves/abstract/modular.js
+/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
+var _0n2 = /* @__PURE__ */ BigInt(0);
+var _1n2 = /* @__PURE__ */ BigInt(1);
+var _2n = /* @__PURE__ */ BigInt(2);
+var _3n = /* @__PURE__ */ BigInt(3);
+var _4n = /* @__PURE__ */ BigInt(4);
+var _5n = /* @__PURE__ */ BigInt(5);
+var _7n = /* @__PURE__ */ BigInt(7);
+var _8n = /* @__PURE__ */ BigInt(8);
+var _9n = /* @__PURE__ */ BigInt(9);
+var _16n = /* @__PURE__ */ BigInt(16);
+function mod(a, b) {
+ const result = a % b;
+ return result >= _0n2 ? result : b + result;
+}
+function pow2(x, power, modulo) {
+ let res = x;
+ while (power-- > _0n2) {
+ res *= res;
+ res %= modulo;
+ }
+ return res;
+}
+function invert(number, modulo) {
+ if (number === _0n2)
+ throw new Error("invert: expected non-zero number");
+ if (modulo <= _0n2)
+ throw new Error("invert: expected positive modulus, got " + modulo);
+ let a = mod(number, modulo);
+ let b = modulo;
+ let x = _0n2, y = _1n2, u = _1n2, v = _0n2;
+ while (a !== _0n2) {
+ const q = b / a;
+ const r = b % a;
+ const m = x - u * q;
+ const n = y - v * q;
+ b = a, a = r, x = u, y = v, u = m, v = n;
+ }
+ const gcd = b;
+ if (gcd !== _1n2)
+ throw new Error("invert: does not exist");
+ return mod(x, modulo);
+}
+function assertIsSquare(Fp, root, n) {
+ if (!Fp.eql(Fp.sqr(root), n))
+ throw new Error("Cannot find square root");
+}
+function sqrt3mod4(Fp, n) {
+ const p1div4 = (Fp.ORDER + _1n2) / _4n;
+ const root = Fp.pow(n, p1div4);
+ assertIsSquare(Fp, root, n);
+ return root;
+}
+function sqrt5mod8(Fp, n) {
+ const p5div8 = (Fp.ORDER - _5n) / _8n;
+ const n2 = Fp.mul(n, _2n);
+ const v = Fp.pow(n2, p5div8);
+ const nv = Fp.mul(n, v);
+ const i = Fp.mul(Fp.mul(nv, _2n), v);
+ const root = Fp.mul(nv, Fp.sub(i, Fp.ONE));
+ assertIsSquare(Fp, root, n);
+ return root;
+}
+function sqrt9mod16(P) {
+ const Fp_ = Field(P);
+ const tn = tonelliShanks(P);
+ const c1 = tn(Fp_, Fp_.neg(Fp_.ONE));
+ const c2 = tn(Fp_, c1);
+ const c3 = tn(Fp_, Fp_.neg(c1));
+ const c4 = (P + _7n) / _16n;
+ return (Fp, n) => {
+ let tv1 = Fp.pow(n, c4);
+ let tv2 = Fp.mul(tv1, c1);
+ const tv3 = Fp.mul(tv1, c2);
+ const tv4 = Fp.mul(tv1, c3);
+ const e1 = Fp.eql(Fp.sqr(tv2), n);
+ const e2 = Fp.eql(Fp.sqr(tv3), n);
+ tv1 = Fp.cmov(tv1, tv2, e1);
+ tv2 = Fp.cmov(tv4, tv3, e2);
+ const e3 = Fp.eql(Fp.sqr(tv2), n);
+ const root = Fp.cmov(tv1, tv2, e3);
+ assertIsSquare(Fp, root, n);
+ return root;
+ };
+}
+function tonelliShanks(P) {
+ if (P < _3n)
+ throw new Error("sqrt is not defined for small field");
+ let Q = P - _1n2;
+ let S = 0;
+ while (Q % _2n === _0n2) {
+ Q /= _2n;
+ S++;
+ }
+ let Z = _2n;
+ const _Fp = Field(P);
+ while (FpLegendre(_Fp, Z) === 1) {
+ if (Z++ > 1000)
+ throw new Error("Cannot find square root: probably non-prime P");
+ }
+ if (S === 1)
+ return sqrt3mod4;
+ let cc = _Fp.pow(Z, Q);
+ const Q1div2 = (Q + _1n2) / _2n;
+ return function tonelliSlow(Fp, n) {
+ if (Fp.is0(n))
+ return n;
+ if (FpLegendre(Fp, n) !== 1)
+ throw new Error("Cannot find square root");
+ let M = S;
+ let c = Fp.mul(Fp.ONE, cc);
+ let t = Fp.pow(n, Q);
+ let R = Fp.pow(n, Q1div2);
+ while (!Fp.eql(t, Fp.ONE)) {
+ if (Fp.is0(t))
+ return Fp.ZERO;
+ let i = 1;
+ let t_tmp = Fp.sqr(t);
+ while (!Fp.eql(t_tmp, Fp.ONE)) {
+ i++;
+ t_tmp = Fp.sqr(t_tmp);
+ if (i === M)
+ throw new Error("Cannot find square root");
+ }
+ const exponent = _1n2 << BigInt(M - i - 1);
+ const b = Fp.pow(c, exponent);
+ M = i;
+ c = Fp.sqr(b);
+ t = Fp.mul(t, c);
+ R = Fp.mul(R, b);
+ }
+ return R;
+ };
+}
+function FpSqrt(P) {
+ if (P % _4n === _3n)
+ return sqrt3mod4;
+ if (P % _8n === _5n)
+ return sqrt5mod8;
+ if (P % _16n === _9n)
+ return sqrt9mod16(P);
+ return tonelliShanks(P);
+}
+var FIELD_FIELDS = [
+ "create",
+ "isValid",
+ "is0",
+ "neg",
+ "inv",
+ "sqrt",
+ "sqr",
+ "eql",
+ "add",
+ "sub",
+ "mul",
+ "pow",
+ "div",
+ "addN",
+ "subN",
+ "mulN",
+ "sqrN"
+];
+function validateField(field) {
+ const initial = {
+ ORDER: "bigint",
+ BYTES: "number",
+ BITS: "number"
+ };
+ const opts = FIELD_FIELDS.reduce((map, val) => {
+ map[val] = "function";
+ return map;
+ }, initial);
+ validateObject(field, opts);
+ return field;
+}
+function FpPow(Fp, num, power) {
+ if (power < _0n2)
+ throw new Error("invalid exponent, negatives unsupported");
+ if (power === _0n2)
+ return Fp.ONE;
+ if (power === _1n2)
+ return num;
+ let p = Fp.ONE;
+ let d = num;
+ while (power > _0n2) {
+ if (power & _1n2)
+ p = Fp.mul(p, d);
+ d = Fp.sqr(d);
+ power >>= _1n2;
+ }
+ return p;
+}
+function FpInvertBatch(Fp, nums, passZero = false) {
+ const inverted = new Array(nums.length).fill(passZero ? Fp.ZERO : undefined);
+ const multipliedAcc = nums.reduce((acc, num, i) => {
+ if (Fp.is0(num))
+ return acc;
+ inverted[i] = acc;
+ return Fp.mul(acc, num);
+ }, Fp.ONE);
+ const invertedAcc = Fp.inv(multipliedAcc);
+ nums.reduceRight((acc, num, i) => {
+ if (Fp.is0(num))
+ return acc;
+ inverted[i] = Fp.mul(acc, inverted[i]);
+ return Fp.mul(acc, num);
+ }, invertedAcc);
+ return inverted;
+}
+function FpLegendre(Fp, n) {
+ const p1mod2 = (Fp.ORDER - _1n2) / _2n;
+ const powered = Fp.pow(n, p1mod2);
+ const yes = Fp.eql(powered, Fp.ONE);
+ const zero = Fp.eql(powered, Fp.ZERO);
+ const no = Fp.eql(powered, Fp.neg(Fp.ONE));
+ if (!yes && !zero && !no)
+ throw new Error("invalid Legendre symbol result");
+ return yes ? 1 : zero ? 0 : -1;
+}
+function nLength(n, nBitLength) {
+ if (nBitLength !== undefined)
+ anumber(nBitLength);
+ const _nBitLength = nBitLength !== undefined ? nBitLength : n.toString(2).length;
+ const nByteLength = Math.ceil(_nBitLength / 8);
+ return { nBitLength: _nBitLength, nByteLength };
+}
+
+class _Field {
+ ORDER;
+ BITS;
+ BYTES;
+ isLE;
+ ZERO = _0n2;
+ ONE = _1n2;
+ _lengths;
+ _sqrt;
+ _mod;
+ constructor(ORDER, opts = {}) {
+ if (ORDER <= _0n2)
+ throw new Error("invalid field: expected ORDER > 0, got " + ORDER);
+ let _nbitLength = undefined;
+ this.isLE = false;
+ if (opts != null && typeof opts === "object") {
+ if (typeof opts.BITS === "number")
+ _nbitLength = opts.BITS;
+ if (typeof opts.sqrt === "function")
+ this.sqrt = opts.sqrt;
+ if (typeof opts.isLE === "boolean")
+ this.isLE = opts.isLE;
+ if (opts.allowedLengths)
+ this._lengths = opts.allowedLengths?.slice();
+ if (typeof opts.modFromBytes === "boolean")
+ this._mod = opts.modFromBytes;
+ }
+ const { nBitLength, nByteLength } = nLength(ORDER, _nbitLength);
+ if (nByteLength > 2048)
+ throw new Error("invalid field: expected ORDER of <= 2048 bytes");
+ this.ORDER = ORDER;
+ this.BITS = nBitLength;
+ this.BYTES = nByteLength;
+ this._sqrt = undefined;
+ Object.preventExtensions(this);
+ }
+ create(num) {
+ return mod(num, this.ORDER);
+ }
+ isValid(num) {
+ if (typeof num !== "bigint")
+ throw new Error("invalid field element: expected bigint, got " + typeof num);
+ return _0n2 <= num && num < this.ORDER;
+ }
+ is0(num) {
+ return num === _0n2;
+ }
+ isValidNot0(num) {
+ return !this.is0(num) && this.isValid(num);
+ }
+ isOdd(num) {
+ return (num & _1n2) === _1n2;
+ }
+ neg(num) {
+ return mod(-num, this.ORDER);
+ }
+ eql(lhs, rhs) {
+ return lhs === rhs;
+ }
+ sqr(num) {
+ return mod(num * num, this.ORDER);
+ }
+ add(lhs, rhs) {
+ return mod(lhs + rhs, this.ORDER);
+ }
+ sub(lhs, rhs) {
+ return mod(lhs - rhs, this.ORDER);
+ }
+ mul(lhs, rhs) {
+ return mod(lhs * rhs, this.ORDER);
+ }
+ pow(num, power) {
+ return FpPow(this, num, power);
+ }
+ div(lhs, rhs) {
+ return mod(lhs * invert(rhs, this.ORDER), this.ORDER);
+ }
+ sqrN(num) {
+ return num * num;
+ }
+ addN(lhs, rhs) {
+ return lhs + rhs;
+ }
+ subN(lhs, rhs) {
+ return lhs - rhs;
+ }
+ mulN(lhs, rhs) {
+ return lhs * rhs;
+ }
+ inv(num) {
+ return invert(num, this.ORDER);
+ }
+ sqrt(num) {
+ if (!this._sqrt)
+ this._sqrt = FpSqrt(this.ORDER);
+ return this._sqrt(this, num);
+ }
+ toBytes(num) {
+ return this.isLE ? numberToBytesLE(num, this.BYTES) : numberToBytesBE(num, this.BYTES);
+ }
+ fromBytes(bytes, skipValidation = false) {
+ abytes(bytes);
+ const { _lengths: allowedLengths, BYTES, isLE: isLE2, ORDER, _mod: modFromBytes } = this;
+ if (allowedLengths) {
+ if (!allowedLengths.includes(bytes.length) || bytes.length > BYTES) {
+ throw new Error("Field.fromBytes: expected " + allowedLengths + " bytes, got " + bytes.length);
+ }
+ const padded = new Uint8Array(BYTES);
+ padded.set(bytes, isLE2 ? 0 : padded.length - bytes.length);
+ bytes = padded;
+ }
+ if (bytes.length !== BYTES)
+ throw new Error("Field.fromBytes: expected " + BYTES + " bytes, got " + bytes.length);
+ let scalar = isLE2 ? bytesToNumberLE(bytes) : bytesToNumberBE(bytes);
+ if (modFromBytes)
+ scalar = mod(scalar, ORDER);
+ if (!skipValidation) {
+ if (!this.isValid(scalar))
+ throw new Error("invalid field element: outside of range 0..ORDER");
+ }
+ return scalar;
+ }
+ invertBatch(lst) {
+ return FpInvertBatch(this, lst);
+ }
+ cmov(a, b, condition) {
+ return condition ? b : a;
+ }
+}
+function Field(ORDER, opts = {}) {
+ return new _Field(ORDER, opts);
+}
+function getFieldBytesLength(fieldOrder) {
+ if (typeof fieldOrder !== "bigint")
+ throw new Error("field order must be bigint");
+ const bitLength = fieldOrder.toString(2).length;
+ return Math.ceil(bitLength / 8);
+}
+function getMinHashLength(fieldOrder) {
+ const length = getFieldBytesLength(fieldOrder);
+ return length + Math.ceil(length / 2);
+}
+function mapHashToField(key, fieldOrder, isLE2 = false) {
+ abytes(key);
+ const len = key.length;
+ const fieldLen = getFieldBytesLength(fieldOrder);
+ const minLen = getMinHashLength(fieldOrder);
+ if (len < 16 || len < minLen || len > 1024)
+ throw new Error("expected " + minLen + "-1024 bytes of input, got " + len);
+ const num = isLE2 ? bytesToNumberLE(key) : bytesToNumberBE(key);
+ const reduced = mod(num, fieldOrder - _1n2) + _1n2;
+ return isLE2 ? numberToBytesLE(reduced, fieldLen) : numberToBytesBE(reduced, fieldLen);
+}
+
+// node_modules/@noble/curves/abstract/curve.js
+/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
+var _0n3 = /* @__PURE__ */ BigInt(0);
+var _1n3 = /* @__PURE__ */ BigInt(1);
+function negateCt(condition, item) {
+ const neg = item.negate();
+ return condition ? neg : item;
+}
+function normalizeZ(c, points) {
+ const invertedZs = FpInvertBatch(c.Fp, points.map((p) => p.Z));
+ return points.map((p, i) => c.fromAffine(p.toAffine(invertedZs[i])));
+}
+function validateW(W, bits) {
+ if (!Number.isSafeInteger(W) || W <= 0 || W > bits)
+ throw new Error("invalid window size, expected [1.." + bits + "], got W=" + W);
+}
+function calcWOpts(W, scalarBits) {
+ validateW(W, scalarBits);
+ const windows = Math.ceil(scalarBits / W) + 1;
+ const windowSize = 2 ** (W - 1);
+ const maxNumber = 2 ** W;
+ const mask = bitMask(W);
+ const shiftBy = BigInt(W);
+ return { windows, windowSize, mask, maxNumber, shiftBy };
+}
+function calcOffsets(n, window2, wOpts) {
+ const { windowSize, mask, maxNumber, shiftBy } = wOpts;
+ let wbits = Number(n & mask);
+ let nextN = n >> shiftBy;
+ if (wbits > windowSize) {
+ wbits -= maxNumber;
+ nextN += _1n3;
+ }
+ const offsetStart = window2 * windowSize;
+ const offset = offsetStart + Math.abs(wbits) - 1;
+ const isZero = wbits === 0;
+ const isNeg = wbits < 0;
+ const isNegF = window2 % 2 !== 0;
+ const offsetF = offsetStart;
+ return { nextN, offset, isZero, isNeg, isNegF, offsetF };
+}
+var pointPrecomputes = new WeakMap;
+var pointWindowSizes = new WeakMap;
+function getW(P) {
+ return pointWindowSizes.get(P) || 1;
+}
+function assert0(n) {
+ if (n !== _0n3)
+ throw new Error("invalid wNAF");
+}
+
+class wNAF {
+ BASE;
+ ZERO;
+ Fn;
+ bits;
+ constructor(Point, bits) {
+ this.BASE = Point.BASE;
+ this.ZERO = Point.ZERO;
+ this.Fn = Point.Fn;
+ this.bits = bits;
+ }
+ _unsafeLadder(elm, n, p = this.ZERO) {
+ let d = elm;
+ while (n > _0n3) {
+ if (n & _1n3)
+ p = p.add(d);
+ d = d.double();
+ n >>= _1n3;
+ }
+ return p;
+ }
+ precomputeWindow(point, W) {
+ const { windows, windowSize } = calcWOpts(W, this.bits);
+ const points = [];
+ let p = point;
+ let base = p;
+ for (let window2 = 0;window2 < windows; window2++) {
+ base = p;
+ points.push(base);
+ for (let i = 1;i < windowSize; i++) {
+ base = base.add(p);
+ points.push(base);
+ }
+ p = base.double();
+ }
+ return points;
+ }
+ wNAF(W, precomputes, n) {
+ if (!this.Fn.isValid(n))
+ throw new Error("invalid scalar");
+ let p = this.ZERO;
+ let f = this.BASE;
+ const wo = calcWOpts(W, this.bits);
+ for (let window2 = 0;window2 < wo.windows; window2++) {
+ const { nextN, offset, isZero, isNeg, isNegF, offsetF } = calcOffsets(n, window2, wo);
+ n = nextN;
+ if (isZero) {
+ f = f.add(negateCt(isNegF, precomputes[offsetF]));
+ } else {
+ p = p.add(negateCt(isNeg, precomputes[offset]));
+ }
+ }
+ assert0(n);
+ return { p, f };
+ }
+ wNAFUnsafe(W, precomputes, n, acc = this.ZERO) {
+ const wo = calcWOpts(W, this.bits);
+ for (let window2 = 0;window2 < wo.windows; window2++) {
+ if (n === _0n3)
+ break;
+ const { nextN, offset, isZero, isNeg } = calcOffsets(n, window2, wo);
+ n = nextN;
+ if (isZero) {
+ continue;
+ } else {
+ const item = precomputes[offset];
+ acc = acc.add(isNeg ? item.negate() : item);
+ }
+ }
+ assert0(n);
+ return acc;
+ }
+ getPrecomputes(W, point, transform) {
+ let comp = pointPrecomputes.get(point);
+ if (!comp) {
+ comp = this.precomputeWindow(point, W);
+ if (W !== 1) {
+ if (typeof transform === "function")
+ comp = transform(comp);
+ pointPrecomputes.set(point, comp);
+ }
+ }
+ return comp;
+ }
+ cached(point, scalar, transform) {
+ const W = getW(point);
+ return this.wNAF(W, this.getPrecomputes(W, point, transform), scalar);
+ }
+ unsafe(point, scalar, transform, prev) {
+ const W = getW(point);
+ if (W === 1)
+ return this._unsafeLadder(point, scalar, prev);
+ return this.wNAFUnsafe(W, this.getPrecomputes(W, point, transform), scalar, prev);
+ }
+ createCache(P, W) {
+ validateW(W, this.bits);
+ pointWindowSizes.set(P, W);
+ pointPrecomputes.delete(P);
+ }
+ hasCache(elm) {
+ return getW(elm) !== 1;
+ }
+}
+function mulEndoUnsafe(Point, point, k1, k2) {
+ let acc = point;
+ let p1 = Point.ZERO;
+ let p2 = Point.ZERO;
+ while (k1 > _0n3 || k2 > _0n3) {
+ if (k1 & _1n3)
+ p1 = p1.add(acc);
+ if (k2 & _1n3)
+ p2 = p2.add(acc);
+ acc = acc.double();
+ k1 >>= _1n3;
+ k2 >>= _1n3;
+ }
+ return { p1, p2 };
+}
+function createField(order, field, isLE2) {
+ if (field) {
+ if (field.ORDER !== order)
+ throw new Error("Field.ORDER must match order: Fp == p, Fn == n");
+ validateField(field);
+ return field;
+ } else {
+ return Field(order, { isLE: isLE2 });
+ }
+}
+function createCurveFields(type, CURVE, curveOpts = {}, FpFnLE) {
+ if (FpFnLE === undefined)
+ FpFnLE = type === "edwards";
+ if (!CURVE || typeof CURVE !== "object")
+ throw new Error(`expected valid ${type} CURVE object`);
+ for (const p of ["p", "n", "h"]) {
+ const val = CURVE[p];
+ if (!(typeof val === "bigint" && val > _0n3))
+ throw new Error(`CURVE.${p} must be positive bigint`);
+ }
+ const Fp = createField(CURVE.p, curveOpts.Fp, FpFnLE);
+ const Fn = createField(CURVE.n, curveOpts.Fn, FpFnLE);
+ const _b = type === "weierstrass" ? "b" : "d";
+ const params = ["Gx", "Gy", "a", _b];
+ for (const p of params) {
+ if (!Fp.isValid(CURVE[p]))
+ throw new Error(`CURVE.${p} must be valid field element of CURVE.Fp`);
+ }
+ CURVE = Object.freeze(Object.assign({}, CURVE));
+ return { CURVE, Fp, Fn };
+}
+function createKeygen(randomSecretKey, getPublicKey) {
+ return function keygen(seed) {
+ const secretKey = randomSecretKey(seed);
+ return { secretKey, publicKey: getPublicKey(secretKey) };
+ };
+}
+
+// node_modules/@noble/hashes/hmac.js
+class _HMAC {
+ oHash;
+ iHash;
+ blockLen;
+ outputLen;
+ finished = false;
+ destroyed = false;
+ constructor(hash, key) {
+ ahash(hash);
+ abytes(key, undefined, "key");
+ this.iHash = hash.create();
+ if (typeof this.iHash.update !== "function")
+ throw new Error("Expected instance of class which extends utils.Hash");
+ this.blockLen = this.iHash.blockLen;
+ this.outputLen = this.iHash.outputLen;
+ const blockLen = this.blockLen;
+ const pad = new Uint8Array(blockLen);
+ pad.set(key.length > blockLen ? hash.create().update(key).digest() : key);
+ for (let i = 0;i < pad.length; i++)
+ pad[i] ^= 54;
+ this.iHash.update(pad);
+ this.oHash = hash.create();
+ for (let i = 0;i < pad.length; i++)
+ pad[i] ^= 54 ^ 92;
+ this.oHash.update(pad);
+ clean(pad);
+ }
+ update(buf) {
+ aexists(this);
+ this.iHash.update(buf);
+ return this;
+ }
+ digestInto(out) {
+ aexists(this);
+ abytes(out, this.outputLen, "output");
+ this.finished = true;
+ this.iHash.digestInto(out);
+ this.oHash.update(out);
+ this.oHash.digestInto(out);
+ this.destroy();
+ }
+ digest() {
+ const out = new Uint8Array(this.oHash.outputLen);
+ this.digestInto(out);
+ return out;
+ }
+ _cloneInto(to) {
+ to ||= Object.create(Object.getPrototypeOf(this), {});
+ const { oHash, iHash, finished, destroyed, blockLen, outputLen } = this;
+ to = to;
+ to.finished = finished;
+ to.destroyed = destroyed;
+ to.blockLen = blockLen;
+ to.outputLen = outputLen;
+ to.oHash = oHash._cloneInto(to.oHash);
+ to.iHash = iHash._cloneInto(to.iHash);
+ return to;
+ }
+ clone() {
+ return this._cloneInto();
+ }
+ destroy() {
+ this.destroyed = true;
+ this.oHash.destroy();
+ this.iHash.destroy();
+ }
+}
+var hmac = (hash, key, message) => new _HMAC(hash, key).update(message).digest();
+hmac.create = (hash, key) => new _HMAC(hash, key);
+
+// node_modules/@noble/curves/abstract/weierstrass.js
+/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
+var divNearest = (num, den) => (num + (num >= 0 ? den : -den) / _2n2) / den;
+function _splitEndoScalar(k, basis, n) {
+ const [[a1, b1], [a2, b2]] = basis;
+ const c1 = divNearest(b2 * k, n);
+ const c2 = divNearest(-b1 * k, n);
+ let k1 = k - c1 * a1 - c2 * a2;
+ let k2 = -c1 * b1 - c2 * b2;
+ const k1neg = k1 < _0n4;
+ const k2neg = k2 < _0n4;
+ if (k1neg)
+ k1 = -k1;
+ if (k2neg)
+ k2 = -k2;
+ const MAX_NUM = bitMask(Math.ceil(bitLen(n) / 2)) + _1n4;
+ if (k1 < _0n4 || k1 >= MAX_NUM || k2 < _0n4 || k2 >= MAX_NUM) {
+ throw new Error("splitScalar (endomorphism): failed, k=" + k);
+ }
+ return { k1neg, k1, k2neg, k2 };
+}
+function validateSigFormat(format) {
+ if (!["compact", "recovered", "der"].includes(format))
+ throw new Error('Signature format must be "compact", "recovered", or "der"');
+ return format;
+}
+function validateSigOpts(opts, def) {
+ const optsn = {};
+ for (let optName of Object.keys(def)) {
+ optsn[optName] = opts[optName] === undefined ? def[optName] : opts[optName];
+ }
+ abool(optsn.lowS, "lowS");
+ abool(optsn.prehash, "prehash");
+ if (optsn.format !== undefined)
+ validateSigFormat(optsn.format);
+ return optsn;
+}
+
+class DERErr extends Error {
+ constructor(m = "") {
+ super(m);
+ }
+}
+var DER = {
+ Err: DERErr,
+ _tlv: {
+ encode: (tag, data) => {
+ const { Err: E } = DER;
+ if (tag < 0 || tag > 256)
+ throw new E("tlv.encode: wrong tag");
+ if (data.length & 1)
+ throw new E("tlv.encode: unpadded data");
+ const dataLen = data.length / 2;
+ const len = numberToHexUnpadded(dataLen);
+ if (len.length / 2 & 128)
+ throw new E("tlv.encode: long form length too big");
+ const lenLen = dataLen > 127 ? numberToHexUnpadded(len.length / 2 | 128) : "";
+ const t = numberToHexUnpadded(tag);
+ return t + lenLen + len + data;
+ },
+ decode(tag, data) {
+ const { Err: E } = DER;
+ let pos = 0;
+ if (tag < 0 || tag > 256)
+ throw new E("tlv.encode: wrong tag");
+ if (data.length < 2 || data[pos++] !== tag)
+ throw new E("tlv.decode: wrong tlv");
+ const first = data[pos++];
+ const isLong = !!(first & 128);
+ let length = 0;
+ if (!isLong)
+ length = first;
+ else {
+ const lenLen = first & 127;
+ if (!lenLen)
+ throw new E("tlv.decode(long): indefinite length not supported");
+ if (lenLen > 4)
+ throw new E("tlv.decode(long): byte length is too big");
+ const lengthBytes = data.subarray(pos, pos + lenLen);
+ if (lengthBytes.length !== lenLen)
+ throw new E("tlv.decode: length bytes not complete");
+ if (lengthBytes[0] === 0)
+ throw new E("tlv.decode(long): zero leftmost byte");
+ for (const b of lengthBytes)
+ length = length << 8 | b;
+ pos += lenLen;
+ if (length < 128)
+ throw new E("tlv.decode(long): not minimal encoding");
+ }
+ const v = data.subarray(pos, pos + length);
+ if (v.length !== length)
+ throw new E("tlv.decode: wrong value length");
+ return { v, l: data.subarray(pos + length) };
+ }
+ },
+ _int: {
+ encode(num) {
+ const { Err: E } = DER;
+ if (num < _0n4)
+ throw new E("integer: negative integers are not allowed");
+ let hex = numberToHexUnpadded(num);
+ if (Number.parseInt(hex[0], 16) & 8)
+ hex = "00" + hex;
+ if (hex.length & 1)
+ throw new E("unexpected DER parsing assertion: unpadded hex");
+ return hex;
+ },
+ decode(data) {
+ const { Err: E } = DER;
+ if (data[0] & 128)
+ throw new E("invalid signature integer: negative");
+ if (data[0] === 0 && !(data[1] & 128))
+ throw new E("invalid signature integer: unnecessary leading zero");
+ return bytesToNumberBE(data);
+ }
+ },
+ toSig(bytes) {
+ const { Err: E, _int: int, _tlv: tlv } = DER;
+ const data = abytes(bytes, undefined, "signature");
+ const { v: seqBytes, l: seqLeftBytes } = tlv.decode(48, data);
+ if (seqLeftBytes.length)
+ throw new E("invalid signature: left bytes after parsing");
+ const { v: rBytes, l: rLeftBytes } = tlv.decode(2, seqBytes);
+ const { v: sBytes, l: sLeftBytes } = tlv.decode(2, rLeftBytes);
+ if (sLeftBytes.length)
+ throw new E("invalid signature: left bytes after parsing");
+ return { r: int.decode(rBytes), s: int.decode(sBytes) };
+ },
+ hexFromSig(sig) {
+ const { _tlv: tlv, _int: int } = DER;
+ const rs = tlv.encode(2, int.encode(sig.r));
+ const ss = tlv.encode(2, int.encode(sig.s));
+ const seq = rs + ss;
+ return tlv.encode(48, seq);
+ }
+};
+var _0n4 = BigInt(0);
+var _1n4 = BigInt(1);
+var _2n2 = BigInt(2);
+var _3n2 = BigInt(3);
+var _4n2 = BigInt(4);
+function weierstrass(params, extraOpts = {}) {
+ const validated = createCurveFields("weierstrass", params, extraOpts);
+ const { Fp, Fn } = validated;
+ let CURVE = validated.CURVE;
+ const { h: cofactor, n: CURVE_ORDER } = CURVE;
+ validateObject(extraOpts, {}, {
+ allowInfinityPoint: "boolean",
+ clearCofactor: "function",
+ isTorsionFree: "function",
+ fromBytes: "function",
+ toBytes: "function",
+ endo: "object"
+ });
+ const { endo } = extraOpts;
+ if (endo) {
+ if (!Fp.is0(CURVE.a) || typeof endo.beta !== "bigint" || !Array.isArray(endo.basises)) {
+ throw new Error('invalid endo: expected "beta": bigint and "basises": array');
+ }
+ }
+ const lengths = getWLengths(Fp, Fn);
+ function assertCompressionIsSupported() {
+ if (!Fp.isOdd)
+ throw new Error("compression is not supported: Field does not have .isOdd()");
+ }
+ function pointToBytes(_c, point, isCompressed) {
+ const { x, y } = point.toAffine();
+ const bx = Fp.toBytes(x);
+ abool(isCompressed, "isCompressed");
+ if (isCompressed) {
+ assertCompressionIsSupported();
+ const hasEvenY = !Fp.isOdd(y);
+ return concatBytes(pprefix(hasEvenY), bx);
+ } else {
+ return concatBytes(Uint8Array.of(4), bx, Fp.toBytes(y));
+ }
+ }
+ function pointFromBytes(bytes) {
+ abytes(bytes, undefined, "Point");
+ const { publicKey: comp, publicKeyUncompressed: uncomp } = lengths;
+ const length = bytes.length;
+ const head = bytes[0];
+ const tail = bytes.subarray(1);
+ if (length === comp && (head === 2 || head === 3)) {
+ const x = Fp.fromBytes(tail);
+ if (!Fp.isValid(x))
+ throw new Error("bad point: is not on curve, wrong x");
+ const y2 = weierstrassEquation(x);
+ let y;
+ try {
+ y = Fp.sqrt(y2);
+ } catch (sqrtError) {
+ const err = sqrtError instanceof Error ? ": " + sqrtError.message : "";
+ throw new Error("bad point: is not on curve, sqrt error" + err);
+ }
+ assertCompressionIsSupported();
+ const evenY = Fp.isOdd(y);
+ const evenH = (head & 1) === 1;
+ if (evenH !== evenY)
+ y = Fp.neg(y);
+ return { x, y };
+ } else if (length === uncomp && head === 4) {
+ const L = Fp.BYTES;
+ const x = Fp.fromBytes(tail.subarray(0, L));
+ const y = Fp.fromBytes(tail.subarray(L, L * 2));
+ if (!isValidXY(x, y))
+ throw new Error("bad point: is not on curve");
+ return { x, y };
+ } else {
+ throw new Error(`bad point: got length ${length}, expected compressed=${comp} or uncompressed=${uncomp}`);
+ }
+ }
+ const encodePoint = extraOpts.toBytes || pointToBytes;
+ const decodePoint = extraOpts.fromBytes || pointFromBytes;
+ function weierstrassEquation(x) {
+ const x2 = Fp.sqr(x);
+ const x3 = Fp.mul(x2, x);
+ return Fp.add(Fp.add(x3, Fp.mul(x, CURVE.a)), CURVE.b);
+ }
+ function isValidXY(x, y) {
+ const left = Fp.sqr(y);
+ const right = weierstrassEquation(x);
+ return Fp.eql(left, right);
+ }
+ if (!isValidXY(CURVE.Gx, CURVE.Gy))
+ throw new Error("bad curve params: generator point");
+ const _4a3 = Fp.mul(Fp.pow(CURVE.a, _3n2), _4n2);
+ const _27b2 = Fp.mul(Fp.sqr(CURVE.b), BigInt(27));
+ if (Fp.is0(Fp.add(_4a3, _27b2)))
+ throw new Error("bad curve params: a or b");
+ function acoord(title, n, banZero = false) {
+ if (!Fp.isValid(n) || banZero && Fp.is0(n))
+ throw new Error(`bad point coordinate ${title}`);
+ return n;
+ }
+ function aprjpoint(other) {
+ if (!(other instanceof Point))
+ throw new Error("Weierstrass Point expected");
+ }
+ function splitEndoScalarN(k) {
+ if (!endo || !endo.basises)
+ throw new Error("no endo");
+ return _splitEndoScalar(k, endo.basises, Fn.ORDER);
+ }
+ const toAffineMemo = memoized((p, iz) => {
+ const { X, Y, Z } = p;
+ if (Fp.eql(Z, Fp.ONE))
+ return { x: X, y: Y };
+ const is0 = p.is0();
+ if (iz == null)
+ iz = is0 ? Fp.ONE : Fp.inv(Z);
+ const x = Fp.mul(X, iz);
+ const y = Fp.mul(Y, iz);
+ const zz = Fp.mul(Z, iz);
+ if (is0)
+ return { x: Fp.ZERO, y: Fp.ZERO };
+ if (!Fp.eql(zz, Fp.ONE))
+ throw new Error("invZ was invalid");
+ return { x, y };
+ });
+ const assertValidMemo = memoized((p) => {
+ if (p.is0()) {
+ if (extraOpts.allowInfinityPoint && !Fp.is0(p.Y))
+ return;
+ throw new Error("bad point: ZERO");
+ }
+ const { x, y } = p.toAffine();
+ if (!Fp.isValid(x) || !Fp.isValid(y))
+ throw new Error("bad point: x or y not field elements");
+ if (!isValidXY(x, y))
+ throw new Error("bad point: equation left != right");
+ if (!p.isTorsionFree())
+ throw new Error("bad point: not in prime-order subgroup");
+ return true;
+ });
+ function finishEndo(endoBeta, k1p, k2p, k1neg, k2neg) {
+ k2p = new Point(Fp.mul(k2p.X, endoBeta), k2p.Y, k2p.Z);
+ k1p = negateCt(k1neg, k1p);
+ k2p = negateCt(k2neg, k2p);
+ return k1p.add(k2p);
+ }
+
+ class Point {
+ static BASE = new Point(CURVE.Gx, CURVE.Gy, Fp.ONE);
+ static ZERO = new Point(Fp.ZERO, Fp.ONE, Fp.ZERO);
+ static Fp = Fp;
+ static Fn = Fn;
+ X;
+ Y;
+ Z;
+ constructor(X, Y, Z) {
+ this.X = acoord("x", X);
+ this.Y = acoord("y", Y, true);
+ this.Z = acoord("z", Z);
+ Object.freeze(this);
+ }
+ static CURVE() {
+ return CURVE;
+ }
+ static fromAffine(p) {
+ const { x, y } = p || {};
+ if (!p || !Fp.isValid(x) || !Fp.isValid(y))
+ throw new Error("invalid affine point");
+ if (p instanceof Point)
+ throw new Error("projective point not allowed");
+ if (Fp.is0(x) && Fp.is0(y))
+ return Point.ZERO;
+ return new Point(x, y, Fp.ONE);
+ }
+ static fromBytes(bytes) {
+ const P = Point.fromAffine(decodePoint(abytes(bytes, undefined, "point")));
+ P.assertValidity();
+ return P;
+ }
+ static fromHex(hex) {
+ return Point.fromBytes(hexToBytes(hex));
+ }
+ get x() {
+ return this.toAffine().x;
+ }
+ get y() {
+ return this.toAffine().y;
+ }
+ precompute(windowSize = 8, isLazy = true) {
+ wnaf.createCache(this, windowSize);
+ if (!isLazy)
+ this.multiply(_3n2);
+ return this;
+ }
+ assertValidity() {
+ assertValidMemo(this);
+ }
+ hasEvenY() {
+ const { y } = this.toAffine();
+ if (!Fp.isOdd)
+ throw new Error("Field doesn't support isOdd");
+ return !Fp.isOdd(y);
+ }
+ equals(other) {
+ aprjpoint(other);
+ const { X: X1, Y: Y1, Z: Z1 } = this;
+ const { X: X2, Y: Y2, Z: Z2 } = other;
+ const U1 = Fp.eql(Fp.mul(X1, Z2), Fp.mul(X2, Z1));
+ const U2 = Fp.eql(Fp.mul(Y1, Z2), Fp.mul(Y2, Z1));
+ return U1 && U2;
+ }
+ negate() {
+ return new Point(this.X, Fp.neg(this.Y), this.Z);
+ }
+ double() {
+ const { a, b } = CURVE;
+ const b3 = Fp.mul(b, _3n2);
+ const { X: X1, Y: Y1, Z: Z1 } = this;
+ let { ZERO: X3, ZERO: Y3, ZERO: Z3 } = Fp;
+ let t0 = Fp.mul(X1, X1);
+ let t1 = Fp.mul(Y1, Y1);
+ let t2 = Fp.mul(Z1, Z1);
+ let t3 = Fp.mul(X1, Y1);
+ t3 = Fp.add(t3, t3);
+ Z3 = Fp.mul(X1, Z1);
+ Z3 = Fp.add(Z3, Z3);
+ X3 = Fp.mul(a, Z3);
+ Y3 = Fp.mul(b3, t2);
+ Y3 = Fp.add(X3, Y3);
+ X3 = Fp.sub(t1, Y3);
+ Y3 = Fp.add(t1, Y3);
+ Y3 = Fp.mul(X3, Y3);
+ X3 = Fp.mul(t3, X3);
+ Z3 = Fp.mul(b3, Z3);
+ t2 = Fp.mul(a, t2);
+ t3 = Fp.sub(t0, t2);
+ t3 = Fp.mul(a, t3);
+ t3 = Fp.add(t3, Z3);
+ Z3 = Fp.add(t0, t0);
+ t0 = Fp.add(Z3, t0);
+ t0 = Fp.add(t0, t2);
+ t0 = Fp.mul(t0, t3);
+ Y3 = Fp.add(Y3, t0);
+ t2 = Fp.mul(Y1, Z1);
+ t2 = Fp.add(t2, t2);
+ t0 = Fp.mul(t2, t3);
+ X3 = Fp.sub(X3, t0);
+ Z3 = Fp.mul(t2, t1);
+ Z3 = Fp.add(Z3, Z3);
+ Z3 = Fp.add(Z3, Z3);
+ return new Point(X3, Y3, Z3);
+ }
+ add(other) {
+ aprjpoint(other);
+ const { X: X1, Y: Y1, Z: Z1 } = this;
+ const { X: X2, Y: Y2, Z: Z2 } = other;
+ let { ZERO: X3, ZERO: Y3, ZERO: Z3 } = Fp;
+ const a = CURVE.a;
+ const b3 = Fp.mul(CURVE.b, _3n2);
+ let t0 = Fp.mul(X1, X2);
+ let t1 = Fp.mul(Y1, Y2);
+ let t2 = Fp.mul(Z1, Z2);
+ let t3 = Fp.add(X1, Y1);
+ let t4 = Fp.add(X2, Y2);
+ t3 = Fp.mul(t3, t4);
+ t4 = Fp.add(t0, t1);
+ t3 = Fp.sub(t3, t4);
+ t4 = Fp.add(X1, Z1);
+ let t5 = Fp.add(X2, Z2);
+ t4 = Fp.mul(t4, t5);
+ t5 = Fp.add(t0, t2);
+ t4 = Fp.sub(t4, t5);
+ t5 = Fp.add(Y1, Z1);
+ X3 = Fp.add(Y2, Z2);
+ t5 = Fp.mul(t5, X3);
+ X3 = Fp.add(t1, t2);
+ t5 = Fp.sub(t5, X3);
+ Z3 = Fp.mul(a, t4);
+ X3 = Fp.mul(b3, t2);
+ Z3 = Fp.add(X3, Z3);
+ X3 = Fp.sub(t1, Z3);
+ Z3 = Fp.add(t1, Z3);
+ Y3 = Fp.mul(X3, Z3);
+ t1 = Fp.add(t0, t0);
+ t1 = Fp.add(t1, t0);
+ t2 = Fp.mul(a, t2);
+ t4 = Fp.mul(b3, t4);
+ t1 = Fp.add(t1, t2);
+ t2 = Fp.sub(t0, t2);
+ t2 = Fp.mul(a, t2);
+ t4 = Fp.add(t4, t2);
+ t0 = Fp.mul(t1, t4);
+ Y3 = Fp.add(Y3, t0);
+ t0 = Fp.mul(t5, t4);
+ X3 = Fp.mul(t3, X3);
+ X3 = Fp.sub(X3, t0);
+ t0 = Fp.mul(t3, t1);
+ Z3 = Fp.mul(t5, Z3);
+ Z3 = Fp.add(Z3, t0);
+ return new Point(X3, Y3, Z3);
+ }
+ subtract(other) {
+ return this.add(other.negate());
+ }
+ is0() {
+ return this.equals(Point.ZERO);
+ }
+ multiply(scalar) {
+ const { endo: endo2 } = extraOpts;
+ if (!Fn.isValidNot0(scalar))
+ throw new Error("invalid scalar: out of range");
+ let point, fake;
+ const mul = (n) => wnaf.cached(this, n, (p) => normalizeZ(Point, p));
+ if (endo2) {
+ const { k1neg, k1, k2neg, k2 } = splitEndoScalarN(scalar);
+ const { p: k1p, f: k1f } = mul(k1);
+ const { p: k2p, f: k2f } = mul(k2);
+ fake = k1f.add(k2f);
+ point = finishEndo(endo2.beta, k1p, k2p, k1neg, k2neg);
+ } else {
+ const { p, f } = mul(scalar);
+ point = p;
+ fake = f;
+ }
+ return normalizeZ(Point, [point, fake])[0];
+ }
+ multiplyUnsafe(sc) {
+ const { endo: endo2 } = extraOpts;
+ const p = this;
+ if (!Fn.isValid(sc))
+ throw new Error("invalid scalar: out of range");
+ if (sc === _0n4 || p.is0())
+ return Point.ZERO;
+ if (sc === _1n4)
+ return p;
+ if (wnaf.hasCache(this))
+ return this.multiply(sc);
+ if (endo2) {
+ const { k1neg, k1, k2neg, k2 } = splitEndoScalarN(sc);
+ const { p1, p2 } = mulEndoUnsafe(Point, p, k1, k2);
+ return finishEndo(endo2.beta, p1, p2, k1neg, k2neg);
+ } else {
+ return wnaf.unsafe(p, sc);
+ }
+ }
+ toAffine(invertedZ) {
+ return toAffineMemo(this, invertedZ);
+ }
+ isTorsionFree() {
+ const { isTorsionFree } = extraOpts;
+ if (cofactor === _1n4)
+ return true;
+ if (isTorsionFree)
+ return isTorsionFree(Point, this);
+ return wnaf.unsafe(this, CURVE_ORDER).is0();
+ }
+ clearCofactor() {
+ const { clearCofactor } = extraOpts;
+ if (cofactor === _1n4)
+ return this;
+ if (clearCofactor)
+ return clearCofactor(Point, this);
+ return this.multiplyUnsafe(cofactor);
+ }
+ isSmallOrder() {
+ return this.multiplyUnsafe(cofactor).is0();
+ }
+ toBytes(isCompressed = true) {
+ abool(isCompressed, "isCompressed");
+ this.assertValidity();
+ return encodePoint(Point, this, isCompressed);
+ }
+ toHex(isCompressed = true) {
+ return bytesToHex(this.toBytes(isCompressed));
+ }
+ toString() {
+ return ``;
+ }
+ }
+ const bits = Fn.BITS;
+ const wnaf = new wNAF(Point, extraOpts.endo ? Math.ceil(bits / 2) : bits);
+ Point.BASE.precompute(8);
+ return Point;
+}
+function pprefix(hasEvenY) {
+ return Uint8Array.of(hasEvenY ? 2 : 3);
+}
+function getWLengths(Fp, Fn) {
+ return {
+ secretKey: Fn.BYTES,
+ publicKey: 1 + Fp.BYTES,
+ publicKeyUncompressed: 1 + 2 * Fp.BYTES,
+ publicKeyHasPrefix: true,
+ signature: 2 * Fn.BYTES
+ };
+}
+function ecdh(Point, ecdhOpts = {}) {
+ const { Fn } = Point;
+ const randomBytes_ = ecdhOpts.randomBytes || randomBytes;
+ const lengths = Object.assign(getWLengths(Point.Fp, Fn), { seed: getMinHashLength(Fn.ORDER) });
+ function isValidSecretKey(secretKey) {
+ try {
+ const num = Fn.fromBytes(secretKey);
+ return Fn.isValidNot0(num);
+ } catch (error) {
+ return false;
+ }
+ }
+ function isValidPublicKey(publicKey, isCompressed) {
+ const { publicKey: comp, publicKeyUncompressed } = lengths;
+ try {
+ const l = publicKey.length;
+ if (isCompressed === true && l !== comp)
+ return false;
+ if (isCompressed === false && l !== publicKeyUncompressed)
+ return false;
+ return !!Point.fromBytes(publicKey);
+ } catch (error) {
+ return false;
+ }
+ }
+ function randomSecretKey(seed = randomBytes_(lengths.seed)) {
+ return mapHashToField(abytes(seed, lengths.seed, "seed"), Fn.ORDER);
+ }
+ function getPublicKey(secretKey, isCompressed = true) {
+ return Point.BASE.multiply(Fn.fromBytes(secretKey)).toBytes(isCompressed);
+ }
+ function isProbPub(item) {
+ const { secretKey, publicKey, publicKeyUncompressed } = lengths;
+ if (!isBytes(item))
+ return;
+ if ("_lengths" in Fn && Fn._lengths || secretKey === publicKey)
+ return;
+ const l = abytes(item, undefined, "key").length;
+ return l === publicKey || l === publicKeyUncompressed;
+ }
+ function getSharedSecret(secretKeyA, publicKeyB, isCompressed = true) {
+ if (isProbPub(secretKeyA) === true)
+ throw new Error("first arg must be private key");
+ if (isProbPub(publicKeyB) === false)
+ throw new Error("second arg must be public key");
+ const s = Fn.fromBytes(secretKeyA);
+ const b = Point.fromBytes(publicKeyB);
+ return b.multiply(s).toBytes(isCompressed);
+ }
+ const utils = {
+ isValidSecretKey,
+ isValidPublicKey,
+ randomSecretKey
+ };
+ const keygen = createKeygen(randomSecretKey, getPublicKey);
+ return Object.freeze({ getPublicKey, getSharedSecret, keygen, Point, utils, lengths });
+}
+function ecdsa(Point, hash, ecdsaOpts = {}) {
+ ahash(hash);
+ validateObject(ecdsaOpts, {}, {
+ hmac: "function",
+ lowS: "boolean",
+ randomBytes: "function",
+ bits2int: "function",
+ bits2int_modN: "function"
+ });
+ ecdsaOpts = Object.assign({}, ecdsaOpts);
+ const randomBytes2 = ecdsaOpts.randomBytes || randomBytes;
+ const hmac2 = ecdsaOpts.hmac || ((key, msg) => hmac(hash, key, msg));
+ const { Fp, Fn } = Point;
+ const { ORDER: CURVE_ORDER, BITS: fnBits } = Fn;
+ const { keygen, getPublicKey, getSharedSecret, utils, lengths } = ecdh(Point, ecdsaOpts);
+ const defaultSigOpts = {
+ prehash: true,
+ lowS: typeof ecdsaOpts.lowS === "boolean" ? ecdsaOpts.lowS : true,
+ format: "compact",
+ extraEntropy: false
+ };
+ const hasLargeCofactor = CURVE_ORDER * _2n2 < Fp.ORDER;
+ function isBiggerThanHalfOrder(number) {
+ const HALF = CURVE_ORDER >> _1n4;
+ return number > HALF;
+ }
+ function validateRS(title, num) {
+ if (!Fn.isValidNot0(num))
+ throw new Error(`invalid signature ${title}: out of range 1..Point.Fn.ORDER`);
+ return num;
+ }
+ function assertSmallCofactor() {
+ if (hasLargeCofactor)
+ throw new Error('"recovered" sig type is not supported for cofactor >2 curves');
+ }
+ function validateSigLength(bytes, format) {
+ validateSigFormat(format);
+ const size = lengths.signature;
+ const sizer = format === "compact" ? size : format === "recovered" ? size + 1 : undefined;
+ return abytes(bytes, sizer);
+ }
+
+ class Signature {
+ r;
+ s;
+ recovery;
+ constructor(r, s, recovery) {
+ this.r = validateRS("r", r);
+ this.s = validateRS("s", s);
+ if (recovery != null) {
+ assertSmallCofactor();
+ if (![0, 1, 2, 3].includes(recovery))
+ throw new Error("invalid recovery id");
+ this.recovery = recovery;
+ }
+ Object.freeze(this);
+ }
+ static fromBytes(bytes, format = defaultSigOpts.format) {
+ validateSigLength(bytes, format);
+ let recid;
+ if (format === "der") {
+ const { r: r2, s: s2 } = DER.toSig(abytes(bytes));
+ return new Signature(r2, s2);
+ }
+ if (format === "recovered") {
+ recid = bytes[0];
+ format = "compact";
+ bytes = bytes.subarray(1);
+ }
+ const L = lengths.signature / 2;
+ const r = bytes.subarray(0, L);
+ const s = bytes.subarray(L, L * 2);
+ return new Signature(Fn.fromBytes(r), Fn.fromBytes(s), recid);
+ }
+ static fromHex(hex, format) {
+ return this.fromBytes(hexToBytes(hex), format);
+ }
+ assertRecovery() {
+ const { recovery } = this;
+ if (recovery == null)
+ throw new Error("invalid recovery id: must be present");
+ return recovery;
+ }
+ addRecoveryBit(recovery) {
+ return new Signature(this.r, this.s, recovery);
+ }
+ recoverPublicKey(messageHash) {
+ const { r, s } = this;
+ const recovery = this.assertRecovery();
+ const radj = recovery === 2 || recovery === 3 ? r + CURVE_ORDER : r;
+ if (!Fp.isValid(radj))
+ throw new Error("invalid recovery id: sig.r+curve.n != R.x");
+ const x = Fp.toBytes(radj);
+ const R = Point.fromBytes(concatBytes(pprefix((recovery & 1) === 0), x));
+ const ir = Fn.inv(radj);
+ const h = bits2int_modN(abytes(messageHash, undefined, "msgHash"));
+ const u1 = Fn.create(-h * ir);
+ const u2 = Fn.create(s * ir);
+ const Q = Point.BASE.multiplyUnsafe(u1).add(R.multiplyUnsafe(u2));
+ if (Q.is0())
+ throw new Error("invalid recovery: point at infinify");
+ Q.assertValidity();
+ return Q;
+ }
+ hasHighS() {
+ return isBiggerThanHalfOrder(this.s);
+ }
+ toBytes(format = defaultSigOpts.format) {
+ validateSigFormat(format);
+ if (format === "der")
+ return hexToBytes(DER.hexFromSig(this));
+ const { r, s } = this;
+ const rb = Fn.toBytes(r);
+ const sb = Fn.toBytes(s);
+ if (format === "recovered") {
+ assertSmallCofactor();
+ return concatBytes(Uint8Array.of(this.assertRecovery()), rb, sb);
+ }
+ return concatBytes(rb, sb);
+ }
+ toHex(format) {
+ return bytesToHex(this.toBytes(format));
+ }
+ }
+ const bits2int = ecdsaOpts.bits2int || function bits2int_def(bytes) {
+ if (bytes.length > 8192)
+ throw new Error("input is too large");
+ const num = bytesToNumberBE(bytes);
+ const delta = bytes.length * 8 - fnBits;
+ return delta > 0 ? num >> BigInt(delta) : num;
+ };
+ const bits2int_modN = ecdsaOpts.bits2int_modN || function bits2int_modN_def(bytes) {
+ return Fn.create(bits2int(bytes));
+ };
+ const ORDER_MASK = bitMask(fnBits);
+ function int2octets(num) {
+ aInRange("num < 2^" + fnBits, num, _0n4, ORDER_MASK);
+ return Fn.toBytes(num);
+ }
+ function validateMsgAndHash(message, prehash) {
+ abytes(message, undefined, "message");
+ return prehash ? abytes(hash(message), undefined, "prehashed message") : message;
+ }
+ function prepSig(message, secretKey, opts) {
+ const { lowS, prehash, extraEntropy } = validateSigOpts(opts, defaultSigOpts);
+ message = validateMsgAndHash(message, prehash);
+ const h1int = bits2int_modN(message);
+ const d = Fn.fromBytes(secretKey);
+ if (!Fn.isValidNot0(d))
+ throw new Error("invalid private key");
+ const seedArgs = [int2octets(d), int2octets(h1int)];
+ if (extraEntropy != null && extraEntropy !== false) {
+ const e = extraEntropy === true ? randomBytes2(lengths.secretKey) : extraEntropy;
+ seedArgs.push(abytes(e, undefined, "extraEntropy"));
+ }
+ const seed = concatBytes(...seedArgs);
+ const m = h1int;
+ function k2sig(kBytes) {
+ const k = bits2int(kBytes);
+ if (!Fn.isValidNot0(k))
+ return;
+ const ik = Fn.inv(k);
+ const q = Point.BASE.multiply(k).toAffine();
+ const r = Fn.create(q.x);
+ if (r === _0n4)
+ return;
+ const s = Fn.create(ik * Fn.create(m + r * d));
+ if (s === _0n4)
+ return;
+ let recovery = (q.x === r ? 0 : 2) | Number(q.y & _1n4);
+ let normS = s;
+ if (lowS && isBiggerThanHalfOrder(s)) {
+ normS = Fn.neg(s);
+ recovery ^= 1;
+ }
+ return new Signature(r, normS, hasLargeCofactor ? undefined : recovery);
+ }
+ return { seed, k2sig };
+ }
+ function sign(message, secretKey, opts = {}) {
+ const { seed, k2sig } = prepSig(message, secretKey, opts);
+ const drbg = createHmacDrbg(hash.outputLen, Fn.BYTES, hmac2);
+ const sig = drbg(seed, k2sig);
+ return sig.toBytes(opts.format);
+ }
+ function verify(signature, message, publicKey, opts = {}) {
+ const { lowS, prehash, format } = validateSigOpts(opts, defaultSigOpts);
+ publicKey = abytes(publicKey, undefined, "publicKey");
+ message = validateMsgAndHash(message, prehash);
+ if (!isBytes(signature)) {
+ const end = signature instanceof Signature ? ", use sig.toBytes()" : "";
+ throw new Error("verify expects Uint8Array signature" + end);
+ }
+ validateSigLength(signature, format);
+ try {
+ const sig = Signature.fromBytes(signature, format);
+ const P = Point.fromBytes(publicKey);
+ if (lowS && sig.hasHighS())
+ return false;
+ const { r, s } = sig;
+ const h = bits2int_modN(message);
+ const is = Fn.inv(s);
+ const u1 = Fn.create(h * is);
+ const u2 = Fn.create(r * is);
+ const R = Point.BASE.multiplyUnsafe(u1).add(P.multiplyUnsafe(u2));
+ if (R.is0())
+ return false;
+ const v = Fn.create(R.x);
+ return v === r;
+ } catch (e) {
+ return false;
+ }
+ }
+ function recoverPublicKey(signature, message, opts = {}) {
+ const { prehash } = validateSigOpts(opts, defaultSigOpts);
+ message = validateMsgAndHash(message, prehash);
+ return Signature.fromBytes(signature, "recovered").recoverPublicKey(message).toBytes();
+ }
+ return Object.freeze({
+ keygen,
+ getPublicKey,
+ getSharedSecret,
+ utils,
+ lengths,
+ Point,
+ sign,
+ verify,
+ recoverPublicKey,
+ Signature,
+ hash
+ });
+}
+
+// node_modules/@noble/curves/secp256k1.js
+/*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) */
+var secp256k1_CURVE = {
+ p: BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),
+ n: BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),
+ h: BigInt(1),
+ a: BigInt(0),
+ b: BigInt(7),
+ Gx: BigInt("0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),
+ Gy: BigInt("0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")
+};
+var secp256k1_ENDO = {
+ beta: BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),
+ basises: [
+ [BigInt("0x3086d221a7d46bcde86c90e49284eb15"), -BigInt("0xe4437ed6010e88286f547fa90abfe4c3")],
+ [BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"), BigInt("0x3086d221a7d46bcde86c90e49284eb15")]
+ ]
+};
+var _2n3 = /* @__PURE__ */ BigInt(2);
+function sqrtMod(y) {
+ const P = secp256k1_CURVE.p;
+ const _3n3 = BigInt(3), _6n = BigInt(6), _11n = BigInt(11), _22n = BigInt(22);
+ const _23n = BigInt(23), _44n = BigInt(44), _88n = BigInt(88);
+ const b2 = y * y * y % P;
+ const b3 = b2 * b2 * y % P;
+ const b6 = pow2(b3, _3n3, P) * b3 % P;
+ const b9 = pow2(b6, _3n3, P) * b3 % P;
+ const b11 = pow2(b9, _2n3, P) * b2 % P;
+ const b22 = pow2(b11, _11n, P) * b11 % P;
+ const b44 = pow2(b22, _22n, P) * b22 % P;
+ const b88 = pow2(b44, _44n, P) * b44 % P;
+ const b176 = pow2(b88, _88n, P) * b88 % P;
+ const b220 = pow2(b176, _44n, P) * b44 % P;
+ const b223 = pow2(b220, _3n3, P) * b3 % P;
+ const t1 = pow2(b223, _23n, P) * b22 % P;
+ const t2 = pow2(t1, _6n, P) * b2 % P;
+ const root = pow2(t2, _2n3, P);
+ if (!Fpk1.eql(Fpk1.sqr(root), y))
+ throw new Error("Cannot find square root");
+ return root;
+}
+var Fpk1 = Field(secp256k1_CURVE.p, { sqrt: sqrtMod });
+var Pointk1 = /* @__PURE__ */ weierstrass(secp256k1_CURVE, {
+ Fp: Fpk1,
+ endo: secp256k1_ENDO
+});
+var secp256k1 = /* @__PURE__ */ ecdsa(Pointk1, sha256);
+
+// node_modules/@noble/hashes/sha3.js
+var _0n5 = BigInt(0);
+var _1n5 = BigInt(1);
+var _2n4 = BigInt(2);
+var _7n2 = BigInt(7);
+var _256n = BigInt(256);
+var _0x71n = BigInt(113);
+var SHA3_PI = [];
+var SHA3_ROTL = [];
+var _SHA3_IOTA = [];
+for (let round = 0, R = _1n5, x = 1, y = 0;round < 24; round++) {
+ [x, y] = [y, (2 * x + 3 * y) % 5];
+ SHA3_PI.push(2 * (5 * y + x));
+ SHA3_ROTL.push((round + 1) * (round + 2) / 2 % 64);
+ let t = _0n5;
+ for (let j = 0;j < 7; j++) {
+ R = (R << _1n5 ^ (R >> _7n2) * _0x71n) % _256n;
+ if (R & _2n4)
+ t ^= _1n5 << (_1n5 << BigInt(j)) - _1n5;
+ }
+ _SHA3_IOTA.push(t);
+}
+var IOTAS = split(_SHA3_IOTA, true);
+var SHA3_IOTA_H = IOTAS[0];
+var SHA3_IOTA_L = IOTAS[1];
+var rotlH = (h, l, s) => s > 32 ? rotlBH(h, l, s) : rotlSH(h, l, s);
+var rotlL = (h, l, s) => s > 32 ? rotlBL(h, l, s) : rotlSL(h, l, s);
+function keccakP(s, rounds = 24) {
+ const B = new Uint32Array(5 * 2);
+ for (let round = 24 - rounds;round < 24; round++) {
+ for (let x = 0;x < 10; x++)
+ B[x] = s[x] ^ s[x + 10] ^ s[x + 20] ^ s[x + 30] ^ s[x + 40];
+ for (let x = 0;x < 10; x += 2) {
+ const idx1 = (x + 8) % 10;
+ const idx0 = (x + 2) % 10;
+ const B0 = B[idx0];
+ const B1 = B[idx0 + 1];
+ const Th = rotlH(B0, B1, 1) ^ B[idx1];
+ const Tl = rotlL(B0, B1, 1) ^ B[idx1 + 1];
+ for (let y = 0;y < 50; y += 10) {
+ s[x + y] ^= Th;
+ s[x + y + 1] ^= Tl;
+ }
+ }
+ let curH = s[2];
+ let curL = s[3];
+ for (let t = 0;t < 24; t++) {
+ const shift = SHA3_ROTL[t];
+ const Th = rotlH(curH, curL, shift);
+ const Tl = rotlL(curH, curL, shift);
+ const PI = SHA3_PI[t];
+ curH = s[PI];
+ curL = s[PI + 1];
+ s[PI] = Th;
+ s[PI + 1] = Tl;
+ }
+ for (let y = 0;y < 50; y += 10) {
+ for (let x = 0;x < 10; x++)
+ B[x] = s[y + x];
+ for (let x = 0;x < 10; x++)
+ s[y + x] ^= ~B[(x + 2) % 10] & B[(x + 4) % 10];
+ }
+ s[0] ^= SHA3_IOTA_H[round];
+ s[1] ^= SHA3_IOTA_L[round];
+ }
+ clean(B);
+}
+
+class Keccak {
+ state;
+ pos = 0;
+ posOut = 0;
+ finished = false;
+ state32;
+ destroyed = false;
+ blockLen;
+ suffix;
+ outputLen;
+ enableXOF = false;
+ rounds;
+ constructor(blockLen, suffix, outputLen, enableXOF = false, rounds = 24) {
+ this.blockLen = blockLen;
+ this.suffix = suffix;
+ this.outputLen = outputLen;
+ this.enableXOF = enableXOF;
+ this.rounds = rounds;
+ anumber(outputLen, "outputLen");
+ if (!(0 < blockLen && blockLen < 200))
+ throw new Error("only keccak-f1600 function is supported");
+ this.state = new Uint8Array(200);
+ this.state32 = u32(this.state);
+ }
+ clone() {
+ return this._cloneInto();
+ }
+ keccak() {
+ swap32IfBE(this.state32);
+ keccakP(this.state32, this.rounds);
+ swap32IfBE(this.state32);
+ this.posOut = 0;
+ this.pos = 0;
+ }
+ update(data) {
+ aexists(this);
+ abytes(data);
+ const { blockLen, state } = this;
+ const len = data.length;
+ for (let pos = 0;pos < len; ) {
+ const take = Math.min(blockLen - this.pos, len - pos);
+ for (let i = 0;i < take; i++)
+ state[this.pos++] ^= data[pos++];
+ if (this.pos === blockLen)
+ this.keccak();
+ }
+ return this;
+ }
+ finish() {
+ if (this.finished)
+ return;
+ this.finished = true;
+ const { state, suffix, pos, blockLen } = this;
+ state[pos] ^= suffix;
+ if ((suffix & 128) !== 0 && pos === blockLen - 1)
+ this.keccak();
+ state[blockLen - 1] ^= 128;
+ this.keccak();
+ }
+ writeInto(out) {
+ aexists(this, false);
+ abytes(out);
+ this.finish();
+ const bufferOut = this.state;
+ const { blockLen } = this;
+ for (let pos = 0, len = out.length;pos < len; ) {
+ if (this.posOut >= blockLen)
+ this.keccak();
+ const take = Math.min(blockLen - this.posOut, len - pos);
+ out.set(bufferOut.subarray(this.posOut, this.posOut + take), pos);
+ this.posOut += take;
+ pos += take;
+ }
+ return out;
+ }
+ xofInto(out) {
+ if (!this.enableXOF)
+ throw new Error("XOF is not possible for this instance");
+ return this.writeInto(out);
+ }
+ xof(bytes) {
+ anumber(bytes);
+ return this.xofInto(new Uint8Array(bytes));
+ }
+ digestInto(out) {
+ aoutput(out, this);
+ if (this.finished)
+ throw new Error("digest() was already called");
+ this.writeInto(out);
+ this.destroy();
+ return out;
+ }
+ digest() {
+ return this.digestInto(new Uint8Array(this.outputLen));
+ }
+ destroy() {
+ this.destroyed = true;
+ clean(this.state);
+ }
+ _cloneInto(to) {
+ const { blockLen, suffix, outputLen, rounds, enableXOF } = this;
+ to ||= new Keccak(blockLen, suffix, outputLen, enableXOF, rounds);
+ to.state32.set(this.state32);
+ to.pos = this.pos;
+ to.posOut = this.posOut;
+ to.finished = this.finished;
+ to.rounds = rounds;
+ to.suffix = suffix;
+ to.outputLen = outputLen;
+ to.enableXOF = enableXOF;
+ to.destroyed = this.destroyed;
+ return to;
+ }
+}
+var genKeccak = (suffix, blockLen, outputLen, info = {}) => createHasher(() => new Keccak(blockLen, suffix, outputLen), info);
+var keccak_256 = /* @__PURE__ */ genKeccak(1, 136, 32);
+
+// src/client/key-derivation.ts
+class EncryptIDKeyManager {
+ masterKey = null;
+ derivedKeys = null;
+ fromPRF = false;
+ async initFromPRF(prfOutput) {
+ this.masterKey = await crypto.subtle.importKey("raw", prfOutput, { name: "HKDF" }, false, ["deriveKey", "deriveBits"]);
+ this.fromPRF = true;
+ this.derivedKeys = null;
+ }
+ async initFromPassphrase(passphrase, salt) {
+ const encoder = new TextEncoder;
+ const passphraseKey = await crypto.subtle.importKey("raw", encoder.encode(passphrase), { name: "PBKDF2" }, false, ["deriveBits"]);
+ const masterKeyMaterial = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations: 600000, hash: "SHA-256" }, passphraseKey, 256);
+ this.masterKey = await crypto.subtle.importKey("raw", masterKeyMaterial, { name: "HKDF" }, false, ["deriveKey", "deriveBits"]);
+ this.fromPRF = false;
+ this.derivedKeys = null;
+ }
+ static generateSalt() {
+ return crypto.getRandomValues(new Uint8Array(32));
+ }
+ isInitialized() {
+ return this.masterKey !== null;
+ }
+ async getKeys() {
+ if (!this.masterKey)
+ throw new Error("Key manager not initialized");
+ if (this.derivedKeys)
+ return this.derivedKeys;
+ const [encryptionKey, signingKeyPair, didSeed, ethereum] = await Promise.all([
+ this.deriveEncryptionKey(),
+ this.deriveSigningKeyPair(),
+ this.deriveDIDSeed(),
+ this.deriveEthereumKeys()
+ ]);
+ const did = await this.generateDID(didSeed);
+ this.derivedKeys = { encryptionKey, signingKeyPair, didSeed, did, fromPRF: this.fromPRF, ethereum };
+ return this.derivedKeys;
+ }
+ async deriveEncryptionKey() {
+ const encoder = new TextEncoder;
+ return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("encryptid-encryption-key-v1"), info: encoder.encode("AES-256-GCM") }, this.masterKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt", "wrapKey", "unwrapKey"]);
+ }
+ async deriveSigningKeyPair() {
+ return crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
+ }
+ async deriveEthereumKeys() {
+ const encoder = new TextEncoder;
+ const privateKeyBits = await crypto.subtle.deriveBits({
+ name: "HKDF",
+ hash: "SHA-256",
+ salt: encoder.encode("encryptid-ethereum-secp256k1-v1"),
+ info: encoder.encode("secp256k1-private-key")
+ }, this.masterKey, 256);
+ const privateKey = new Uint8Array(privateKeyBits);
+ const publicKey = secp256k1.getPublicKey(privateKey, true);
+ const uncompressedPubKey = secp256k1.getPublicKey(privateKey, false);
+ const pubKeyHash = keccak_256(uncompressedPubKey.slice(1));
+ const addressBytes = pubKeyHash.slice(-20);
+ const address = toChecksumAddress(addressBytes);
+ return { address, publicKey, privateKey };
+ }
+ async deriveDIDSeed() {
+ const encoder = new TextEncoder;
+ const seed = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: encoder.encode("encryptid-did-key-v1"), info: encoder.encode("Ed25519-seed") }, this.masterKey, 256);
+ return new Uint8Array(seed);
+ }
+ async generateDID(seed) {
+ const publicKeyHash = await crypto.subtle.digest("SHA-256", seed);
+ const publicKeyBytes = new Uint8Array(publicKeyHash).slice(0, 32);
+ const multicodecPrefix = new Uint8Array([237, 1]);
+ const multicodecKey = new Uint8Array(34);
+ multicodecKey.set(multicodecPrefix);
+ multicodecKey.set(publicKeyBytes, 2);
+ const base58Encoded = bufferToBase64url(multicodecKey.buffer).replace(/-/g, "").replace(/_/g, "");
+ return `did:key:z${base58Encoded}`;
+ }
+ clear() {
+ this.masterKey = null;
+ this.derivedKeys = null;
+ this.fromPRF = false;
+ }
+}
+function toChecksumAddress(addressBytes) {
+ const hex = Array.from(addressBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
+ const hash = keccak_256(new TextEncoder().encode(hex));
+ let checksummed = "0x";
+ for (let i = 0;i < 40; i++) {
+ const hashNibble = hash[Math.floor(i / 2)] >> (i % 2 === 0 ? 4 : 0) & 15;
+ checksummed += hashNibble >= 8 ? hex[i].toUpperCase() : hex[i];
+ }
+ return checksummed;
+}
+function signEthHash(hash, privateKey) {
+ const sigBytes = secp256k1.sign(hash, privateKey, { prehash: false, format: "recovered" });
+ const sig = secp256k1.Signature.fromBytes(sigBytes, "recovered");
+ return {
+ r: sig.r.toString(16).padStart(64, "0"),
+ s: sig.s.toString(16).padStart(64, "0"),
+ v: (sig.recovery ?? 0) + 27,
+ signature: sig.toBytes("compact")
+ };
+}
+var keyManagerInstance = null;
+function getKeyManager() {
+ if (!keyManagerInstance)
+ keyManagerInstance = new EncryptIDKeyManager;
+ return keyManagerInstance;
+}
+// src/client/session.ts
+var OPERATION_PERMISSIONS = {
+ "rspace:view-public": { minAuthLevel: 1 /* BASIC */ },
+ "rspace:view-private": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:edit-board": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:create-board": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:delete-board": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rspace:encrypt-board": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "encrypt" },
+ "rwallet:view-balance": { minAuthLevel: 1 /* BASIC */ },
+ "rwallet:view-history": { minAuthLevel: 2 /* STANDARD */ },
+ "rwallet:send-small": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "wallet" },
+ "rwallet:send-large": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "wallet", maxAgeSeconds: 60 },
+ "rwallet:add-guardian": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rwallet:remove-guardian": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rvote:view-proposals": { minAuthLevel: 1 /* BASIC */ },
+ "rvote:cast-vote": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "sign", maxAgeSeconds: 300 },
+ "rvote:delegate": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "wallet" },
+ "rfiles:list-files": { minAuthLevel: 2 /* STANDARD */ },
+ "rfiles:download-own": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "encrypt" },
+ "rfiles:upload": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "encrypt" },
+ "rfiles:share": { minAuthLevel: 3 /* ELEVATED */, requiresCapability: "encrypt" },
+ "rfiles:delete": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rfiles:export-keys": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rmaps:view-public": { minAuthLevel: 1 /* BASIC */ },
+ "rmaps:add-location": { minAuthLevel: 2 /* STANDARD */ },
+ "rmaps:edit-location": { minAuthLevel: 2 /* STANDARD */, requiresCapability: "sign" },
+ "account:view-profile": { minAuthLevel: 2 /* STANDARD */ },
+ "account:edit-profile": { minAuthLevel: 3 /* ELEVATED */ },
+ "account:export-data": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "account:delete": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rspace:create-space": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:configure-space": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rspace:delete-space": { minAuthLevel: 4 /* CRITICAL */, maxAgeSeconds: 60 },
+ "rspace:invite-member": { minAuthLevel: 2 /* STANDARD */ },
+ "rspace:remove-member": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rspace:change-visibility": { minAuthLevel: 3 /* ELEVATED */, maxAgeSeconds: 300 },
+ "rfunds:create-space": { minAuthLevel: 2 /* STANDARD */ },
+ "rfunds:edit-flows": { minAuthLevel: 2 /* STANDARD */ },
+ "rfunds:share-space": { minAuthLevel: 2 /* STANDARD */ }
+};
+var SESSION_STORAGE_KEY = "encryptid_session";
+var TOKEN_REFRESH_THRESHOLD = 5 * 60 * 1000;
+
+class SessionManager {
+ session = null;
+ refreshTimer = null;
+ constructor() {
+ this.restoreSession();
+ }
+ async createSession(authResult, did, capabilities, walletAddress, username) {
+ const now = Math.floor(Date.now() / 1000);
+ const claims = {
+ iss: "https://encryptid.jeffemmett.com",
+ sub: did,
+ aud: ["rspace.online", "rwallet.online", "rvote.online", "rfiles.online", "rmaps.online", "rmail.online"],
+ iat: now,
+ exp: now + 15 * 60,
+ jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
+ username: username || "",
+ eid: {
+ walletAddress,
+ credentialId: authResult.credentialId,
+ authLevel: 3 /* ELEVATED */,
+ authTime: now,
+ capabilities,
+ recoveryConfigured: false
+ }
+ };
+ const accessToken = this.createUnsignedToken(claims);
+ const refreshToken = this.createRefreshToken(did);
+ this.session = { accessToken, refreshToken, claims, lastAuthTime: Date.now() };
+ this.persistSession();
+ this.scheduleRefresh();
+ return this.session;
+ }
+ getSession() {
+ return this.session;
+ }
+ getDID() {
+ return this.session?.claims.sub ?? null;
+ }
+ getAccessToken() {
+ return this.session?.accessToken ?? null;
+ }
+ getAuthLevel() {
+ if (!this.session)
+ return 1 /* BASIC */;
+ const now = Math.floor(Date.now() / 1000);
+ if (now >= this.session.claims.exp)
+ return 1 /* BASIC */;
+ const authAge = now - this.session.claims.eid.authTime;
+ if (authAge < 60)
+ return 3 /* ELEVATED */;
+ if (authAge < 15 * 60)
+ return 2 /* STANDARD */;
+ return 1 /* BASIC */;
+ }
+ canPerform(operation) {
+ const permission = OPERATION_PERMISSIONS[operation];
+ if (!permission)
+ return { allowed: false, reason: "Unknown operation" };
+ if (!this.session)
+ return { allowed: false, reason: "Not authenticated" };
+ const currentLevel = this.getAuthLevel();
+ if (currentLevel < permission.minAuthLevel) {
+ return { allowed: false, reason: `Requires ${AuthLevel[permission.minAuthLevel]} auth level (current: ${AuthLevel[currentLevel]})` };
+ }
+ if (permission.requiresCapability) {
+ if (!this.session.claims.eid.capabilities[permission.requiresCapability]) {
+ return { allowed: false, reason: `Requires ${permission.requiresCapability} capability` };
+ }
+ }
+ if (permission.maxAgeSeconds) {
+ const authAge = Math.floor(Date.now() / 1000) - this.session.claims.eid.authTime;
+ if (authAge > permission.maxAgeSeconds) {
+ return { allowed: false, reason: `Authentication too old (${authAge}s > ${permission.maxAgeSeconds}s)` };
+ }
+ }
+ return { allowed: true };
+ }
+ requiresFreshAuth(operation) {
+ const permission = OPERATION_PERMISSIONS[operation];
+ if (!permission)
+ return true;
+ if (permission.minAuthLevel >= 4 /* CRITICAL */)
+ return true;
+ if (permission.maxAgeSeconds && permission.maxAgeSeconds <= 60)
+ return true;
+ return false;
+ }
+ upgradeAuthLevel(level = 3 /* ELEVATED */) {
+ if (!this.session)
+ return;
+ this.session.claims.eid.authLevel = level;
+ this.session.claims.eid.authTime = Math.floor(Date.now() / 1000);
+ this.session.lastAuthTime = Date.now();
+ this.persistSession();
+ }
+ clearSession() {
+ this.session = null;
+ if (this.refreshTimer) {
+ clearTimeout(this.refreshTimer);
+ this.refreshTimer = null;
+ }
+ try {
+ localStorage.removeItem(SESSION_STORAGE_KEY);
+ } catch {}
+ }
+ isValid() {
+ if (!this.session)
+ return false;
+ return Math.floor(Date.now() / 1000) < this.session.claims.exp;
+ }
+ createUnsignedToken(claims) {
+ const header = { alg: "none", typ: "JWT" };
+ return `${btoa(JSON.stringify(header))}.${btoa(JSON.stringify(claims))}.`;
+ }
+ createRefreshToken(did) {
+ return btoa(JSON.stringify({
+ sub: did,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
+ jti: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer)
+ }));
+ }
+ persistSession() {
+ if (!this.session)
+ return;
+ try {
+ localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.session));
+ } catch {}
+ }
+ restoreSession() {
+ try {
+ const stored = localStorage.getItem(SESSION_STORAGE_KEY);
+ if (stored) {
+ const session = JSON.parse(stored);
+ if (Math.floor(Date.now() / 1000) < session.claims.exp) {
+ this.session = session;
+ this.scheduleRefresh();
+ } else {
+ localStorage.removeItem(SESSION_STORAGE_KEY);
+ }
+ }
+ } catch {}
+ }
+ scheduleRefresh() {
+ if (!this.session)
+ return;
+ if (this.refreshTimer)
+ clearTimeout(this.refreshTimer);
+ const expiresAt = this.session.claims.exp * 1000;
+ const refreshAt = expiresAt - TOKEN_REFRESH_THRESHOLD;
+ const delay = Math.max(refreshAt - Date.now(), 0);
+ this.refreshTimer = setTimeout(() => this.refreshTokens(), delay);
+ }
+ async refreshTokens() {
+ if (!this.session)
+ return;
+ const now = Math.floor(Date.now() / 1000);
+ this.session.claims.eid.authLevel = Math.min(this.session.claims.eid.authLevel, 2 /* STANDARD */);
+ this.session.claims.iat = now;
+ this.session.claims.exp = now + 15 * 60;
+ this.session.claims.jti = bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer);
+ this.session.accessToken = this.createUnsignedToken(this.session.claims);
+ this.persistSession();
+ this.scheduleRefresh();
+ }
+}
+var sessionManagerInstance = null;
+function getSessionManager() {
+ if (!sessionManagerInstance)
+ sessionManagerInstance = new SessionManager;
+ return sessionManagerInstance;
+}
+// src/client/recovery.ts
+class RecoveryManager {
+ config = null;
+ activeRequest = null;
+ constructor() {
+ this.loadConfig();
+ }
+ async initializeRecovery(threshold = 3) {
+ this.config = {
+ threshold,
+ delaySeconds: 48 * 60 * 60,
+ guardians: [],
+ guardianListHash: "",
+ updatedAt: Date.now()
+ };
+ await this.saveConfig();
+ return this.config;
+ }
+ async addGuardian(guardian) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ if (this.config.guardians.length >= 7)
+ throw new Error("Maximum of 7 guardians allowed");
+ const newGuardian = {
+ ...guardian,
+ id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
+ addedAt: Date.now()
+ };
+ this.config.guardians.push(newGuardian);
+ this.config.guardianListHash = await this.hashGuardianList();
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ return newGuardian;
+ }
+ async removeGuardian(guardianId) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ const index = this.config.guardians.findIndex((g) => g.id === guardianId);
+ if (index === -1)
+ throw new Error("Guardian not found");
+ const remainingWeight = this.config.guardians.filter((g) => g.id !== guardianId).reduce((sum, g) => sum + g.weight, 0);
+ if (remainingWeight < this.config.threshold)
+ throw new Error("Cannot remove guardian: would make recovery impossible");
+ this.config.guardians.splice(index, 1);
+ this.config.guardianListHash = await this.hashGuardianList();
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ }
+ async setThreshold(threshold) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ const totalWeight = this.config.guardians.reduce((sum, g) => sum + g.weight, 0);
+ if (threshold > totalWeight)
+ throw new Error("Threshold cannot exceed total guardian weight");
+ if (threshold < 1)
+ throw new Error("Threshold must be at least 1");
+ this.config.threshold = threshold;
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ }
+ async setDelay(delaySeconds) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ if (delaySeconds < 3600 || delaySeconds > 7 * 24 * 3600)
+ throw new Error("Delay must be between 1 hour and 7 days");
+ this.config.delaySeconds = delaySeconds;
+ this.config.updatedAt = Date.now();
+ await this.saveConfig();
+ }
+ getConfig() {
+ return this.config;
+ }
+ isConfigured() {
+ if (!this.config)
+ return false;
+ return this.config.guardians.reduce((sum, g) => sum + g.weight, 0) >= this.config.threshold;
+ }
+ async verifyGuardian(guardianId) {
+ if (!this.config)
+ throw new Error("Recovery not initialized");
+ const guardian = this.config.guardians.find((g) => g.id === guardianId);
+ if (!guardian)
+ throw new Error("Guardian not found");
+ guardian.lastVerified = Date.now();
+ await this.saveConfig();
+ return true;
+ }
+ async initiateRecovery(newCredentialId) {
+ if (!this.config)
+ throw new Error("Recovery not configured");
+ if (this.activeRequest?.status === "pending")
+ throw new Error("Recovery already in progress");
+ const now = Date.now();
+ this.activeRequest = {
+ id: bufferToBase64url(crypto.getRandomValues(new Uint8Array(16)).buffer),
+ accountDID: "",
+ newCredentialId,
+ initiatedAt: now,
+ completesAt: now + this.config.delaySeconds * 1000,
+ status: "pending",
+ approvals: [],
+ approvalWeight: 0
+ };
+ return this.activeRequest;
+ }
+ async approveRecovery(guardianId, signature) {
+ if (!this.activeRequest || this.activeRequest.status !== "pending")
+ throw new Error("No pending recovery request");
+ if (!this.config)
+ throw new Error("Recovery not configured");
+ const guardian = this.config.guardians.find((g) => g.id === guardianId);
+ if (!guardian)
+ throw new Error("Guardian not found");
+ if (this.activeRequest.approvals.some((a) => a.guardianId === guardianId))
+ throw new Error("Guardian already approved");
+ this.activeRequest.approvals.push({ guardianId, approvedAt: Date.now(), signature });
+ this.activeRequest.approvalWeight += guardian.weight;
+ if (this.activeRequest.approvalWeight >= this.config.threshold) {
+ this.activeRequest.status = "approved";
+ }
+ return this.activeRequest;
+ }
+ async cancelRecovery() {
+ if (!this.activeRequest || this.activeRequest.status !== "pending")
+ throw new Error("No pending recovery request to cancel");
+ this.activeRequest.status = "cancelled";
+ this.activeRequest = null;
+ }
+ async completeRecovery() {
+ if (!this.activeRequest)
+ throw new Error("No recovery request");
+ if (this.activeRequest.status !== "approved")
+ throw new Error("Recovery not approved");
+ if (Date.now() < this.activeRequest.completesAt) {
+ const remaining = this.activeRequest.completesAt - Date.now();
+ throw new Error(`Time-lock not expired. ${Math.ceil(remaining / 1000 / 60)} minutes remaining.`);
+ }
+ this.activeRequest.status = "completed";
+ this.activeRequest = null;
+ }
+ getActiveRequest() {
+ return this.activeRequest;
+ }
+ async hashGuardianList() {
+ if (!this.config)
+ return "";
+ const sortedIds = this.config.guardians.map((g) => g.id).sort().join(",");
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sortedIds));
+ return bufferToBase64url(hash);
+ }
+ async saveConfig() {
+ if (!this.config)
+ return;
+ try {
+ localStorage.setItem("encryptid_recovery", JSON.stringify(this.config));
+ } catch {}
+ }
+ loadConfig() {
+ try {
+ const stored = localStorage.getItem("encryptid_recovery");
+ if (stored)
+ this.config = JSON.parse(stored);
+ } catch {}
+ }
+}
+var recoveryManagerInstance = null;
+function getRecoveryManager() {
+ if (!recoveryManagerInstance)
+ recoveryManagerInstance = new RecoveryManager;
+ return recoveryManagerInstance;
+}
+function getGuardianTypeInfo(type) {
+ switch (type) {
+ case "secondary_passkey" /* SECONDARY_PASSKEY */:
+ return { name: "Backup Passkey", description: "Another device you own (phone, YubiKey, etc.)", icon: "key", setupInstructions: "Register a passkey on a second device you control." };
+ case "trusted_contact" /* TRUSTED_CONTACT */:
+ return { name: "Trusted Contact", description: "A friend or family member with their own EncryptID", icon: "user", setupInstructions: "Ask a trusted person to create an EncryptID account." };
+ case "hardware_key" /* HARDWARE_KEY */:
+ return { name: "Hardware Security Key", description: "A YubiKey or similar device stored offline", icon: "shield", setupInstructions: "Register a hardware security key and store it safely." };
+ case "institutional" /* INSTITUTIONAL */:
+ return { name: "Recovery Service", description: "A professional recovery service provider", icon: "building", setupInstructions: "Connect with a trusted recovery service." };
+ case "time_delayed_self" /* TIME_DELAYED_SELF */:
+ return { name: "Time-Delayed Self", description: "Recover yourself after a waiting period", icon: "clock", setupInstructions: "Set up a recovery option that requires waiting before completing." };
+ default:
+ return { name: "Unknown", description: "Unknown guardian type", icon: "question", setupInstructions: "" };
+ }
+}
+
+// src/index.ts
+var VERSION = "0.1.0";
+var SPEC_VERSION = "2026-02";
+export {
+ startConditionalUI,
+ signEthHash,
+ registerPasskey,
+ getSessionManager,
+ getRecoveryManager,
+ getKeyManager,
+ getGuardianTypeInfo,
+ detectCapabilities,
+ bufferToBase64url,
+ base64urlToBuffer,
+ authenticatePasskey,
+ VERSION,
+ SessionManager,
+ SPEC_VERSION,
+ RecoveryManager,
+ OPERATION_PERMISSIONS,
+ GuardianType,
+ EncryptIDKeyManager,
+ EncryptIDClient,
+ AuthLevel
+};
diff --git a/frontend/vendor/@encryptid/sdk/package.json b/frontend/vendor/@encryptid/sdk/package.json
new file mode 100644
index 0000000..9535c59
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/package.json
@@ -0,0 +1,129 @@
+{
+ "name": "@encryptid/sdk",
+ "version": "0.1.0",
+ "description": "Unified identity SDK for the r-ecosystem — WebAuthn passkeys, key derivation, session management, and social recovery",
+ "type": "module",
+ "main": "./index.js",
+ "types": "./index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./index.js",
+ "types": "./index.d.ts"
+ },
+ "./client": {
+ "import": "./client/index.js",
+ "types": "./client/index.d.ts"
+ },
+ "./server": {
+ "import": "./server/index.js",
+ "types": "./server/index.d.ts"
+ },
+ "./server/nextjs": {
+ "import": "./server/middleware/nextjs.js",
+ "types": "./server/middleware/nextjs.d.ts"
+ },
+ "./server/hono": {
+ "import": "./server/middleware/hono.js",
+ "types": "./server/middleware/hono.d.ts"
+ },
+ "./server/express": {
+ "import": "./server/middleware/express.js",
+ "types": "./server/middleware/express.d.ts"
+ },
+ "./server/space-auth": {
+ "import": "./server/space-auth.js",
+ "types": "./server/space-auth.d.ts"
+ },
+ "./server/ws-auth": {
+ "import": "./server/ws-auth.js",
+ "types": "./server/ws-auth.d.ts"
+ },
+ "./ui": {
+ "import": "./ui/index.js",
+ "types": "./ui/index.d.ts"
+ },
+ "./ui/react": {
+ "import": "./ui/react/index.js",
+ "types": "./ui/react/index.d.ts"
+ },
+ "./types": {
+ "import": "./types/index.js",
+ "types": "./types/index.d.ts"
+ },
+ "./types/roles": {
+ "import": "./types/roles.js",
+ "types": "./types/roles.d.ts"
+ },
+ "./types/module-permissions": {
+ "import": "./types/module-permissions.js",
+ "types": "./types/module-permissions.d.ts"
+ },
+ "./types/modules": {
+ "import": "./types/modules/index.js",
+ "types": "./types/modules/index.d.ts"
+ },
+ "./server/role-resolver": {
+ "import": "./server/role-resolver.js",
+ "types": "./server/role-resolver.d.ts"
+ },
+ "./types/membership-events": {
+ "import": "./types/membership-events.js",
+ "types": "./types/membership-events.d.ts"
+ },
+ "./client/token-relay": {
+ "import": "./client/token-relay.js",
+ "types": "./client/token-relay.d.ts"
+ },
+ "./browser": {
+ "import": "./encryptid.browser.js",
+ "default": "./encryptid.browser.js"
+ }
+ },
+ "files": [
+ "dist",
+ "src/python",
+ "src/browser.ts"
+ ],
+ "scripts": {
+ "build": "bun build ./src/index.ts --outdir ./dist --target browser && tsc --emitDeclarationOnly",
+ "build:browser": "bun build ./src/browser.ts --outfile ./encryptid.browser.js --target browser --minify",
+ "build:node": "bun build ./src/server/index.ts --outdir ./server --target node",
+ "build:all": "bun run build && bun run build:browser",
+ "typecheck": "tsc --noEmit",
+ "clean": "rm -rf dist"
+ },
+ "dependencies": {
+ "@noble/curves": "^2.0.1",
+ "@noble/hashes": "^2.0.1",
+ "hono": "^4.11.0"
+ },
+ "peerDependencies": {
+ "next": ">=14.0.0",
+ "react": ">=18.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "next": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "typescript": "^5.7.0"
+ },
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://gitea.jeffemmett.com/jeffemmett/encryptid-sdk"
+ },
+ "keywords": [
+ "webauthn",
+ "passkey",
+ "identity",
+ "encryption",
+ "self-sovereign",
+ "social-recovery"
+ ]
+}
diff --git a/frontend/vendor/@encryptid/sdk/server/index.d.ts b/frontend/vendor/@encryptid/sdk/server/index.d.ts
new file mode 100644
index 0000000..1720b41
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/index.d.ts
@@ -0,0 +1,10 @@
+/**
+ * @encryptid/sdk/server — Server-side module
+ */
+export { verifyEncryptIDToken, getAuthLevel, checkPermission, } from './jwt-verify.js';
+export type { VerifyOptions } from './jwt-verify.js';
+export { evaluateSpaceAccess, extractToken, SpaceVisibility, } from './space-auth.js';
+export type { SpaceAuthConfig, SpaceAuthResult, SpaceAuthOptions } from './space-auth.js';
+export { authenticateWSUpgrade } from './ws-auth.js';
+export { resolveSpaceRole, resolveSpaceRoleRemote, createRemoteMembershipLookup, invalidateRoleCache } from './role-resolver.js';
+export type { RoleResolverOptions, RemoteResolverOptions } from './role-resolver.js';
diff --git a/frontend/vendor/@encryptid/sdk/server/index.js b/frontend/vendor/@encryptid/sdk/server/index.js
new file mode 100644
index 0000000..f787184
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/index.js
@@ -0,0 +1,24 @@
+import {
+ evaluateSpaceAccess,
+ extractToken
+} from "../index-j6kh1974.js";
+import {
+ SpaceVisibility
+} from "../index-5c1t4ftn.js";
+import {
+ authenticateWSUpgrade
+} from "../index-2yszamrn.js";
+import {
+ checkPermission,
+ getAuthLevel,
+ verifyEncryptIDToken
+} from "../index-stg63j73.js";
+export {
+ verifyEncryptIDToken,
+ getAuthLevel,
+ extractToken,
+ evaluateSpaceAccess,
+ checkPermission,
+ authenticateWSUpgrade,
+ SpaceVisibility
+};
diff --git a/frontend/vendor/@encryptid/sdk/server/jwt-verify.d.ts b/frontend/vendor/@encryptid/sdk/server/jwt-verify.d.ts
new file mode 100644
index 0000000..ffe9365
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/jwt-verify.d.ts
@@ -0,0 +1,35 @@
+/**
+ * EncryptID JWT Verification
+ *
+ * Server-side utilities for verifying EncryptID JWT tokens.
+ * Can verify locally with shared secret or by calling the EncryptID server.
+ */
+import type { EncryptIDClaims, OperationPermission } from '../types/index.js';
+export interface VerifyOptions {
+ /** JWT secret for local verification (HS256) */
+ secret?: string;
+ /** EncryptID server URL for remote verification */
+ serverUrl?: string;
+ /** Expected audience (your app's origin) */
+ audience?: string;
+ /** Clock tolerance in seconds for expiration check */
+ clockTolerance?: number;
+}
+/**
+ * Verify an EncryptID JWT token
+ *
+ * If `secret` is provided, verifies locally using HMAC-SHA256.
+ * Otherwise, calls the EncryptID server's /api/session/verify endpoint.
+ */
+export declare function verifyEncryptIDToken(token: string, options?: VerifyOptions): Promise;
+/**
+ * Extract the auth level from claims
+ */
+export declare function getAuthLevel(claims: EncryptIDClaims): number;
+/**
+ * Check if claims satisfy an operation permission
+ */
+export declare function checkPermission(claims: EncryptIDClaims, permission: OperationPermission): {
+ allowed: boolean;
+ reason?: string;
+};
diff --git a/frontend/vendor/@encryptid/sdk/server/middleware/express.d.ts b/frontend/vendor/@encryptid/sdk/server/middleware/express.d.ts
new file mode 100644
index 0000000..0e2c3b2
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/middleware/express.d.ts
@@ -0,0 +1,74 @@
+/**
+ * EncryptID Express Middleware
+ *
+ * Authentication middleware for Express and compatible frameworks.
+ */
+import { type VerifyOptions } from '../jwt-verify.js';
+import { type SpaceAuthOptions } from '../space-auth.js';
+import type { EncryptIDClaims, SpaceAuthResult } from '../../types/index.js';
+declare global {
+ namespace Express {
+ interface Request {
+ encryptid?: EncryptIDClaims;
+ spaceAuth?: SpaceAuthResult;
+ }
+ }
+}
+interface ExpressRequest {
+ headers: Record;
+ method: string;
+ encryptid?: EncryptIDClaims;
+ spaceAuth?: SpaceAuthResult;
+}
+interface ExpressResponse {
+ status(code: number): ExpressResponse;
+ json(body: unknown): void;
+}
+type NextFunction = () => void;
+/**
+ * Express middleware that verifies EncryptID JWT tokens
+ *
+ * Usage:
+ * ```ts
+ * import express from 'express';
+ * import { encryptIDAuth } from '@encryptid/sdk/server/express';
+ *
+ * const app = express();
+ *
+ * app.use('/api', encryptIDAuth());
+ *
+ * app.get('/api/profile', (req, res) => {
+ * res.json({ did: req.encryptid.did });
+ * });
+ * ```
+ */
+export declare function encryptIDAuth(options?: VerifyOptions): (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => Promise;
+/**
+ * Optional auth — sets session if token present, continues either way
+ */
+export declare function encryptIDOptional(options?: VerifyOptions): (req: ExpressRequest, _res: ExpressResponse, next: NextFunction) => Promise;
+export interface EncryptIDSpaceAuthConfig extends SpaceAuthOptions {
+ /** Function to extract space slug from request (default: req.params.slug) */
+ getSlug?: (req: ExpressRequest & {
+ params?: Record;
+ }) => string;
+}
+/**
+ * Express middleware for space-aware auth.
+ *
+ * Usage:
+ * ```ts
+ * app.use('/api/spaces/:slug', encryptIDSpaceAuth({
+ * getSpaceConfig: async (slug) => db.getSpace(slug),
+ * }));
+ *
+ * app.get('/api/spaces/:slug', (req, res) => {
+ * const { readOnly, isOwner } = req.spaceAuth;
+ * res.json({ canEdit: !readOnly, isOwner });
+ * });
+ * ```
+ */
+export declare function encryptIDSpaceAuth(config: EncryptIDSpaceAuthConfig): (req: ExpressRequest & {
+ params?: Record;
+}, res: ExpressResponse, next: NextFunction) => Promise;
+export {};
diff --git a/frontend/vendor/@encryptid/sdk/server/middleware/express.js b/frontend/vendor/@encryptid/sdk/server/middleware/express.js
new file mode 100644
index 0000000..efcdb85
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/middleware/express.js
@@ -0,0 +1,59 @@
+import {
+ evaluateSpaceAccess,
+ extractToken
+} from "../../index-j6kh1974.js";
+import"../../index-5c1t4ftn.js";
+import {
+ verifyEncryptIDToken
+} from "../../index-stg63j73.js";
+
+// src/server/middleware/express.ts
+function encryptIDAuth(options = {}) {
+ return async (req, res, next) => {
+ const authHeader = req.headers.authorization;
+ if (!authHeader || typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
+ res.status(401).json({ error: "Missing EncryptID token" });
+ return;
+ }
+ const token = authHeader.slice(7);
+ try {
+ req.encryptid = await verifyEncryptIDToken(token, options);
+ next();
+ } catch (err) {
+ res.status(401).json({ error: err.message || "Invalid token" });
+ }
+ };
+}
+function encryptIDOptional(options = {}) {
+ return async (req, _res, next) => {
+ const authHeader = req.headers.authorization;
+ if (authHeader && typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
+ try {
+ req.encryptid = await verifyEncryptIDToken(authHeader.slice(7), options);
+ } catch {}
+ }
+ next();
+ };
+}
+function encryptIDSpaceAuth(config) {
+ const { getSlug, ...options } = config;
+ return async (req, res, next) => {
+ const slug = getSlug ? getSlug(req) : req.params?.slug || "";
+ const token = extractToken(req.headers);
+ const result = await evaluateSpaceAccess(slug, token, req.method, options);
+ if (!result.allowed) {
+ res.status(result.claims ? 403 : 401).json({ error: result.reason });
+ return;
+ }
+ if (result.claims) {
+ req.encryptid = result.claims;
+ }
+ req.spaceAuth = result;
+ next();
+ };
+}
+export {
+ encryptIDSpaceAuth,
+ encryptIDOptional,
+ encryptIDAuth
+};
diff --git a/frontend/vendor/@encryptid/sdk/server/middleware/hono.d.ts b/frontend/vendor/@encryptid/sdk/server/middleware/hono.d.ts
new file mode 100644
index 0000000..b01aead
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/middleware/hono.d.ts
@@ -0,0 +1,100 @@
+/**
+ * EncryptID Hono Middleware
+ *
+ * Authentication middleware for Hono web framework.
+ */
+import type { MiddlewareHandler } from 'hono';
+import { type VerifyOptions } from '../jwt-verify.js';
+import { type SpaceAuthOptions } from '../space-auth.js';
+import type { EncryptIDClaims, SpaceAuthResult } from '../../types/index.js';
+import type { ResolvedRole } from '../../types/roles.js';
+declare module 'hono' {
+ interface ContextVariableMap {
+ encryptid: EncryptIDClaims;
+ spaceAuth: SpaceAuthResult;
+ spaceRole: ResolvedRole;
+ }
+}
+/**
+ * Hono middleware that verifies EncryptID JWT tokens
+ *
+ * Usage:
+ * ```ts
+ * import { Hono } from 'hono';
+ * import { encryptIDAuth } from '@encryptid/sdk/server/hono';
+ *
+ * const app = new Hono();
+ *
+ * // Protect all /api routes
+ * app.use('/api/*', encryptIDAuth());
+ *
+ * app.get('/api/profile', (c) => {
+ * const session = c.get('encryptid');
+ * return c.json({ did: session.did, sub: session.sub });
+ * });
+ * ```
+ */
+export declare function encryptIDAuth(options?: VerifyOptions): MiddlewareHandler;
+/**
+ * Optional auth — sets session if token present, continues either way
+ */
+export declare function encryptIDOptional(options?: VerifyOptions): MiddlewareHandler;
+export interface EncryptIDSpaceAuthConfig extends SpaceAuthOptions {
+ /** Route param name for the space slug (default: 'slug') */
+ slugParam?: string;
+ /** Query param fallback for the space slug (default: 'space') */
+ slugQuery?: string;
+}
+/**
+ * Hono middleware for space-aware auth.
+ *
+ * Reads the space slug from route params or query, evaluates access
+ * based on visibility, and sets `c.var.spaceAuth` with the result.
+ *
+ * Usage:
+ * ```ts
+ * app.use('/api/communities/:slug/*', encryptIDSpaceAuth({
+ * getSpaceConfig: async (slug) => db.getCommunity(slug),
+ * }));
+ *
+ * app.get('/api/communities/:slug', (c) => {
+ * const auth = c.get('spaceAuth');
+ * if (auth.readOnly) { // public_read, unauthenticated
+ * return c.json({ ...community, canEdit: false });
+ * }
+ * });
+ * ```
+ */
+export declare function encryptIDSpaceAuth(config: EncryptIDSpaceAuthConfig): MiddlewareHandler;
+export interface EncryptIDSpaceRoleConfig extends EncryptIDSpaceAuthConfig {
+ /** Look up membership for a DID in a space. You provide the DB query. */
+ getMembership: (userDID: string, spaceSlug: string) => Promise;
+ /** Resolve visibility for a space slug (if not in SpaceAuthConfig). Defaults to using getSpaceConfig. */
+ getVisibility?: (spaceSlug: string) => Promise;
+}
+/**
+ * Combined space auth + role resolution middleware for Hono.
+ *
+ * Sets `c.var.spaceAuth`, `c.var.spaceRole`, and optionally `c.var.encryptid`.
+ *
+ * Usage:
+ * ```ts
+ * import { encryptIDSpaceRoleAuth } from '@encryptid/sdk/server/hono';
+ * import { hasCapability } from '@encryptid/sdk';
+ * import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
+ *
+ * app.use('/api/spaces/:slug/*', encryptIDSpaceRoleAuth({
+ * getSpaceConfig: async (slug) => db.getSpace(slug),
+ * getMembership: async (did, slug) => db.getMembership(did, slug),
+ * }));
+ *
+ * app.post('/api/spaces/:slug/proposals', (c) => {
+ * const { role } = c.get('spaceRole');
+ * if (!hasCapability(role, 'create_proposal', RVOTE_PERMISSIONS)) {
+ * return c.json({ error: 'Insufficient permissions' }, 403);
+ * }
+ * // ...
+ * });
+ * ```
+ */
+export declare function encryptIDSpaceRoleAuth(config: EncryptIDSpaceRoleConfig): MiddlewareHandler;
diff --git a/frontend/vendor/@encryptid/sdk/server/middleware/hono.js b/frontend/vendor/@encryptid/sdk/server/middleware/hono.js
new file mode 100644
index 0000000..0c2e3b5
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/middleware/hono.js
@@ -0,0 +1,59 @@
+import {
+ evaluateSpaceAccess,
+ extractToken
+} from "../../index-j6kh1974.js";
+import"../../index-5c1t4ftn.js";
+import {
+ verifyEncryptIDToken
+} from "../../index-stg63j73.js";
+
+// src/server/middleware/hono.ts
+function encryptIDAuth(options = {}) {
+ return async (c, next) => {
+ const authHeader = c.req.header("Authorization");
+ if (!authHeader?.startsWith("Bearer ")) {
+ return c.json({ error: "Missing EncryptID token" }, 401);
+ }
+ const token = authHeader.slice(7);
+ try {
+ const claims = await verifyEncryptIDToken(token, options);
+ c.set("encryptid", claims);
+ await next();
+ } catch (err) {
+ return c.json({ error: err.message || "Invalid token" }, 401);
+ }
+ };
+}
+function encryptIDOptional(options = {}) {
+ return async (c, next) => {
+ const authHeader = c.req.header("Authorization");
+ if (authHeader?.startsWith("Bearer ")) {
+ try {
+ const claims = await verifyEncryptIDToken(authHeader.slice(7), options);
+ c.set("encryptid", claims);
+ } catch {}
+ }
+ await next();
+ };
+}
+function encryptIDSpaceAuth(config) {
+ const { slugParam = "slug", slugQuery = "space", ...options } = config;
+ return async (c, next) => {
+ const spaceSlug = c.req.param(slugParam) || c.req.query(slugQuery) || "";
+ const token = extractToken(c.req.raw.headers);
+ const result = await evaluateSpaceAccess(spaceSlug, token, c.req.method, options);
+ if (!result.allowed) {
+ return c.json({ error: result.reason }, result.claims ? 403 : 401);
+ }
+ if (result.claims) {
+ c.set("encryptid", result.claims);
+ }
+ c.set("spaceAuth", result);
+ await next();
+ };
+}
+export {
+ encryptIDSpaceAuth,
+ encryptIDOptional,
+ encryptIDAuth
+};
diff --git a/frontend/vendor/@encryptid/sdk/server/middleware/nextjs.d.ts b/frontend/vendor/@encryptid/sdk/server/middleware/nextjs.d.ts
new file mode 100644
index 0000000..bfb51e8
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/middleware/nextjs.d.ts
@@ -0,0 +1,136 @@
+/**
+ * EncryptID Next.js Middleware
+ *
+ * Helpers for protecting Next.js App Router routes and API endpoints.
+ */
+import { type VerifyOptions } from '../jwt-verify.js';
+import { type SpaceAuthOptions } from '../space-auth.js';
+import type { EncryptIDClaims, SpaceAuthResult, SpaceVisibility } from '../../types/index.js';
+import type { ResolvedRole, SpaceMembership } from '../../types/roles.js';
+export interface EncryptIDNextConfig extends VerifyOptions {
+ /** Paths that don't require authentication */
+ publicPaths?: string[];
+ /** Redirect URL for unauthenticated requests (null = return 401) */
+ loginUrl?: string | null;
+}
+/**
+ * Get EncryptID session from a Next.js request
+ *
+ * Usage in API routes:
+ * ```ts
+ * import { getEncryptIDSession } from '@encryptid/sdk/server/nextjs';
+ *
+ * export async function GET(req: Request) {
+ * const session = await getEncryptIDSession(req);
+ * if (!session) return new Response('Unauthorized', { status: 401 });
+ * // session.sub, session.did, session.eid.authLevel, etc.
+ * }
+ * ```
+ */
+export declare function getEncryptIDSession(request: Request, options?: VerifyOptions): Promise;
+/**
+ * Protect a Next.js API route handler
+ *
+ * Usage:
+ * ```ts
+ * import { withEncryptID } from '@encryptid/sdk/server/nextjs';
+ *
+ * export const GET = withEncryptID(async (req, session) => {
+ * return Response.json({ user: session.sub, did: session.did });
+ * });
+ * ```
+ */
+export declare function withEncryptID(handler: (request: Request, session: EncryptIDClaims) => Promise, options?: VerifyOptions): (request: Request) => Promise;
+/**
+ * Create Next.js middleware for EncryptID
+ *
+ * Usage in middleware.ts:
+ * ```ts
+ * import { createEncryptIDMiddleware } from '@encryptid/sdk/server/nextjs';
+ *
+ * const encryptIDMiddleware = createEncryptIDMiddleware({
+ * publicPaths: ['/auth/signin', '/api/auth'],
+ * loginUrl: '/auth/signin',
+ * });
+ *
+ * export function middleware(request: NextRequest) {
+ * return encryptIDMiddleware(request);
+ * }
+ * ```
+ */
+export declare function createEncryptIDMiddleware(config?: EncryptIDNextConfig): (request: Request) => Promise;
+/**
+ * Check space access in a Next.js API route or server component.
+ *
+ * Usage:
+ * ```ts
+ * const result = await checkSpaceAccess(request, spaceSlug, {
+ * getSpaceConfig: async (slug) => {
+ * const space = await prisma.space.findUnique({ where: { slug } });
+ * if (!space) return null;
+ * return { spaceSlug: slug, visibility: space.visibility, app: 'rvote' };
+ * },
+ * });
+ * if (!result.allowed) return new Response(result.reason, { status: 401 });
+ * ```
+ */
+export declare function checkSpaceAccess(request: Request, spaceSlug: string, options: SpaceAuthOptions): Promise;
+/**
+ * HOC that wraps a Next.js API route handler with space auth.
+ *
+ * Usage:
+ * ```ts
+ * export const POST = withSpaceAuth(
+ * async (req, spaceAuth, slug) => {
+ * return Response.json({ owner: spaceAuth.isOwner });
+ * },
+ * (req) => new URL(req.url).pathname.split('/')[2],
+ * { getSpaceConfig: async (slug) => { ... } },
+ * );
+ * ```
+ */
+export declare function withSpaceAuth(handler: (request: Request, spaceAuth: SpaceAuthResult, spaceSlug: string) => Promise, getSlug: (request: Request) => string, options: SpaceAuthOptions): (request: Request) => Promise;
+export interface SpaceRoleOptions extends SpaceAuthOptions {
+ /** Look up membership for a DID in a space. You provide the DB query. */
+ getMembership: (userDID: string, spaceSlug: string) => Promise;
+ /** Resolve visibility for a space slug. If not provided, uses getSpaceConfig. */
+ getVisibility?: (spaceSlug: string) => Promise;
+}
+export interface SpaceRoleResult {
+ spaceAuth: SpaceAuthResult;
+ resolvedRole: ResolvedRole;
+}
+/**
+ * Check space access AND resolve role in one call.
+ *
+ * Usage:
+ * ```ts
+ * const result = await checkSpaceRole(request, slug, {
+ * getSpaceConfig: async (slug) => prisma.space.findUnique({ where: { slug } }),
+ * getMembership: async (did, slug) => prisma.spaceMember.findUnique({
+ * where: { userDID_spaceSlug: { userDID: did, spaceSlug: slug } }
+ * }),
+ * });
+ * if (!result.spaceAuth.allowed) return deny();
+ * if (hasCapability(result.resolvedRole.role, 'create_proposal', RVOTE_PERMISSIONS)) { ... }
+ * ```
+ */
+export declare function checkSpaceRole(request: Request, spaceSlug: string, options: SpaceRoleOptions): Promise;
+/**
+ * HOC that wraps a Next.js API route handler with space auth + role resolution.
+ *
+ * Usage:
+ * ```ts
+ * export const POST = withSpaceRole(
+ * async (req, spaceAuth, role, slug) => {
+ * if (!hasCapability(role.role, 'create_proposal', RVOTE_PERMISSIONS)) {
+ * return Response.json({ error: 'Forbidden' }, { status: 403 });
+ * }
+ * return Response.json({ created: true });
+ * },
+ * (req) => new URL(req.url).pathname.split('/')[3], // extract slug
+ * { getSpaceConfig: ..., getMembership: ... },
+ * );
+ * ```
+ */
+export declare function withSpaceRole(handler: (request: Request, spaceAuth: SpaceAuthResult, resolvedRole: ResolvedRole, spaceSlug: string) => Promise, getSlug: (request: Request) => string, options: SpaceRoleOptions): (request: Request) => Promise;
diff --git a/frontend/vendor/@encryptid/sdk/server/middleware/nextjs.js b/frontend/vendor/@encryptid/sdk/server/middleware/nextjs.js
new file mode 100644
index 0000000..72b2b9c
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/middleware/nextjs.js
@@ -0,0 +1,83 @@
+import {
+ evaluateSpaceAccess,
+ extractToken
+} from "../../index-j6kh1974.js";
+import"../../index-5c1t4ftn.js";
+import {
+ verifyEncryptIDToken
+} from "../../index-stg63j73.js";
+
+// src/server/middleware/nextjs.ts
+async function getEncryptIDSession(request, options = {}) {
+ const authHeader = request.headers.get("Authorization");
+ if (authHeader?.startsWith("Bearer ")) {
+ try {
+ return await verifyEncryptIDToken(authHeader.slice(7), options);
+ } catch {
+ return null;
+ }
+ }
+ const cookieHeader = request.headers.get("Cookie") || "";
+ const tokenMatch = cookieHeader.match(/encryptid_token=([^;]+)/);
+ if (tokenMatch) {
+ try {
+ return await verifyEncryptIDToken(tokenMatch[1], options);
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+function withEncryptID(handler, options = {}) {
+ return async (request) => {
+ const session = await getEncryptIDSession(request, options);
+ if (!session) {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401,
+ headers: { "Content-Type": "application/json" }
+ });
+ }
+ return handler(request, session);
+ };
+}
+function createEncryptIDMiddleware(config = {}) {
+ const { publicPaths = [], loginUrl = null, ...verifyOptions } = config;
+ return async (request) => {
+ const url = new URL(request.url);
+ if (publicPaths.some((p) => url.pathname.startsWith(p))) {
+ return null;
+ }
+ const session = await getEncryptIDSession(request, verifyOptions);
+ if (!session) {
+ if (loginUrl) {
+ return Response.redirect(new URL(loginUrl, request.url));
+ }
+ return new Response("Unauthorized", { status: 401 });
+ }
+ return null;
+ };
+}
+async function checkSpaceAccess(request, spaceSlug, options) {
+ const token = extractToken(request.headers);
+ return evaluateSpaceAccess(spaceSlug, token, request.method, options);
+}
+function withSpaceAuth(handler, getSlug, options) {
+ return async (request) => {
+ const slug = getSlug(request);
+ const result = await checkSpaceAccess(request, slug, options);
+ if (!result.allowed) {
+ return new Response(JSON.stringify({ error: result.reason }), {
+ status: result.claims ? 403 : 401,
+ headers: { "Content-Type": "application/json" }
+ });
+ }
+ return handler(request, result, slug);
+ };
+}
+export {
+ withSpaceAuth,
+ withEncryptID,
+ getEncryptIDSession,
+ createEncryptIDMiddleware,
+ checkSpaceAccess
+};
diff --git a/frontend/vendor/@encryptid/sdk/server/role-resolver.d.ts b/frontend/vendor/@encryptid/sdk/server/role-resolver.d.ts
new file mode 100644
index 0000000..dce650e
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/role-resolver.d.ts
@@ -0,0 +1,94 @@
+/**
+ * EncryptID Space Role Resolver
+ *
+ * Resolves a user's effective SpaceRole given their space access result
+ * and a membership lookup function. This is the bridge between
+ * evaluateSpaceAccess() (layer 1: "can they enter?") and
+ * hasCapability() (layer 3: "what can they do?").
+ */
+import type { ResolvedRole, SpaceMembership } from '../types/roles.js';
+import type { SpaceAuthResult } from '../types/index.js';
+import { SpaceVisibility } from '../types/index.js';
+export interface RoleResolverOptions {
+ /**
+ * Look up membership for a DID in a space.
+ * You provide the DB query — works with Prisma, Automerge, raw SQL, etc.
+ * Return null if no membership found.
+ */
+ getMembership: (userDID: string, spaceSlug: string) => Promise;
+ /** The space's visibility setting */
+ visibility: SpaceVisibility;
+}
+export interface RemoteResolverOptions {
+ /** EncryptID server URL (e.g., https://encryptid.jeffemmett.com) */
+ serverUrl: string;
+ /** The space's visibility setting */
+ visibility: SpaceVisibility;
+ /** Cache TTL in milliseconds (default: 5 minutes) */
+ cacheTtlMs?: number;
+}
+/**
+ * Invalidate the role cache for a specific user/space or the entire cache.
+ */
+export declare function invalidateRoleCache(userDID?: string, spaceSlug?: string): void;
+/**
+ * Create a getMembership function that calls the EncryptID server.
+ * This is a convenience wrapper for modules that don't have local membership data.
+ *
+ * @example
+ * ```ts
+ * const role = await resolveSpaceRole(spaceAuth, slug, {
+ * visibility: 'public',
+ * getMembership: createRemoteMembershipLookup('https://encryptid.jeffemmett.com'),
+ * });
+ * ```
+ */
+export declare function createRemoteMembershipLookup(serverUrl: string): (userDID: string, spaceSlug: string) => Promise;
+/**
+ * Resolve a user's effective SpaceRole in a space.
+ *
+ * Decision flow:
+ * 1. Owner → ADMIN (always)
+ * 2. Has explicit membership → membership.role
+ * 3. No membership, apply defaults based on visibility:
+ * - PUBLIC: anonymous & authenticated → PARTICIPANT
+ * - PUBLIC_READ: anonymous → VIEWER, authenticated → PARTICIPANT
+ * - AUTHENTICATED: → VIEWER (must have membership for more)
+ * - MEMBERS_ONLY: should not reach here (denied at space access layer)
+ *
+ * @param spaceAuth - Result from evaluateSpaceAccess()
+ * @param spaceSlug - The space identifier
+ * @param options - Membership lookup and visibility config
+ *
+ * @example
+ * ```ts
+ * const spaceAuth = await evaluateSpaceAccess(slug, token, method, opts);
+ * if (!spaceAuth.allowed) return deny();
+ *
+ * const { role, source } = await resolveSpaceRole(spaceAuth, slug, {
+ * visibility: space.visibility,
+ * getMembership: (did, slug) => db.membership.findUnique({ where: { did_slug: { did, slug } } }),
+ * });
+ *
+ * if (hasCapability(role, 'create_proposal', RVOTE_PERMISSIONS)) { ... }
+ * ```
+ */
+export declare function resolveSpaceRole(spaceAuth: SpaceAuthResult, spaceSlug: string, options: RoleResolverOptions): Promise;
+/**
+ * Resolve a user's SpaceRole by querying the EncryptID server.
+ *
+ * This is the recommended function for modules that don't maintain
+ * their own membership table. It:
+ * 1. Checks the in-memory cache (5-min TTL)
+ * 2. If miss, queries EncryptID server for membership
+ * 3. Falls back to visibility-based defaults on network error
+ *
+ * @example
+ * ```ts
+ * const { role, source } = await resolveSpaceRoleRemote(spaceAuth, slug, {
+ * serverUrl: 'https://encryptid.jeffemmett.com',
+ * visibility: space.visibility,
+ * });
+ * ```
+ */
+export declare function resolveSpaceRoleRemote(spaceAuth: SpaceAuthResult, spaceSlug: string, options: RemoteResolverOptions): Promise;
diff --git a/frontend/vendor/@encryptid/sdk/server/space-auth.d.ts b/frontend/vendor/@encryptid/sdk/server/space-auth.d.ts
new file mode 100644
index 0000000..35fc039
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/space-auth.d.ts
@@ -0,0 +1,35 @@
+/**
+ * EncryptID Space Auth Guard
+ *
+ * Framework-agnostic space-aware authentication.
+ * Evaluates whether a request should be allowed based on:
+ * 1. Space visibility configuration
+ * 2. Request method (GET/HEAD/OPTIONS = read, others = write)
+ * 3. EncryptID session (if present)
+ */
+import { type VerifyOptions } from './jwt-verify.js';
+import { SpaceVisibility } from '../types/index.js';
+import type { SpaceAuthConfig, SpaceAuthResult } from '../types/index.js';
+export { SpaceVisibility };
+export type { SpaceAuthConfig, SpaceAuthResult };
+export interface SpaceAuthOptions extends VerifyOptions {
+ /** Resolve a space slug to its auth config. You provide the DB/store query. */
+ getSpaceConfig: (spaceSlug: string) => Promise;
+}
+/**
+ * Core space auth evaluation — framework-agnostic.
+ *
+ * Apps call this with the space slug, the extracted token (or null),
+ * the HTTP method, and a callback to look up the space's config.
+ */
+export declare function evaluateSpaceAccess(spaceSlug: string, token: string | null, method: string, options: SpaceAuthOptions): Promise;
+/**
+ * Extract EncryptID token from request headers or cookies.
+ * Works with both the standard Headers API (fetch/Hono/Next.js) and
+ * Express-style header objects.
+ */
+export declare function extractToken(headers: {
+ get?: (name: string) => string | null | undefined;
+ authorization?: string;
+ cookie?: string;
+}): string | null;
diff --git a/frontend/vendor/@encryptid/sdk/server/space-auth.js b/frontend/vendor/@encryptid/sdk/server/space-auth.js
new file mode 100644
index 0000000..876de6d
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/space-auth.js
@@ -0,0 +1,13 @@
+import {
+ evaluateSpaceAccess,
+ extractToken
+} from "../index-j6kh1974.js";
+import {
+ SpaceVisibility
+} from "../index-5c1t4ftn.js";
+import"../index-stg63j73.js";
+export {
+ extractToken,
+ evaluateSpaceAccess,
+ SpaceVisibility
+};
diff --git a/frontend/vendor/@encryptid/sdk/server/ws-auth.d.ts b/frontend/vendor/@encryptid/sdk/server/ws-auth.d.ts
new file mode 100644
index 0000000..8d4f775
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/ws-auth.d.ts
@@ -0,0 +1,18 @@
+/**
+ * EncryptID WebSocket Authentication
+ *
+ * Since WebSocket upgrade requests carry the initial HTTP headers,
+ * we verify the token during the upgrade handshake.
+ *
+ * Supported token locations (checked in order):
+ * 1. `token` query parameter: ws://host/ws?token=xxx
+ * 2. Sec-WebSocket-Protocol subprotocol: "encryptid.TOKEN_HERE"
+ * 3. Cookie: encryptid_token=xxx
+ */
+import { type VerifyOptions } from './jwt-verify.js';
+import type { EncryptIDClaims } from '../types/index.js';
+/**
+ * Authenticate a WebSocket upgrade request.
+ * Returns claims if a valid token is found, null otherwise.
+ */
+export declare function authenticateWSUpgrade(request: Request, options?: VerifyOptions): Promise;
diff --git a/frontend/vendor/@encryptid/sdk/server/ws-auth.js b/frontend/vendor/@encryptid/sdk/server/ws-auth.js
new file mode 100644
index 0000000..74a2840
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/server/ws-auth.js
@@ -0,0 +1,7 @@
+import {
+ authenticateWSUpgrade
+} from "../index-2yszamrn.js";
+import"../index-stg63j73.js";
+export {
+ authenticateWSUpgrade
+};
diff --git a/frontend/vendor/@encryptid/sdk/types/index.d.ts b/frontend/vendor/@encryptid/sdk/types/index.d.ts
new file mode 100644
index 0000000..2bae4de
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/index.d.ts
@@ -0,0 +1,246 @@
+/**
+ * EncryptID SDK — Shared Types
+ */
+export interface EncryptIDCredential {
+ credentialId: string;
+ publicKey: ArrayBuffer;
+ userId: string;
+ username: string;
+ createdAt: number;
+ prfSupported: boolean;
+ transports?: AuthenticatorTransport[];
+}
+export interface AuthenticationResult {
+ credentialId: string;
+ userId: string;
+ prfOutput?: ArrayBuffer;
+ signature: ArrayBuffer;
+ authenticatorData: ArrayBuffer;
+}
+export interface EncryptIDConfig {
+ rpId: string;
+ rpName: string;
+ origin: string;
+ userVerification: UserVerificationRequirement;
+ timeout: number;
+}
+export interface WebAuthnCapabilities {
+ webauthn: boolean;
+ platformAuthenticator: boolean;
+ conditionalUI: boolean;
+ prfExtension: boolean;
+}
+export interface DerivedKeys {
+ encryptionKey: CryptoKey;
+ signingKeyPair: CryptoKeyPair;
+ didSeed: Uint8Array;
+ did: string;
+ fromPRF: boolean;
+ /** Ethereum-compatible secp256k1 wallet derived from the same master key */
+ ethereum?: {
+ address: string;
+ publicKey: Uint8Array;
+ privateKey: Uint8Array;
+ };
+}
+export interface EncryptedData {
+ ciphertext: ArrayBuffer;
+ iv: Uint8Array;
+ tag?: ArrayBuffer;
+}
+export interface SignedData {
+ data: ArrayBuffer;
+ signature: ArrayBuffer;
+ publicKey: ArrayBuffer;
+}
+export declare enum AuthLevel {
+ BASIC = 1,
+ STANDARD = 2,
+ ELEVATED = 3,
+ CRITICAL = 4
+}
+export interface EncryptIDClaims {
+ iss: string;
+ sub: string;
+ aud: string[];
+ iat: number;
+ exp: number;
+ jti: string;
+ username: string;
+ did?: string;
+ eid: {
+ walletAddress?: string;
+ credentialId?: string;
+ authLevel: AuthLevel;
+ authTime: number;
+ capabilities: {
+ encrypt: boolean;
+ sign: boolean;
+ wallet: boolean;
+ };
+ recoveryConfigured: boolean;
+ };
+}
+export interface SessionState {
+ accessToken: string;
+ refreshToken: string;
+ claims: EncryptIDClaims;
+ lastAuthTime: number;
+}
+export interface OperationPermission {
+ minAuthLevel: AuthLevel;
+ requiresCapability?: 'encrypt' | 'sign' | 'wallet';
+ maxAgeSeconds?: number;
+}
+export declare enum GuardianType {
+ SECONDARY_PASSKEY = "secondary_passkey",
+ TRUSTED_CONTACT = "trusted_contact",
+ HARDWARE_KEY = "hardware_key",
+ INSTITUTIONAL = "institutional",
+ TIME_DELAYED_SELF = "time_delayed_self"
+}
+export interface Guardian {
+ id: string;
+ type: GuardianType;
+ name: string;
+ weight: number;
+ credentialId?: string;
+ contactDID?: string;
+ contactEmail?: string;
+ serviceUrl?: string;
+ delaySeconds?: number;
+ addedAt: number;
+ lastVerified?: number;
+}
+export interface RecoveryConfig {
+ threshold: number;
+ delaySeconds: number;
+ guardians: Guardian[];
+ guardianListHash: string;
+ updatedAt: number;
+}
+export interface RecoveryRequest {
+ id: string;
+ accountDID: string;
+ newCredentialId: string;
+ initiatedAt: number;
+ completesAt: number;
+ status: 'pending' | 'approved' | 'cancelled' | 'completed';
+ approvals: {
+ guardianId: string;
+ approvedAt: number;
+ signature: string;
+ }[];
+ approvalWeight: number;
+}
+export interface RegistrationStartResponse {
+ options: {
+ challenge: string;
+ rp: {
+ id: string;
+ name: string;
+ };
+ user: {
+ id: string;
+ name: string;
+ displayName: string;
+ };
+ pubKeyCredParams: {
+ alg: number;
+ type: string;
+ }[];
+ authenticatorSelection: Record;
+ timeout: number;
+ attestation: string;
+ extensions?: Record;
+ };
+ userId: string;
+}
+export interface RegistrationCompleteResponse {
+ success: boolean;
+ userId: string;
+ token: string;
+ did: string;
+}
+export interface AuthStartResponse {
+ options: {
+ challenge: string;
+ rpId: string;
+ userVerification: string;
+ timeout: number;
+ allowCredentials?: {
+ type: string;
+ id: string;
+ transports?: string[];
+ }[];
+ };
+}
+export interface AuthCompleteResponse {
+ success: boolean;
+ userId: string;
+ username: string;
+ token: string;
+ did: string;
+}
+export interface SessionVerifyResponse {
+ valid: boolean;
+ userId?: string;
+ username?: string;
+ did?: string;
+ exp?: number;
+ error?: string;
+}
+export interface EmailRecoverySetResponse {
+ success: boolean;
+ email: string;
+}
+export interface EmailRecoveryRequestResponse {
+ success: boolean;
+ message: string;
+}
+export interface EmailRecoveryVerifyResponse {
+ success: boolean;
+ token: string;
+ userId: string;
+ username: string;
+ did: string;
+ message: string;
+}
+export declare enum SpaceVisibility {
+ /** Anyone can view and interact, no auth required */
+ PUBLIC = "public",
+ /** Anyone can view, auth required for write/interact */
+ PUBLIC_READ = "public_read",
+ /** Auth required for any access */
+ AUTHENTICATED = "authenticated",
+ /** Only space members can access (app must check membership separately) */
+ MEMBERS_ONLY = "members_only"
+}
+export type AppName = 'rspace' | 'rvote' | 'rfiles' | 'rmaps' | 'rwallet' | 'rfunds' | 'rnotes' | 'rtrips' | 'rnetwork' | 'rcart' | 'rmail' | 'rcal' | 'rtube' | 'rstack' | 'canvas';
+export { SpaceRole, SPACE_ROLE_LEVEL, roleAtLeast } from './roles.js';
+export type { SpaceMembership, ResolvedRole } from './roles.js';
+export { hasCapability, getCapabilities } from './module-permissions.js';
+export type { ModulePermissionMap } from './module-permissions.js';
+export type { SpaceMembershipEvent, SpaceMembershipEventType, AddMemberRequest, UpdateMemberRequest, MemberResponse, MemberListResponse, } from './membership-events.js';
+export interface SpaceAuthConfig {
+ /** Space identifier (slug) */
+ spaceSlug: string;
+ /** Who can see/interact with this space */
+ visibility: SpaceVisibility;
+ /** DID of the space creator/owner */
+ ownerDID?: string;
+ /** App this space belongs to */
+ app: AppName;
+}
+export interface SpaceAuthResult {
+ /** Whether access is allowed */
+ allowed: boolean;
+ /** The authenticated user's claims (null if unauthenticated public access) */
+ claims: EncryptIDClaims | null;
+ /** Why access was denied */
+ reason?: string;
+ /** Whether the user is the space owner */
+ isOwner: boolean;
+ /** Whether this is read-only access (public_read with no auth) */
+ readOnly: boolean;
+}
diff --git a/frontend/vendor/@encryptid/sdk/types/index.js b/frontend/vendor/@encryptid/sdk/types/index.js
new file mode 100644
index 0000000..0845545
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/index.js
@@ -0,0 +1,10 @@
+import {
+ AuthLevel,
+ GuardianType,
+ SpaceVisibility
+} from "../index-5c1t4ftn.js";
+export {
+ SpaceVisibility,
+ GuardianType,
+ AuthLevel
+};
diff --git a/frontend/vendor/@encryptid/sdk/types/membership-events.d.ts b/frontend/vendor/@encryptid/sdk/types/membership-events.d.ts
new file mode 100644
index 0000000..01fa624
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/membership-events.d.ts
@@ -0,0 +1,49 @@
+/**
+ * EncryptID SDK — Space Membership Event Types
+ *
+ * Events emitted when membership changes in a space.
+ * Used by the membership sync system to keep all r*.online
+ * modules consistent when a user's role changes.
+ */
+import type { SpaceRole } from './roles.js';
+export type SpaceMembershipEventType = 'member.joined' | 'member.left' | 'member.role_changed';
+/**
+ * Emitted by EncryptID server when space membership changes.
+ * Modules can subscribe to these events to invalidate role caches.
+ */
+export interface SpaceMembershipEvent {
+ type: SpaceMembershipEventType;
+ /** Space identifier */
+ spaceSlug: string;
+ /** DID of the user whose membership changed */
+ userDID: string;
+ /** New role (undefined for member.left) */
+ role?: SpaceRole;
+ /** Previous role (undefined for member.joined) */
+ previousRole?: SpaceRole;
+ /** DID of user who initiated the change */
+ changedBy?: string;
+ /** Unix timestamp (ms) */
+ timestamp: number;
+}
+export interface AddMemberRequest {
+ /** DID of user to add */
+ userDID: string;
+ /** Role to grant */
+ role: SpaceRole;
+}
+export interface UpdateMemberRequest {
+ /** New role */
+ role: SpaceRole;
+}
+export interface MemberResponse {
+ userDID: string;
+ spaceSlug: string;
+ role: SpaceRole;
+ joinedAt: number;
+ grantedBy?: string;
+}
+export interface MemberListResponse {
+ members: MemberResponse[];
+ total: number;
+}
diff --git a/frontend/vendor/@encryptid/sdk/types/module-permissions.d.ts b/frontend/vendor/@encryptid/sdk/types/module-permissions.d.ts
new file mode 100644
index 0000000..a715ed7
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/module-permissions.d.ts
@@ -0,0 +1,53 @@
+/**
+ * EncryptID SDK — Module Permission Maps
+ *
+ * Each r*.online module declares a static mapping from capabilities
+ * to the minimum SpaceRole required. This is the central abstraction
+ * for permission inheritance across the ecosystem.
+ */
+import { SpaceRole } from './roles.js';
+import type { AppName } from './index.js';
+/**
+ * A module's permission declaration.
+ * Maps each capability string to the minimum SpaceRole required.
+ *
+ * @template TCapability - Union of capability string literals for this module
+ */
+export interface ModulePermissionMap {
+ /** Module identifier (matches AppName) */
+ module: AppName;
+ /** Human-readable module name */
+ displayName: string;
+ /**
+ * For each capability, the minimum SpaceRole required.
+ * If a capability is not listed, it requires ADMIN by default.
+ */
+ capabilities: Record;
+}
+/**
+ * Check if a user's SpaceRole satisfies a module capability requirement.
+ *
+ * @param userRole - The user's resolved SpaceRole in the space
+ * @param capability - The capability to check
+ * @param permMap - The module's permission map
+ * @returns true if the user's role meets or exceeds the minimum for this capability
+ *
+ * @example
+ * ```ts
+ * import { hasCapability } from '@encryptid/sdk';
+ * import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
+ *
+ * if (hasCapability(userRole, 'create_proposal', RVOTE_PERMISSIONS)) {
+ * // user can create proposals
+ * }
+ * ```
+ */
+export declare function hasCapability(userRole: SpaceRole, capability: T, permMap: ModulePermissionMap): boolean;
+/**
+ * Get all capabilities a role has access to in a module.
+ *
+ * @param userRole - The user's resolved SpaceRole
+ * @param permMap - The module's permission map
+ * @returns Array of capability strings the user has access to
+ */
+export declare function getCapabilities(userRole: SpaceRole, permMap: ModulePermissionMap): T[];
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/index.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/index.d.ts
new file mode 100644
index 0000000..8d16834
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/index.d.ts
@@ -0,0 +1,15 @@
+/**
+ * Module Permission Maps — Barrel Export
+ *
+ * Import specific module permissions:
+ * import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
+ *
+ * Or import all:
+ * import * as modules from '@encryptid/sdk/types/modules';
+ */
+export { RSPACE_PERMISSIONS, type RSpaceCapability } from './rspace.js';
+export { RVOTE_PERMISSIONS, type RVoteCapability } from './rvote.js';
+export { RNOTES_PERMISSIONS, type RNotesCapability } from './rnotes.js';
+export { RFUNDS_PERMISSIONS, type RFundsCapability } from './rfunds.js';
+export { RMAPS_PERMISSIONS, type RMapsCapability } from './rmaps.js';
+export { RTUBE_PERMISSIONS, type RTubeCapability } from './rtube.js';
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/rfunds.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/rfunds.d.ts
new file mode 100644
index 0000000..f572548
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/rfunds.d.ts
@@ -0,0 +1,8 @@
+/**
+ * rFunds — Funding Flows & Treasury
+ *
+ * Permission capabilities for the rFunds funding/treasury module.
+ */
+import type { ModulePermissionMap } from '../module-permissions.js';
+export type RFundsCapability = 'view_flows' | 'create_flow' | 'contribute_funds' | 'moderate_flows' | 'configure_treasury';
+export declare const RFUNDS_PERMISSIONS: ModulePermissionMap;
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/rmaps.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/rmaps.d.ts
new file mode 100644
index 0000000..02ce498
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/rmaps.d.ts
@@ -0,0 +1,8 @@
+/**
+ * rMaps — Spatial Intelligence
+ *
+ * Permission capabilities for the rMaps collaborative mapping module.
+ */
+import type { ModulePermissionMap } from '../module-permissions.js';
+export type RMapsCapability = 'view_map' | 'add_markers' | 'share_location' | 'moderate_markers' | 'configure_map';
+export declare const RMAPS_PERMISSIONS: ModulePermissionMap;
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/rnotes.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/rnotes.d.ts
new file mode 100644
index 0000000..e1d7872
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/rnotes.d.ts
@@ -0,0 +1,10 @@
+/**
+ * rNotes — Collaborative Notebooks
+ *
+ * Permission capabilities for the rNotes note-taking module.
+ * Note: rNotes also has per-notebook CollaboratorRole overrides.
+ * Space-level role sets the default; notebook-level can narrow or widen.
+ */
+import type { ModulePermissionMap } from '../module-permissions.js';
+export type RNotesCapability = 'view_notebooks' | 'create_notebook' | 'edit_own_notes' | 'edit_any_notes' | 'manage_notebooks';
+export declare const RNOTES_PERMISSIONS: ModulePermissionMap;
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/rspace.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/rspace.d.ts
new file mode 100644
index 0000000..4d2f9a1
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/rspace.d.ts
@@ -0,0 +1,8 @@
+/**
+ * rSpace — Canvas/Collaboration Platform
+ *
+ * Permission capabilities for the rSpace collaborative canvas.
+ */
+import type { ModulePermissionMap } from '../module-permissions.js';
+export type RSpaceCapability = 'view_canvas' | 'add_shapes' | 'edit_own_shapes' | 'edit_any_shape' | 'delete_any_shape' | 'configure_space';
+export declare const RSPACE_PERMISSIONS: ModulePermissionMap;
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/rtube.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/rtube.d.ts
new file mode 100644
index 0000000..298ef19
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/rtube.d.ts
@@ -0,0 +1,8 @@
+/**
+ * rTube — Video Hosting & Streaming
+ *
+ * Permission capabilities for the rTube video module.
+ */
+import type { ModulePermissionMap } from '../module-permissions.js';
+export type RTubeCapability = 'view_videos' | 'upload_video' | 'start_stream' | 'moderate_videos' | 'configure_channel';
+export declare const RTUBE_PERMISSIONS: ModulePermissionMap;
diff --git a/frontend/vendor/@encryptid/sdk/types/modules/rvote.d.ts b/frontend/vendor/@encryptid/sdk/types/modules/rvote.d.ts
new file mode 100644
index 0000000..834f907
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/modules/rvote.d.ts
@@ -0,0 +1,8 @@
+/**
+ * rVote — Decision Engine
+ *
+ * Permission capabilities for the rVote voting/governance module.
+ */
+import type { ModulePermissionMap } from '../module-permissions.js';
+export type RVoteCapability = 'view_proposals' | 'create_proposal' | 'cast_vote' | 'moderate_proposals' | 'configure_voting';
+export declare const RVOTE_PERMISSIONS: ModulePermissionMap;
diff --git a/frontend/vendor/@encryptid/sdk/types/roles.d.ts b/frontend/vendor/@encryptid/sdk/types/roles.d.ts
new file mode 100644
index 0000000..658fc14
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/types/roles.d.ts
@@ -0,0 +1,57 @@
+/**
+ * EncryptID SDK — Space Role System
+ *
+ * Defines the unified role hierarchy for the r*.online ecosystem.
+ * Spaces have visibility (who can enter) and roles (what you can do).
+ */
+/**
+ * Ecosystem-wide role within a space.
+ * These are the ONLY roles stored at the space level.
+ * Modules interpret these into module-specific capabilities.
+ */
+export declare enum SpaceRole {
+ /** Can view public content in the space */
+ VIEWER = "viewer",
+ /** Can participate: create, edit own content */
+ PARTICIPANT = "participant",
+ /** Can moderate: edit/delete others' content, manage participants */
+ MODERATOR = "moderator",
+ /** Full control: configure space, manage roles, delete space */
+ ADMIN = "admin"
+}
+/**
+ * Ordered precedence (higher number = more powerful).
+ * Used by hasCapability() and role comparison helpers.
+ */
+export declare const SPACE_ROLE_LEVEL: Record;
+/**
+ * Check if a role meets or exceeds a required minimum role.
+ */
+export declare function roleAtLeast(userRole: SpaceRole, requiredRole: SpaceRole): boolean;
+/**
+ * Space membership record.
+ * Each app stores memberships in its own DB (Prisma, Automerge, etc.)
+ * but the shape is consistent across the ecosystem.
+ */
+export interface SpaceMembership {
+ /** User's DID (from EncryptID claims.sub) */
+ userDID: string;
+ /** Space identifier (slug) */
+ spaceSlug: string;
+ /** Role in this space */
+ role: SpaceRole;
+ /** When the membership was granted (epoch ms) */
+ joinedAt: number;
+ /** DID of user who granted this membership (null = self-join or owner) */
+ grantedBy?: string;
+}
+/**
+ * Result of resolving a user's effective role in a space.
+ * Includes the source for debugging and audit.
+ */
+export interface ResolvedRole {
+ /** The effective role */
+ role: SpaceRole;
+ /** How the role was determined */
+ source: 'membership' | 'owner' | 'default' | 'anonymous';
+}
diff --git a/frontend/vendor/@encryptid/sdk/ui/guardian-setup.d.ts b/frontend/vendor/@encryptid/sdk/ui/guardian-setup.d.ts
new file mode 100644
index 0000000..fe7b00d
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/guardian-setup.d.ts
@@ -0,0 +1,12 @@
+/**
+ * EncryptID Guardian Setup Web Component
+ *
+ * Custom element:
+ * Events: guardian-added, guardian-removed, guardian-verified
+ */
+export declare class GuardianSetupElement extends HTMLElement {
+ private shadow;
+ constructor();
+ connectedCallback(): void;
+ private render;
+}
diff --git a/frontend/vendor/@encryptid/sdk/ui/index.d.ts b/frontend/vendor/@encryptid/sdk/ui/index.d.ts
new file mode 100644
index 0000000..3cd84b1
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/index.d.ts
@@ -0,0 +1,5 @@
+/**
+ * @encryptid/sdk/ui — Web Components
+ */
+export { EncryptIDLoginButton } from './login-button.js';
+export { GuardianSetupElement } from './guardian-setup.js';
diff --git a/frontend/vendor/@encryptid/sdk/ui/index.js b/frontend/vendor/@encryptid/sdk/ui/index.js
new file mode 100644
index 0000000..860b81e
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/index.js
@@ -0,0 +1,263 @@
+import {
+ getGuardianTypeInfo,
+ getKeyManager,
+ getRecoveryManager,
+ getSessionManager
+} from "../index-24r9wkfe.js";
+import {
+ authenticatePasskey,
+ detectCapabilities,
+ registerPasskey,
+ startConditionalUI
+} from "../index-2cp5044h.js";
+import {
+ AuthLevel
+} from "../index-5c1t4ftn.js";
+
+// src/ui/login-button.ts
+var PASSKEY_ICON = ` `;
+var styles = `
+:host { --eid-primary: #06b6d4; --eid-primary-hover: #0891b2; --eid-bg: #0f172a; --eid-bg-hover: #1e293b; --eid-text: #f1f5f9; --eid-text-secondary: #94a3b8; --eid-radius: 8px; display: inline-block; font-family: system-ui, -apple-system, sans-serif; }
+.login-btn { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--eid-primary); color: white; border: none; border-radius: var(--eid-radius); font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); }
+.login-btn:hover { background: var(--eid-primary-hover); transform: translateY(-1px); }
+.login-btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
+.login-btn.outline { background: transparent; border: 2px solid var(--eid-primary); color: var(--eid-primary); }
+.login-btn.outline:hover { background: var(--eid-primary); color: white; }
+.login-btn.small { padding: 8px 16px; font-size: 0.875rem; }
+.login-btn.large { padding: 16px 32px; font-size: 1.125rem; }
+.passkey-icon { width: 24px; height: 24px; }
+.user-info { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--eid-bg); border-radius: var(--eid-radius); color: var(--eid-text); cursor: pointer; }
+.user-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--eid-primary); display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 0.875rem; }
+.user-did { font-size: 0.75rem; color: var(--eid-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
+.auth-level { font-size: 0.625rem; padding: 2px 6px; border-radius: 4px; }
+.auth-level.elevated { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
+.auth-level.standard { background: rgba(234, 179, 8, 0.2); color: #eab308; }
+.dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; background: var(--eid-bg); border-radius: var(--eid-radius); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); min-width: 200px; z-index: 100; overflow: hidden; }
+.dropdown-item { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--eid-text); cursor: pointer; transition: background 0.2s; }
+.dropdown-item:hover { background: var(--eid-bg-hover); }
+.dropdown-item.danger { color: #ef4444; }
+.dropdown-divider { height: 1px; background: #334155; margin: 4px 0; }
+.loading-spinner { width: 20px; height: 20px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+`;
+
+class EncryptIDLoginButton extends HTMLElement {
+ shadow;
+ loading = false;
+ showDropdown = false;
+ capabilities = null;
+ static get observedAttributes() {
+ return ["size", "variant", "label", "show-user"];
+ }
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: "open" });
+ }
+ async connectedCallback() {
+ this.capabilities = await detectCapabilities();
+ if (this.capabilities.conditionalUI)
+ this.startConditionalAuth();
+ this.render();
+ document.addEventListener("click", (e) => {
+ if (!this.contains(e.target)) {
+ this.showDropdown = false;
+ this.render();
+ }
+ });
+ }
+ attributeChangedCallback() {
+ this.render();
+ }
+ get size() {
+ return this.getAttribute("size") || "medium";
+ }
+ get variant() {
+ return this.getAttribute("variant") || "primary";
+ }
+ get label() {
+ return this.getAttribute("label") || "Sign in with Passkey";
+ }
+ get showUser() {
+ return this.hasAttribute("show-user");
+ }
+ render() {
+ const session = getSessionManager();
+ const isLoggedIn = session.isValid();
+ const did = session.getDID();
+ const authLevel = session.getAuthLevel();
+ this.shadow.innerHTML = `
+ ${isLoggedIn && this.showUser ? this.renderUserInfo(did, authLevel) : this.renderLoginButton()}
+ ${this.showDropdown ? this.renderDropdown() : ""}
+
`;
+ this.attachEventListeners();
+ }
+ renderLoginButton() {
+ const sizeClass = this.size === "medium" ? "" : this.size;
+ const variantClass = this.variant === "primary" ? "" : this.variant;
+ return `
+ ${this.loading ? '
' : PASSKEY_ICON}
+ ${this.loading ? "Authenticating..." : this.label} `;
+ }
+ renderUserInfo(did, authLevel) {
+ const shortDID = did.slice(0, 20) + "..." + did.slice(-8);
+ const initial = did.slice(8, 10).toUpperCase();
+ const levelName = AuthLevel[authLevel].toLowerCase();
+ return ``;
+ }
+ renderDropdown() {
+ return `
+
Profile
+
Recovery Settings
+
Upgrade Auth Level
+
+
Sign Out
`;
+ }
+ attachEventListeners() {
+ const session = getSessionManager();
+ if (session.isValid() && this.showUser) {
+ this.shadow.querySelector(".user-info")?.addEventListener("click", () => {
+ this.showDropdown = !this.showDropdown;
+ this.render();
+ });
+ this.shadow.querySelectorAll(".dropdown-item").forEach((item) => {
+ item.addEventListener("click", (e) => {
+ e.stopPropagation();
+ this.handleDropdownAction(item.dataset.action);
+ });
+ });
+ } else {
+ this.shadow.querySelector(".login-btn")?.addEventListener("click", () => this.handleLogin());
+ }
+ }
+ async handleLogin() {
+ if (this.loading)
+ return;
+ this.loading = true;
+ this.render();
+ try {
+ const result = await authenticatePasskey();
+ const keyManager = getKeyManager();
+ if (result.prfOutput)
+ await keyManager.initFromPRF(result.prfOutput);
+ const keys = await keyManager.getKeys();
+ await getSessionManager().createSession(result, keys.did, { encrypt: true, sign: true, wallet: false });
+ this.dispatchEvent(new CustomEvent("login-success", { detail: { did: keys.did, credentialId: result.credentialId, prfAvailable: !!result.prfOutput }, bubbles: true }));
+ } catch (error) {
+ if (error.name === "NotAllowedError" || error.message?.includes("No credential")) {
+ this.dispatchEvent(new CustomEvent("login-register-needed", { bubbles: true }));
+ } else {
+ this.dispatchEvent(new CustomEvent("login-error", { detail: { error: error.message }, bubbles: true }));
+ }
+ } finally {
+ this.loading = false;
+ this.render();
+ }
+ }
+ async handleDropdownAction(action) {
+ this.showDropdown = false;
+ if (action === "logout") {
+ getSessionManager().clearSession();
+ getKeyManager().clear();
+ this.dispatchEvent(new CustomEvent("logout", { bubbles: true }));
+ } else if (action === "upgrade") {
+ try {
+ await authenticatePasskey();
+ getSessionManager().upgradeAuthLevel(3 /* ELEVATED */);
+ this.dispatchEvent(new CustomEvent("auth-upgraded", { detail: { level: 3 /* ELEVATED */ }, bubbles: true }));
+ } catch {}
+ } else {
+ this.dispatchEvent(new CustomEvent("navigate", { detail: { path: `/${action}` }, bubbles: true }));
+ }
+ this.render();
+ }
+ async startConditionalAuth() {
+ try {
+ const result = await startConditionalUI();
+ if (result) {
+ const keyManager = getKeyManager();
+ if (result.prfOutput)
+ await keyManager.initFromPRF(result.prfOutput);
+ const keys = await keyManager.getKeys();
+ await getSessionManager().createSession(result, keys.did, { encrypt: true, sign: true, wallet: false });
+ this.dispatchEvent(new CustomEvent("login-success", { detail: { did: keys.did, credentialId: result.credentialId, viaConditionalUI: true }, bubbles: true }));
+ this.render();
+ }
+ } catch {}
+ }
+ async register(username, displayName) {
+ this.loading = true;
+ this.render();
+ try {
+ const credential = await registerPasskey(username, displayName);
+ this.dispatchEvent(new CustomEvent("register-success", { detail: { credentialId: credential.credentialId, prfSupported: credential.prfSupported }, bubbles: true }));
+ await this.handleLogin();
+ } catch (error) {
+ this.dispatchEvent(new CustomEvent("register-error", { detail: { error: error.message }, bubbles: true }));
+ } finally {
+ this.loading = false;
+ this.render();
+ }
+ }
+}
+customElements.define("encryptid-login", EncryptIDLoginButton);
+// src/ui/guardian-setup.ts
+class GuardianSetupElement extends HTMLElement {
+ shadow;
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: "open" });
+ }
+ connectedCallback() {
+ const manager = getRecoveryManager();
+ const config = manager.getConfig();
+ if (!config) {
+ manager.initializeRecovery(3).then(() => this.render());
+ } else {
+ this.render();
+ }
+ }
+ render() {
+ const manager = getRecoveryManager();
+ const config = manager.getConfig();
+ const guardians = config?.guardians ?? [];
+ const threshold = config?.threshold ?? 3;
+ const totalWeight = guardians.reduce((sum, g) => sum + g.weight, 0);
+ const isConfigured = totalWeight >= threshold;
+ this.shadow.innerHTML = `
+
+
+
Social Recovery
+
Set up guardians to recover your account without seed phrases
+
+
+
+
${isConfigured ? "Recovery Configured" : "Setup Incomplete"}
+
${totalWeight}/${threshold} guardians
+
+
+ ${guardians.map((g) => {
+ const info = getGuardianTypeInfo(g.type);
+ return `
${info.icon === "key" ? "\uD83D\uDD11" : info.icon === "user" ? "\uD83D\uDC64" : info.icon === "shield" ? "\uD83D\uDEE1️" : info.icon === "building" ? "\uD83C\uDFE2" : "⏰"}
`;
+ }).join("")}
+
+ `;
+ }
+}
+customElements.define("encryptid-guardian-setup", GuardianSetupElement);
+export {
+ GuardianSetupElement,
+ EncryptIDLoginButton
+};
diff --git a/frontend/vendor/@encryptid/sdk/ui/login-button.d.ts b/frontend/vendor/@encryptid/sdk/ui/login-button.d.ts
new file mode 100644
index 0000000..cca4d94
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/login-button.d.ts
@@ -0,0 +1,30 @@
+/**
+ * EncryptID Login Button Web Component
+ *
+ * Custom element:
+ * Attributes: size (small|medium|large), variant (primary|outline), label, show-user
+ * Events: login-success, login-error, login-register-needed, logout, auth-upgraded
+ */
+export declare class EncryptIDLoginButton extends HTMLElement {
+ private shadow;
+ private loading;
+ private showDropdown;
+ private capabilities;
+ static get observedAttributes(): string[];
+ constructor();
+ connectedCallback(): Promise;
+ attributeChangedCallback(): void;
+ private get size();
+ private get variant();
+ private get label();
+ private get showUser();
+ private render;
+ private renderLoginButton;
+ private renderUserInfo;
+ private renderDropdown;
+ private attachEventListeners;
+ private handleLogin;
+ private handleDropdownAction;
+ private startConditionalAuth;
+ register(username: string, displayName: string): Promise;
+}
diff --git a/frontend/vendor/@encryptid/sdk/ui/react/EncryptIDProvider.d.ts b/frontend/vendor/@encryptid/sdk/ui/react/EncryptIDProvider.d.ts
new file mode 100644
index 0000000..7c5193f
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/react/EncryptIDProvider.d.ts
@@ -0,0 +1,39 @@
+/**
+ * EncryptID React Context Provider
+ *
+ * Wraps your app to provide EncryptID auth state to all components.
+ * Features: localStorage + cookie persistence, auto-refresh, session verification on mount.
+ */
+import React, { type ReactNode } from 'react';
+import { EncryptIDClient } from '../../client/api-client.js';
+import type { EncryptIDClaims } from '../../types/index.js';
+interface EncryptIDContextValue {
+ /** Whether the user is authenticated */
+ isAuthenticated: boolean;
+ /** JWT token (null if not authenticated) */
+ token: string | null;
+ /** Decoded claims (null if not authenticated) */
+ claims: EncryptIDClaims | null;
+ /** User's DID (null if not authenticated) */
+ did: string | null;
+ /** Username from EncryptID */
+ username: string | null;
+ /** Whether auth state is being loaded */
+ loading: boolean;
+ /** Full registration + authentication flow */
+ register: (username: string, displayName?: string) => Promise;
+ /** Full authentication flow */
+ login: (credentialId?: string) => Promise;
+ /** Clear session */
+ logout: () => void;
+ /** The EncryptID API client */
+ client: EncryptIDClient;
+}
+interface EncryptIDProviderProps {
+ children: ReactNode;
+ /** EncryptID server URL (default: https://encryptid.jeffemmett.com) */
+ serverUrl?: string;
+}
+export declare function EncryptIDProvider({ children, serverUrl }: EncryptIDProviderProps): React.FunctionComponentElement>;
+export declare function useEncryptID(): EncryptIDContextValue;
+export {};
diff --git a/frontend/vendor/@encryptid/sdk/ui/react/LoginButton.d.ts b/frontend/vendor/@encryptid/sdk/ui/react/LoginButton.d.ts
new file mode 100644
index 0000000..1caa867
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/react/LoginButton.d.ts
@@ -0,0 +1,32 @@
+/**
+ * EncryptID Login Button — React Component
+ *
+ * Wraps the EncryptID client for easy React integration.
+ */
+import React from 'react';
+interface LoginButtonProps {
+ /** Button label (default: "Sign in with Passkey") */
+ label?: string;
+ /** Button size */
+ size?: 'small' | 'medium' | 'large';
+ /** Visual variant */
+ variant?: 'primary' | 'outline';
+ /** Callback after successful login */
+ onSuccess?: (result: {
+ token: string;
+ did: string;
+ }) => void;
+ /** Callback on error */
+ onError?: (error: Error) => void;
+ /** Callback when registration is needed */
+ onRegisterNeeded?: () => void;
+ /** Additional CSS class */
+ className?: string;
+}
+export declare function LoginButton({ label, size, variant, onSuccess, onError, onRegisterNeeded, className, }: LoginButtonProps): React.DetailedReactHTMLElement<{
+ onClick: () => Promise;
+ disabled: boolean;
+ style: React.CSSProperties;
+ className: string;
+}, HTMLElement>;
+export {};
diff --git a/frontend/vendor/@encryptid/sdk/ui/react/index.d.ts b/frontend/vendor/@encryptid/sdk/ui/react/index.d.ts
new file mode 100644
index 0000000..8afd55f
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/react/index.d.ts
@@ -0,0 +1,5 @@
+/**
+ * @encryptid/sdk/ui/react — React Components
+ */
+export { EncryptIDProvider, useEncryptID } from './EncryptIDProvider.js';
+export { LoginButton } from './LoginButton.js';
diff --git a/frontend/vendor/@encryptid/sdk/ui/react/index.js b/frontend/vendor/@encryptid/sdk/ui/react/index.js
new file mode 100644
index 0000000..104ecf1
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/react/index.js
@@ -0,0 +1,160 @@
+import {
+ EncryptIDClient
+} from "../../index-7egxprg9.js";
+import"../../index-2cp5044h.js";
+
+// src/ui/react/EncryptIDProvider.tsx
+import React, { createContext, useContext, useEffect, useState, useCallback } from "react";
+var EncryptIDContext = createContext(null);
+var TOKEN_KEY = "encryptid_token";
+function EncryptIDProvider({ children, serverUrl }) {
+ const [client] = useState(() => new EncryptIDClient(serverUrl));
+ const [token, setToken] = useState(null);
+ const [claims, setClaims] = useState(null);
+ const [loading, setLoading] = useState(true);
+ useEffect(() => {
+ const stored = localStorage.getItem(TOKEN_KEY);
+ if (stored) {
+ client.verifySession(stored).then((res) => {
+ if (res.valid) {
+ setToken(stored);
+ try {
+ const payload = JSON.parse(atob(stored.split(".")[1]));
+ setClaims(payload);
+ } catch {}
+ } else {
+ localStorage.removeItem(TOKEN_KEY);
+ }
+ }).catch(() => {
+ localStorage.removeItem(TOKEN_KEY);
+ }).finally(() => setLoading(false));
+ } else {
+ setLoading(false);
+ }
+ }, [client]);
+ const register = useCallback(async (username, displayName) => {
+ setLoading(true);
+ try {
+ const result = await client.register(username, displayName);
+ setToken(result.token);
+ localStorage.setItem(TOKEN_KEY, result.token);
+ try {
+ setClaims(JSON.parse(atob(result.token.split(".")[1])));
+ } catch {}
+ } finally {
+ setLoading(false);
+ }
+ }, [client]);
+ const login = useCallback(async (credentialId) => {
+ setLoading(true);
+ try {
+ const result = await client.authenticate(credentialId);
+ setToken(result.token);
+ localStorage.setItem(TOKEN_KEY, result.token);
+ try {
+ setClaims(JSON.parse(atob(result.token.split(".")[1])));
+ } catch {}
+ } finally {
+ setLoading(false);
+ }
+ }, [client]);
+ const logout = useCallback(() => {
+ setToken(null);
+ setClaims(null);
+ localStorage.removeItem(TOKEN_KEY);
+ localStorage.removeItem("encryptid_session");
+ }, []);
+ const value = {
+ isAuthenticated: !!token,
+ token,
+ claims,
+ did: claims?.did || claims?.sub || null,
+ username: claims?.username || null,
+ loading,
+ register,
+ login,
+ logout,
+ client
+ };
+ return React.createElement(EncryptIDContext.Provider, { value }, children);
+}
+function useEncryptID() {
+ const ctx = useContext(EncryptIDContext);
+ if (!ctx)
+ throw new Error("useEncryptID must be used within ");
+ return ctx;
+}
+// src/ui/react/LoginButton.tsx
+import React2, { useState as useState2, useCallback as useCallback2 } from "react";
+function LoginButton({
+ label = "Sign in with Passkey",
+ size = "medium",
+ variant = "primary",
+ onSuccess,
+ onError,
+ onRegisterNeeded,
+ className = ""
+}) {
+ const { login, isAuthenticated, did, logout, loading: contextLoading } = useEncryptID();
+ const [loading, setLoading] = useState2(false);
+ const handleClick = useCallback2(async () => {
+ if (isAuthenticated) {
+ logout();
+ return;
+ }
+ setLoading(true);
+ try {
+ await login();
+ const token = localStorage.getItem("encryptid_token") || "";
+ const currentDid = did || "";
+ onSuccess?.({ token, did: currentDid });
+ } catch (error) {
+ if (error.name === "NotAllowedError") {
+ onRegisterNeeded?.();
+ } else {
+ onError?.(error);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [login, logout, isAuthenticated, did, onSuccess, onError, onRegisterNeeded]);
+ const isLoading = loading || contextLoading;
+ const sizeStyles = {
+ small: { padding: "8px 16px", fontSize: "0.875rem" },
+ medium: { padding: "12px 24px", fontSize: "1rem" },
+ large: { padding: "16px 32px", fontSize: "1.125rem" }
+ };
+ const baseStyle = {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "12px",
+ border: "none",
+ borderRadius: "8px",
+ fontWeight: 500,
+ cursor: isLoading ? "not-allowed" : "pointer",
+ opacity: isLoading ? 0.6 : 1,
+ transition: "all 0.2s",
+ fontFamily: "system-ui, -apple-system, sans-serif",
+ ...sizeStyles[size],
+ ...variant === "primary" ? { background: "#06b6d4", color: "white" } : { background: "transparent", border: "2px solid #06b6d4", color: "#06b6d4" }
+ };
+ return React2.createElement("button", {
+ onClick: handleClick,
+ disabled: isLoading,
+ style: baseStyle,
+ className
+ }, isLoading ? React2.createElement("span", null, "Authenticating...") : React2.createElement(React2.Fragment, null, React2.createElement("svg", {
+ viewBox: "0 0 24 24",
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 2,
+ strokeLinecap: "round",
+ strokeLinejoin: "round",
+ style: { width: "24px", height: "24px" }
+ }, React2.createElement("circle", { cx: 12, cy: 10, r: 3 }), React2.createElement("path", { d: "M12 13v8" }), React2.createElement("path", { d: "M9 18h6" }), React2.createElement("circle", { cx: 12, cy: 10, r: 7 })), React2.createElement("span", null, isAuthenticated ? "Sign Out" : label)));
+}
+export {
+ useEncryptID,
+ LoginButton,
+ EncryptIDProvider
+};
diff --git a/frontend/vendor/@encryptid/sdk/ui/react/useEncryptID.d.ts b/frontend/vendor/@encryptid/sdk/ui/react/useEncryptID.d.ts
new file mode 100644
index 0000000..4c6ecd6
--- /dev/null
+++ b/frontend/vendor/@encryptid/sdk/ui/react/useEncryptID.d.ts
@@ -0,0 +1,4 @@
+/**
+ * Re-export useEncryptID hook from Provider module
+ */
+export { useEncryptID } from './EncryptIDProvider.js';
diff --git a/spaces/_default/space.yaml b/spaces/_default/space.yaml
new file mode 100644
index 0000000..51fe163
--- /dev/null
+++ b/spaces/_default/space.yaml
@@ -0,0 +1,36 @@
+id: default
+name: "rSwag"
+tagline: "Community Merch, On Demand"
+description: "Your community's merch shop — designed locally, printed on demand. Revenue flows directly back to your community."
+domain: "rswag.online"
+footer_text: "rSpace. Infrastructure for the commons."
+
+theme:
+ primary: "195 80% 50%"
+ primary_foreground: "0 0% 100%"
+ secondary: "45 80% 55%"
+ secondary_foreground: "180 10% 96%"
+ background: "222 30% 6%"
+ foreground: "180 10% 96%"
+ card: "222 25% 8%"
+ card_foreground: "180 10% 96%"
+ popover: "222 25% 8%"
+ popover_foreground: "180 10% 96%"
+ muted: "222 20% 14%"
+ muted_foreground: "215 20% 65%"
+ accent: "222 20% 14%"
+ accent_foreground: "180 10% 96%"
+ destructive: "0 62.8% 30.6%"
+ destructive_foreground: "210 40% 98%"
+ border: "222 20% 16%"
+ input: "222 20% 16%"
+ ring: "195 80% 50%"
+
+design_filter: "all"
+logo_url: null
+
+design_tips:
+ - "Be specific about text you want included"
+ - "Mention colors, mood, and style preferences"
+ - "rSpace themes work great: spatial webs, nodes, commons"
+ - "Generated designs start as drafts — preview before adding to the store"
diff --git a/spaces/fungiflows/space.yaml b/spaces/fungiflows/space.yaml
new file mode 100644
index 0000000..c892412
--- /dev/null
+++ b/spaces/fungiflows/space.yaml
@@ -0,0 +1,36 @@
+id: fungiflows
+name: "Fungi Flows"
+tagline: "If Hip Hop Ate Magic Mushrooms"
+description: "Psychedelic mushroom merch for the Fungi Flows movement. Hoodies, tees, stickers, and prints — spreading spores of consciousness."
+domain: "fungiswag.jeffemmett.com"
+footer_text: "Fungi Flows. Spreading spores of consciousness."
+
+theme:
+ primary: "51 100% 50%"
+ primary_foreground: "264 30% 6%"
+ secondary: "110 100% 54%"
+ secondary_foreground: "264 30% 6%"
+ background: "264 30% 4%"
+ foreground: "40 30% 92%"
+ card: "264 30% 6%"
+ card_foreground: "40 30% 92%"
+ popover: "264 30% 6%"
+ popover_foreground: "40 30% 92%"
+ muted: "264 20% 12%"
+ muted_foreground: "40 10% 60%"
+ accent: "264 20% 12%"
+ accent_foreground: "40 30% 92%"
+ destructive: "0 62.8% 30.6%"
+ destructive_foreground: "210 40% 98%"
+ border: "264 15% 18%"
+ input: "264 15% 18%"
+ ring: "51 100% 50%"
+
+design_filter: "fungiflows"
+logo_url: null
+
+design_tips:
+ - "Mushroom and mycological imagery works perfectly"
+ - "Include psychedelic, bioluminescent, and fractal elements"
+ - "Pittsburgh hip-hop and music culture references welcome"
+ - "Deep purple, gold, and neon green color palette"