From 7e7be05b9a9c3a05e3643a9280f23fb50aa9e914 Mon Sep 17 00:00:00 2001 From: Brian Date: Wed, 25 Feb 2026 10:33:27 -0800 Subject: [PATCH 1/2] Add post-login Terms & Conditions enforcement REST endpoints (GET /api/tc/status, POST /api/tc/accept) with JWT auth and dev-mode test token support. Frontend redirects to /terms page before connecting WebSocket if T&C not yet accepted. Lazy WebSocket connection pattern ensures no connection on auth pages. Co-Authored-By: Claude Opus 4.6 --- ...8860ee9e6fb9c159895ae83c13d0308cd8cb0.json | 2 +- ...5c106a55d76737caf59b0c5c20cd565be1e7a.json | 26 ++++++ ...c5e82985353543fd474e9e64cc1549e62e940.json | 12 +++ ...5faead219b685cdf38a466058067e3b795151.json | 2 +- backend/Cargo.toml | 2 +- .../20260224000000_add_tc_columns.sql | 2 + backend/src/auth.rs | 13 +++ backend/src/db.rs | 40 ++++++++ backend/src/lib.rs | 1 + backend/src/main.rs | 3 + backend/src/tc.rs | 85 +++++++++++++++++ frontend/src/lib/api.svelte.ts | 79 +++++++++++++--- frontend/src/lib/terms.ts | 39 ++++++++ frontend/src/routes/(auth)/terms/+page.svelte | 92 +++++++++++++++++++ frontend/src/routes/+layout.svelte | 35 +++++-- frontend/vite.config.ts | 5 +- 16 files changed, 411 insertions(+), 27 deletions(-) create mode 100644 backend/.sqlx/query-35025cfb400377168e5e5a884af5c106a55d76737caf59b0c5c20cd565be1e7a.json create mode 100644 backend/.sqlx/query-ab30cab1778dcea54357763c7ecc5e82985353543fd474e9e64cc1549e62e940.json create mode 100644 backend/migrations/20260224000000_add_tc_columns.sql create mode 100644 backend/src/tc.rs create mode 100644 frontend/src/lib/terms.ts create mode 100644 frontend/src/routes/(auth)/terms/+page.svelte diff --git a/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json b/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json index 62ccad36..0c20ab1c 100644 --- a/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json +++ b/backend/.sqlx/query-0f706c05ef523de5c112b3b6f788860ee9e6fb9c159895ae83c13d0308cd8cb0.json @@ -53,7 +53,7 @@ "Right": 1 }, "nullable": [ - false, + true, false, false, false, diff --git a/backend/.sqlx/query-35025cfb400377168e5e5a884af5c106a55d76737caf59b0c5c20cd565be1e7a.json b/backend/.sqlx/query-35025cfb400377168e5e5a884af5c106a55d76737caf59b0c5c20cd565be1e7a.json new file mode 100644 index 00000000..8b204ceb --- /dev/null +++ b/backend/.sqlx/query-35025cfb400377168e5e5a884af5c106a55d76737caf59b0c5c20cd565be1e7a.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT tc_accepted_at IS NOT NULL AS \"accepted!: bool\", tc_version\n FROM account\n WHERE kinde_id = ?\n ", + "describe": { + "columns": [ + { + "name": "accepted!: bool", + "ordinal": 0, + "type_info": "Int" + }, + { + "name": "tc_version", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "35025cfb400377168e5e5a884af5c106a55d76737caf59b0c5c20cd565be1e7a" +} diff --git a/backend/.sqlx/query-ab30cab1778dcea54357763c7ecc5e82985353543fd474e9e64cc1549e62e940.json b/backend/.sqlx/query-ab30cab1778dcea54357763c7ecc5e82985353543fd474e9e64cc1549e62e940.json new file mode 100644 index 00000000..ee9719ee --- /dev/null +++ b/backend/.sqlx/query-ab30cab1778dcea54357763c7ecc5e82985353543fd474e9e64cc1549e62e940.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE account\n SET tc_accepted_at = CURRENT_TIMESTAMP, tc_version = ?\n WHERE kinde_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ab30cab1778dcea54357763c7ecc5e82985353543fd474e9e64cc1549e62e940" +} diff --git a/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json b/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json index 9aa1ab1b..c0c21fca 100644 --- a/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json +++ b/backend/.sqlx/query-c14b33f32699bf8773bc195f23d5faead219b685cdf38a466058067e3b795151.json @@ -53,7 +53,7 @@ "Right": 0 }, "nullable": [ - false, + true, false, false, false, diff --git a/backend/Cargo.toml b/backend/Cargo.toml index eea1c80e..a11f0d1b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -22,7 +22,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [ serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" tokio = { version = "1.38.0", features = ["full"] } -tower-http = { version = "0.5.2", features = ["trace", "limit", "set-header"] } +tower-http = { version = "0.5.2", features = ["trace", "limit", "set-header", "cors"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } async-once-cell = "0.5.3" diff --git a/backend/migrations/20260224000000_add_tc_columns.sql b/backend/migrations/20260224000000_add_tc_columns.sql new file mode 100644 index 00000000..826ef4c4 --- /dev/null +++ b/backend/migrations/20260224000000_add_tc_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE "account" ADD COLUMN "tc_accepted_at" DATETIME DEFAULT NULL; +ALTER TABLE "account" ADD COLUMN "tc_version" TEXT DEFAULT NULL; diff --git a/backend/src/auth.rs b/backend/src/auth.rs index bc96df57..29922aa8 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -59,6 +59,19 @@ impl FromRequestParts for AccessClaims { (StatusCode::UNAUTHORIZED, "Missing Authorization header").into_response() })?; let token = bearer.token(); + + #[cfg(feature = "dev-mode")] + if token.starts_with("test::") { + let client = validate_test_token(token).map_err(|e| { + tracing::error!("Test token validation failed: {:?}", e); + (StatusCode::UNAUTHORIZED, "Bad test token").into_response() + })?; + return Ok(AccessClaims { + sub: client.id, + roles: client.roles, + }); + } + let claims = validate_jwt(token).await.map_err(|e| { tracing::error!("JWT validation failed: {:?}", e); (StatusCode::UNAUTHORIZED, "Bad JWT").into_response() diff --git a/backend/src/db.rs b/backend/src/db.rs index d7458efc..c5d2889b 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -3470,6 +3470,46 @@ impl DB { Ok((transaction, info)) } + + /// # Errors + /// Returns an error if the database query fails. + pub async fn get_tc_status_by_kinde_id( + &self, + kinde_id: &str, + ) -> SqlxResult)>> { + let row = sqlx::query!( + r#" + SELECT tc_accepted_at IS NOT NULL AS "accepted!: bool", tc_version + FROM account + WHERE kinde_id = ? + "#, + kinde_id + ) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| (r.accepted, r.tc_version))) + } + + /// # Errors + /// Returns an error if the database query fails. + pub async fn accept_tc_by_kinde_id( + &self, + kinde_id: &str, + tc_version: &str, + ) -> SqlxResult { + let result = sqlx::query!( + r#" + UPDATE account + SET tc_accepted_at = CURRENT_TIMESTAMP, tc_version = ? + WHERE kinde_id = ? + "#, + tc_version, + kinde_id + ) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } } async fn get_portfolio_with_credits( diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 279276f6..cd2a4a00 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -56,6 +56,7 @@ pub mod convert; pub mod db; pub mod handle_socket; pub mod subscriptions; +pub mod tc; #[cfg(feature = "dev-mode")] pub mod seed; diff --git a/backend/src/main.rs b/backend/src/main.rs index 9ca99618..db1cd541 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -10,6 +10,7 @@ use backend::{airtable_users, AppState}; use std::{env, path::Path, str::FromStr}; use tokio::{fs::create_dir_all, net::TcpListener}; use tower_http::{ + cors::CorsLayer, limit::RequestBodyLimitLayer, set_header::response::SetResponseHeaderLayer, trace::TraceLayer, }; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -42,7 +43,9 @@ async fn main() -> anyhow::Result<()> { .route("/sync-airtable-users", get(sync_airtable_users)) .route("/api/upload-image", post(upload_image)) .route("/api/images/:filename", get(serve_image)) + .merge(backend::tc::router()) .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()) // Limit file uploads to 10MB .layer(RequestBodyLimitLayer::new(50 * 1024 * 1024)) .layer(SetResponseHeaderLayer::if_not_present( diff --git a/backend/src/tc.rs b/backend/src/tc.rs new file mode 100644 index 00000000..be95e596 --- /dev/null +++ b/backend/src/tc.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::State, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; + +use crate::{auth::AccessClaims, AppState}; + +#[derive(Serialize)] +struct TcStatusResponse { + accepted: bool, + tc_version: Option, +} + +#[derive(Deserialize)] +struct AcceptTcRequest { + tc_version: String, +} + +#[derive(Serialize)] +struct AcceptTcResponse { + success: bool, +} + +async fn tc_status( + claims: AccessClaims, + State(state): State, +) -> impl IntoResponse { + let result = state.db.get_tc_status_by_kinde_id(&claims.sub).await; + match result { + Ok(Some((accepted, tc_version))) => Json(TcStatusResponse { + accepted, + tc_version, + }) + .into_response(), + Ok(None) => Json(TcStatusResponse { + accepted: false, + tc_version: None, + }) + .into_response(), + Err(e) => { + tracing::error!("Failed to get T&C status: {e}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error", + ) + .into_response() + } + } +} + +async fn accept_tc( + claims: AccessClaims, + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let result = state + .db + .accept_tc_by_kinde_id(&claims.sub, &body.tc_version) + .await; + match result { + Ok(true) => Json(AcceptTcResponse { success: true }).into_response(), + Ok(false) => ( + axum::http::StatusCode::NOT_FOUND, + "Account not found", + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to accept T&C: {e}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error", + ) + .into_response() + } + } +} + +pub fn router() -> Router { + Router::new() + .route("/api/tc/status", get(tc_status)) + .route("/api/tc/accept", post(accept_tc)) +} diff --git a/frontend/src/lib/api.svelte.ts b/frontend/src/lib/api.svelte.ts index a35a8a3b..b8bccdc0 100644 --- a/frontend/src/lib/api.svelte.ts +++ b/frontend/src/lib/api.svelte.ts @@ -32,10 +32,26 @@ import { notifyUser } from './notifications'; // // Using new Error().stack is generally more reliable for the original call site. // }; -const socket = new ReconnectingWebSocket(PUBLIC_SERVER_URL); -socket.binaryType = 'arraybuffer'; +let socket: ReconnectingWebSocket | null = null; + +function ensureSocket(): ReconnectingWebSocket { + if (!socket) { + socket = new ReconnectingWebSocket(PUBLIC_SERVER_URL); + socket.binaryType = 'arraybuffer'; + console.log('Connecting to', PUBLIC_SERVER_URL); + socket.onopen = authenticate; + socket.onclose = () => { + serverState.stale = true; + }; + socket.onmessage = handleMessage; + } + return socket; +} -console.log('Connecting to', PUBLIC_SERVER_URL); +/** Call from layout after auth check to establish WebSocket connection */ +export const connect = () => { + ensureSocket(); +}; export class MarketData { definition: websocket_api.IMarket = $state({}); @@ -63,7 +79,8 @@ export const serverState = $state({ auctions: new SvelteMap(), universes: new SvelteMap(), lastKnownTransactionId: 0, - arborPixieAccountId: undefined as number | undefined + arborPixieAccountId: undefined as number | undefined, + tcAccepted: undefined as boolean | undefined }); export const hasArborPixieTransfer = () => { @@ -99,11 +116,12 @@ let messageQueue: websocket_api.IClientMessage[] = []; let hasAuthenticated = false; export const sendClientMessage = (msg: websocket_api.IClientMessage) => { + const s = ensureSocket(); if (hasAuthenticated || 'authenticate' in msg) { const msgType = Object.keys(msg).find((key) => msg[key as keyof typeof msg]); console.log(`sending ${msgType} message`, msg[msgType as keyof typeof msg]); const data = websocket_api.ClientMessage.encode(msg).finish(); - socket.send(data); + s.send(data); hasAuthenticated = true; for (const m of messageQueue) { sendClientMessage(m); @@ -157,13 +175,7 @@ const authenticate = async () => { sendClientMessage({ authenticate }); }; -socket.onopen = authenticate; - -socket.onclose = () => { - serverState.stale = true; -}; - -socket.onmessage = (event: MessageEvent) => { +const handleMessage = (event: MessageEvent) => { const data = event.data; const msg = websocket_api.ServerMessage.decode(new Uint8Array(data)); @@ -527,5 +539,46 @@ if (browser) { /** Force WebSocket to reconnect and re-authenticate (useful after login state changes) */ export const reconnect = () => { - socket.reconnect(); + ensureSocket().reconnect(); }; + +export async function checkTcStatus(): Promise { + const token = await kinde.getToken(); + if (!token) return false; + try { + const res = await fetch('/api/tc/status', { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) return false; + const data = await res.json(); + serverState.tcAccepted = data.accepted; + return data.accepted; + } catch (e) { + console.error('Failed to check T&C status', e); + return false; + } +} + +export async function acceptTc(tcVersion: string): Promise { + const token = await kinde.getToken(); + if (!token) return false; + try { + const res = await fetch('/api/tc/accept', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ tc_version: tcVersion }) + }); + if (!res.ok) return false; + const data = await res.json(); + if (data.success) { + serverState.tcAccepted = true; + } + return data.success; + } catch (e) { + console.error('Failed to accept T&C', e); + return false; + } +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts new file mode 100644 index 00000000..53ea2899 --- /dev/null +++ b/frontend/src/lib/terms.ts @@ -0,0 +1,39 @@ +export const TC_VERSION = '1.0'; + +export const TC_CONTENT = ` +

Arbor Trading Platform — Terms & Conditions

+ +

By accessing and using the Arbor Trading Platform ("the Platform"), you agree to the following terms. If you do not agree, you may not use the Platform.

+ +

1. Educational Purpose

+

The Platform is an experimental educational tool designed for learning about markets, order books, and quantitative trading. All currency ("clips") used on the Platform is entirely fictitious and exists solely for demonstration and educational purposes. Clips carry no real-world financial value and cannot be redeemed, exchanged, or converted into real currency or any other form of value.

+ +

2. Acceptable Use

+

You agree not to:

+
    +
  • Attempt to disrupt, damage, or gain unauthorized access to the Platform or its underlying systems.
  • +
  • Exploit bugs, vulnerabilities, or unintended behavior in a way that degrades the experience for others. If you discover a bug or vulnerability, please report it to an administrator.
  • +
  • Use the Platform for any illegal, nefarious, or malicious purpose.
  • +
+ +

3. Conduct

+

You agree to engage respectfully with other participants. The following are prohibited:

+
    +
  • Slurs, hate speech, or discriminatory language in market names, account names, or any other user-generated content.
  • +
  • Harassment, bullying, or intimidation of other participants in any form.
  • +
  • Defacing the Platform with offensive or inappropriate content.
  • +
+

Administrators reserve the right to remove content and suspend or terminate accounts that violate these standards at their sole discretion.

+ +

4. Intellectual Property

+

The Platform, including its source code, design, and associated materials, is the intellectual property of Arbor. You may not copy, reproduce, distribute, reverse-engineer, or create derivative works from any part of the Platform without prior written permission.

+ +

5. Trade Data

+

All trading activity on the Platform, including but not limited to orders, trades, positions, and performance data, is the property of Arbor. Arbor may analyze, aggregate, anonymize, and share this data for research, educational, and promotional purposes.

+ +

6. No Guarantees

+

The Platform is provided "as is" on an experimental basis, without warranties of any kind. Arbor does not guarantee uptime, availability, data integrity, or continuity of service. The Platform may be taken offline, reset, or modified at any time without notice.

+ +

7. Changes to Terms

+

These terms may be updated from time to time. Continued use of the Platform after changes constitutes acceptance of the revised terms. You will be notified of material changes and may be asked to re-accept.

+`; diff --git a/frontend/src/routes/(auth)/terms/+page.svelte b/frontend/src/routes/(auth)/terms/+page.svelte new file mode 100644 index 00000000..7ce4c1ee --- /dev/null +++ b/frontend/src/routes/(auth)/terms/+page.svelte @@ -0,0 +1,92 @@ + + +{#if !mounted} + +{:else} +
+
+

Terms & Conditions

+

+ Please read and accept the terms and conditions to continue. +

+ +
+ {@html TC_CONTENT} +
+ +
+ + +
+ + +
+
+{/if} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 869bfcc8..2593f6e0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,6 +1,7 @@