Skip to content

jmwalletd.app

jmwalletd.app

FastAPI application factory.

Creates the FastAPI app with: - CORS middleware restricted to local origins (localhost, 127.0.0.1, [::1]). - All API routes under /api/v1. - WebSocket endpoint at /ws (for direct connections) and /api/v1/ws. - Global exception handlers that produce reference-compatible error responses. - Cache-busting response headers.

Classes

Functions:

create_app(*, data_dir: Path | None = None) -> FastAPI

Create and configure the FastAPI application.

Args: data_dir: JoinMarket data directory. Defaults to ~/.joinmarket-ng.

Returns: Configured FastAPI app ready to serve.

Source code in jmwalletd/src/jmwalletd/app.py
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def create_app(*, data_dir: Path | None = None) -> FastAPI:
    """Create and configure the FastAPI application.

    Args:
        data_dir: JoinMarket data directory. Defaults to ``~/.joinmarket-ng``.

    Returns:
        Configured FastAPI app ready to serve.
    """
    app = FastAPI(
        title="JoinMarket wallet daemon",
        description="JAM-compatible HTTP/WebSocket API for JoinMarket-NG",
        version="0.17.0",
        swagger_ui_init_oauth={
            "clientId": "jmwalletd-local",
            "appName": "JoinMarket Wallet Daemon",
            "usePkceWithAuthorizationCodeGrant": False,
        },
        lifespan=_lifespan,
    )

    allowed_origins = [
        "http://localhost",
        "http://127.0.0.1",
        "http://[::1]",
    ]
    # Allow any port on localhost (common for development/custom setups)
    allow_origin_regex = r"^http://(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$"

    app.add_middleware(
        CORSMiddleware,
        allow_origins=allowed_origins,
        allow_origin_regex=allow_origin_regex,
        allow_methods=["*"],
        allow_headers=["*"],
        allow_credentials=False,
    )

    # ------------------------------------------------------------------
    # Daemon state singleton.
    # ------------------------------------------------------------------
    state = DaemonState(data_dir=data_dir)
    set_daemon_state(state)

    # ------------------------------------------------------------------
    # In-memory log ring buffer so jam's Logs page can fetch recent output.
    # ------------------------------------------------------------------
    install_log_sink()

    # ------------------------------------------------------------------
    # Cache-busting response headers (matching reference).
    # ------------------------------------------------------------------
    @app.middleware("http")
    async def add_cache_headers(request: Request, call_next: Any) -> Any:
        response = await call_next(request)
        response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "Sat, 26 Jul 1997 05:00:00 GMT"
        return response

    # ------------------------------------------------------------------
    # Exception handlers.
    # ------------------------------------------------------------------
    @app.exception_handler(JMWalletDaemonError)
    async def daemon_error_handler(request: Request, exc: JMWalletDaemonError) -> JSONResponse:
        headers: dict[str, str] = {}

        # Add WWW-Authenticate header for auth-related 401/403 errors and label
        # service-state 401s distinctly so JAM does not mis-handle them as an
        # expired token.
        if exc.status_code in (401, 403):
            if isinstance(exc, (InvalidToken, InvalidCredentials)):
                error_type = "invalid_token"
            elif isinstance(exc, InsufficientScope):
                error_type = "insufficient_scope"
            else:
                error_type = "service_state"
            headers["WWW-Authenticate"] = (
                f'Bearer, error="{error_type}", error_description="{exc.detail}"'
            )

        return JSONResponse(
            status_code=exc.status_code,
            content={"message": exc.detail},
            headers=headers,
        )

    # ------------------------------------------------------------------
    # Register routers.
    # ------------------------------------------------------------------
    from jmwalletd.routers.coinjoin import router as coinjoin_router
    from jmwalletd.routers.logs import router as logs_router
    from jmwalletd.routers.obwatch import router as obwatch_router
    from jmwalletd.routers.tumbler import router as tumbler_router
    from jmwalletd.routers.wallet import router as wallet_router
    from jmwalletd.routers.wallet_data import router as wallet_data_router
    from jmwalletd.websocket import router as ws_router

    app.include_router(wallet_router, prefix="/api/v1")
    app.include_router(wallet_data_router, prefix="/api/v1")
    app.include_router(coinjoin_router, prefix="/api/v1")
    app.include_router(tumbler_router, prefix="/api/v1")
    app.include_router(obwatch_router, prefix="/api/v1")
    app.include_router(logs_router, prefix="/api/v1")

    # JAM also calls /obwatch/* without the /api/v1 prefix.
    app.include_router(obwatch_router)

    # WebSocket: available at /ws (direct), /api/v1/ws (prefixed),
    # and /jmws (what JAM frontend connects to).
    app.include_router(ws_router, prefix="/ws")
    app.include_router(ws_router, prefix="/jmws")
    app.include_router(ws_router, prefix="/api/v1/ws")

    # ------------------------------------------------------------------
    # CORS preflight handler for root (restricted to local origins).
    # Registered before SPA catch-all so GET / can be served by JAM.
    # ------------------------------------------------------------------
    @app.options("/")
    async def cors_preflight(request: Request) -> JSONResponse:
        origin = request.headers.get("Origin")

        # Only allow local origins
        def is_allowed(o: str | None) -> bool:
            return bool(o and (o in allowed_origins or re.match(allow_origin_regex, o)))

        allow_origin = origin if is_allowed(origin) else "http://localhost:28183"

        return JSONResponse(
            content={},
            headers={
                "Access-Control-Allow-Origin": allow_origin,
                "Access-Control-Allow-Methods": "POST",
            },
        )

    # ------------------------------------------------------------------
    # Serve JAM static files (if present).
    # ------------------------------------------------------------------
    # Look in data_dir/jam, system path, or Flatpak prefix /app
    jam_dirs = [
        state.data_dir / "jam",
        Path("/app/share/jmwalletd/jam"),
        Path("/usr/share/jmwalletd/jam"),
    ]
    static_dir: Path | None = None
    for d in jam_dirs:
        if d.exists() and (d / "index.html").exists():
            static_dir = d
            break

    if static_dir:
        logger.info("Serving JAM from {}", static_dir)
        from starlette.routing import Route
        from starlette.staticfiles import StaticFiles as StarletteStaticFiles

        # Serve /assets folder
        if (static_dir / "assets").exists():
            app.mount(
                "/assets", StarletteStaticFiles(directory=static_dir / "assets"), name="assets"
            )

        # SPA catch-all: only intercept GET/HEAD requests so that POST/PUT/etc.
        # to API paths that don't exist get a proper 404/405 from the API routers
        # rather than being swallowed and served index.html or a 405.
        async def serve_spa(request: Request) -> Any:
            from fastapi.responses import FileResponse

            full_path = request.path_params.get("full_path", "")
            path_obj = static_dir / full_path
            if full_path:
                try:
                    resolved_path = path_obj.resolve(strict=False)
                    static_root = static_dir.resolve(strict=True)
                    if (
                        resolved_path != static_root
                        and static_root in resolved_path.parents
                        and resolved_path.exists()
                        and resolved_path.is_file()
                    ):
                        return FileResponse(resolved_path)
                except OSError:
                    # Invalid path input should fall back to index.html
                    pass
            return FileResponse(static_dir / "index.html")

        app.router.routes.append(
            Route("/{full_path:path}", endpoint=serve_spa, methods=["GET", "HEAD"])
        )

    logger.info("jmwalletd app created (data_dir={})", state.data_dir)
    return app