From d06b3ad38c9f6ee9a6ba49506a9e856adeeda1cc Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 19 Apr 2026 09:17:15 +0000 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=90=8E=E7=AB=AF=E5=88=B0st?= =?UTF-8?q?db?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/spacetimedb-rust.mdc | 663 ++++++++++ .cursor/rules/spacetimedb.mdc | 116 ++ .env.example | 5 + .github/copilot-instructions.md | 770 +++++++++++ .windsurfrules | 770 +++++++++++ AGENTS.md | 771 +++++++++++ CLAUDE.md | 770 +++++++++++ ...TIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md | 196 +++ package-lock.json | 146 +- package.json | 1 + packages/shared/src/contracts/auth.ts | 2 +- scripts/spacetime/init_local_app_config.sql | 50 + spacetime.json | 10 + spacetime.local.json | 3 + spacetimedb/.gitignore | 1 + spacetimedb/Cargo.lock | 996 ++++++++++++++ spacetimedb/Cargo.toml | 14 + spacetimedb/src/auth.rs | 1174 +++++++++++++++++ spacetimedb/src/common.rs | 302 +++++ spacetimedb/src/config.rs | 107 ++ spacetimedb/src/lib.rs | 26 + spacetimedb/src/runtime.rs | 1034 +++++++++++++++ spacetimedb/src/types.rs | 665 ++++++++++ src/components/auth/AccountModal.tsx | 4 + src/components/auth/AuthGate.test.tsx | 100 +- src/components/auth/AuthGate.tsx | 525 +++----- .../auth/PhoneVerificationModal.tsx | 201 +++ .../game-shell/PlatformHomeView.tsx | 4 + src/module_bindings/add_reducer.rs | 66 + src/module_bindings/mod.rs | 817 ++++++++++++ src/module_bindings/person_table.rs | 111 ++ src/module_bindings/person_type.rs | 45 + src/module_bindings/say_hello_reducer.rs | 62 + src/services/authService.test.ts | 652 ++++----- src/services/authService.ts | 441 ++++--- src/services/storageService.test.ts | 146 +- src/services/storageService.ts | 555 ++++---- src/spacetime/client.ts | 242 ++++ ...clear_platform_browse_history_procedure.ts | 23 + .../generated/client_app_config_table.ts | 24 + .../delete_custom_world_profile_procedure.ts | 24 + .../generated/delete_snapshot_procedure.ts | 23 + src/spacetime/generated/index.ts | 282 ++++ src/spacetime/generated/kick_event_table.ts | 18 + .../generated/lift_my_risk_block_procedure.ts | 27 + .../logout_all_user_sessions_procedure.ts | 23 + .../generated/my_auth_audit_logs_table.ts | 20 + .../generated/my_auth_risk_blocks_table.ts | 24 + .../generated/my_auth_state_table.ts | 32 + .../generated/my_browse_history_table.ts | 29 + .../my_custom_world_profiles_table.ts | 37 + .../my_custom_world_sessions_table.ts | 18 + .../generated/my_profile_dashboard_table.ts | 18 + .../my_profile_played_worlds_table.ts | 23 + .../my_profile_wallet_ledger_table.ts | 19 + .../generated/my_runtime_settings_table.ts | 15 + src/spacetime/generated/my_snapshot_table.ts | 19 + .../generated/my_user_sessions_table.ts | 22 + .../publish_custom_world_profile_procedure.ts | 25 + .../published_custom_world_gallery_table.ts | 36 + .../published_custom_world_profiles_table.ts | 37 + .../put_runtime_settings_procedure.ts | 24 + .../revoke_user_session_procedure.ts | 24 + .../generated/save_snapshot_procedure.ts | 27 + .../send_sms_verification_code_procedure.ts | 28 + .../session_revocation_event_table.ts | 18 + src/spacetime/generated/types.ts | 600 +++++++++ src/spacetime/generated/types/procedures.ts | 55 + src/spacetime/generated/types/reducers.ts | 10 + ...npublish_custom_world_profile_procedure.ts | 25 + .../upsert_custom_world_profile_procedure.ts | 26 + .../upsert_custom_world_session_procedure.ts | 27 + ...psert_platform_browse_history_procedure.ts | 27 + .../verification_prompt_event_table.ts | 19 + .../generated/verify_sms_code_procedure.ts | 25 + src/spacetime/mappers.ts | 387 ++++++ 76 files changed, 13399 insertions(+), 1304 deletions(-) create mode 100644 .cursor/rules/spacetimedb-rust.mdc create mode 100644 .cursor/rules/spacetimedb.mdc create mode 100644 .github/copilot-instructions.md create mode 100644 .windsurfrules create mode 100644 CLAUDE.md create mode 100644 docs/technical/SPACETIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md create mode 100644 scripts/spacetime/init_local_app_config.sql create mode 100644 spacetime.json create mode 100644 spacetime.local.json create mode 100644 spacetimedb/.gitignore create mode 100644 spacetimedb/Cargo.lock create mode 100644 spacetimedb/Cargo.toml create mode 100644 spacetimedb/src/auth.rs create mode 100644 spacetimedb/src/common.rs create mode 100644 spacetimedb/src/config.rs create mode 100644 spacetimedb/src/lib.rs create mode 100644 spacetimedb/src/runtime.rs create mode 100644 spacetimedb/src/types.rs create mode 100644 src/components/auth/PhoneVerificationModal.tsx create mode 100644 src/module_bindings/add_reducer.rs create mode 100644 src/module_bindings/mod.rs create mode 100644 src/module_bindings/person_table.rs create mode 100644 src/module_bindings/person_type.rs create mode 100644 src/module_bindings/say_hello_reducer.rs create mode 100644 src/spacetime/client.ts create mode 100644 src/spacetime/generated/clear_platform_browse_history_procedure.ts create mode 100644 src/spacetime/generated/client_app_config_table.ts create mode 100644 src/spacetime/generated/delete_custom_world_profile_procedure.ts create mode 100644 src/spacetime/generated/delete_snapshot_procedure.ts create mode 100644 src/spacetime/generated/index.ts create mode 100644 src/spacetime/generated/kick_event_table.ts create mode 100644 src/spacetime/generated/lift_my_risk_block_procedure.ts create mode 100644 src/spacetime/generated/logout_all_user_sessions_procedure.ts create mode 100644 src/spacetime/generated/my_auth_audit_logs_table.ts create mode 100644 src/spacetime/generated/my_auth_risk_blocks_table.ts create mode 100644 src/spacetime/generated/my_auth_state_table.ts create mode 100644 src/spacetime/generated/my_browse_history_table.ts create mode 100644 src/spacetime/generated/my_custom_world_profiles_table.ts create mode 100644 src/spacetime/generated/my_custom_world_sessions_table.ts create mode 100644 src/spacetime/generated/my_profile_dashboard_table.ts create mode 100644 src/spacetime/generated/my_profile_played_worlds_table.ts create mode 100644 src/spacetime/generated/my_profile_wallet_ledger_table.ts create mode 100644 src/spacetime/generated/my_runtime_settings_table.ts create mode 100644 src/spacetime/generated/my_snapshot_table.ts create mode 100644 src/spacetime/generated/my_user_sessions_table.ts create mode 100644 src/spacetime/generated/publish_custom_world_profile_procedure.ts create mode 100644 src/spacetime/generated/published_custom_world_gallery_table.ts create mode 100644 src/spacetime/generated/published_custom_world_profiles_table.ts create mode 100644 src/spacetime/generated/put_runtime_settings_procedure.ts create mode 100644 src/spacetime/generated/revoke_user_session_procedure.ts create mode 100644 src/spacetime/generated/save_snapshot_procedure.ts create mode 100644 src/spacetime/generated/send_sms_verification_code_procedure.ts create mode 100644 src/spacetime/generated/session_revocation_event_table.ts create mode 100644 src/spacetime/generated/types.ts create mode 100644 src/spacetime/generated/types/procedures.ts create mode 100644 src/spacetime/generated/types/reducers.ts create mode 100644 src/spacetime/generated/unpublish_custom_world_profile_procedure.ts create mode 100644 src/spacetime/generated/upsert_custom_world_profile_procedure.ts create mode 100644 src/spacetime/generated/upsert_custom_world_session_procedure.ts create mode 100644 src/spacetime/generated/upsert_platform_browse_history_procedure.ts create mode 100644 src/spacetime/generated/verification_prompt_event_table.ts create mode 100644 src/spacetime/generated/verify_sms_code_procedure.ts create mode 100644 src/spacetime/mappers.ts diff --git a/.cursor/rules/spacetimedb-rust.mdc b/.cursor/rules/spacetimedb-rust.mdc new file mode 100644 index 00000000..f0ebeafb --- /dev/null +++ b/.cursor/rules/spacetimedb-rust.mdc @@ -0,0 +1,663 @@ +--- +description: "⛔ MANDATORY: Read this ENTIRE file before writing ANY SpacetimeDB Rust code. Contains SDK patterns from official documentation." +globs: **/*.rs +alwaysApply: true +--- + +# SpacetimeDB Rust SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB Rust code: + +### 1. Wrong Crate for Server vs Client + +```rust +// ❌ WRONG — using client crate for server module +use spacetimedb_sdk::*; // This is for CLIENTS only! + +// ✅ CORRECT — use spacetimedb for server modules +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +``` + +### 2. Wrong Table Macro Syntax + +```rust +// ❌ WRONG — using attribute-style like C# +#[spacetimedb::table] +#[primary_key] +pub struct User { ... } + +// ❌ WRONG — SpacetimeType on tables (causes conflicts!) +#[derive(SpacetimeType)] +#[table(accessor = my_table)] +pub struct MyTable { ... } + +// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + name: Option, +} +``` + +### 3. Wrong Table Access Pattern + +```rust +// ❌ WRONG — using ctx.Db or ctx.db() method or field access +ctx.Db.user.Insert(...); +ctx.db().user().insert(...); +ctx.db.player; // Field access + +// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses +ctx.db.user().insert(User { ... }); +ctx.db.user().identity().find(ctx.sender); +ctx.db.player().id().find(&player_id); +``` + +### 4. Wrong Update Pattern + +```rust +// ❌ WRONG — partial update or using .update() directly on table +ctx.db.user().update(User { name: Some("new".into()), ..Default::default() }); + +// ✅ CORRECT — find existing, spread it, update via primary key accessor +if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some("new".into()), ..user }); +} +``` + +### 5. Wrong Reducer Return Type + +```rust +// ❌ WRONG — returning data from reducer +#[reducer] +pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option { ... } + +// ❌ WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } + +// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context +#[reducer] +pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> { + if value.is_empty() { + return Err("Value cannot be empty".to_string()); + } + Ok(()) +} +``` + +### 6. Wrong Client Connection Pattern + +```rust +// ❌ WRONG — subscribing before connected +let conn = DbConnection::builder().build()?; +conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe in on_connect callback +DbConnection::builder() + .on_connect(|conn, identity, token| { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); + }) + .build()?; +``` + +### 7. Forgetting to Advance the Connection + +```rust +// ❌ WRONG — connection never processes messages +let conn = DbConnection::builder().build()?; +// ... callbacks never fire ... + +// ✅ CORRECT — must call one of these to process messages +conn.run_threaded(); // Spawn background thread +// OR +conn.run_async().await; // Async task +// OR (in game loop) +conn.frame_tick()?; // Manual polling +``` + +### 8. Missing Table Trait Import + +```rust +// ❌ WRONG — "no method named `insert` found" +use spacetimedb::{table, reducer, ReducerContext}; +ctx.db.user().insert(...); // ERROR! + +// ✅ CORRECT — import Table trait for table methods +use spacetimedb::{table, reducer, Table, ReducerContext}; +ctx.db.user().insert(...); // Works! +``` + +### 9. Wrong ScheduleAt Variant + +```rust +// ❌ WRONG — At variant doesn't exist +scheduled_at: ScheduleAt::At(future_time), + +// ✅ CORRECT — use Time variant +scheduled_at: ScheduleAt::Time(future_time), +``` + +### 10. Identity to String Conversion + +```rust +// ❌ WRONG — to_hex() returns HexString<32>, not String +let id: String = identity.to_hex(); // Type mismatch! + +// ✅ CORRECT — chain .to_string() +let id: String = identity.to_hex().to_string(); +``` + +### 11. Timestamp Duration Extraction + +```rust +// ❌ WRONG — returns Result, not Duration directly +let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros(); + +// ✅ CORRECT — unwrap the Result +let micros = ctx.timestamp.to_duration_since_unix_epoch() + .unwrap_or_default() + .as_micros(); +``` + +### 12. Borrow After Move + +```rust +// ❌ WRONG — `tool` moved into struct, then borrowed +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if tool == "eraser" { ... } // ERROR: value moved! + +// ✅ CORRECT — check before move, or use clone +let is_eraser = tool == "eraser"; +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if is_eraser { ... } +``` + +### 13. Client SDK Uses Blocking I/O + +The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor. + +### 14. Wrong Schedule Syntax +```rust +// ❌ WRONG — `schedule` is not a valid table type +#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))] + +// ✅ CORRECT — `scheduled` is a valid table type +#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))] +``` +--- + +## 1) Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +--- + +## 2) Table Definition (CRITICAL) + +**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!** + +> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc. + +```rust +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; + +// ❌ WRONG — DO NOT derive SpacetimeType on tables! +#[derive(SpacetimeType)] // REMOVE THIS! +#[table(accessor = task)] +pub struct Task { ... } + +// ✅ CORRECT — just the #[table] attribute +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + + #[unique] + username: Option, + + online: bool, +} + +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + sender: Identity, + text: String, + sent: Timestamp, +} + +// With multi-column index +#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))] +pub struct Task { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner_id: Identity, + pub title: String, +} +``` + +### Table Options + +```rust +#[table(accessor = my_table)] // Private table (default) +#[table(accessor = my_table, public)] // Public table - clients can subscribe +``` + +### Column Attributes + +```rust +#[primary_key] // Primary key (auto-indexed, enables .find()) +#[auto_inc] // Auto-increment (use with #[primary_key]) +#[unique] // Unique constraint (auto-indexed) +#[index(btree)] // B-Tree index for queries +``` + +### Insert returns ROW, not ID + +```rust +let row = ctx.db.task().insert(Task { + id: 0, // auto-inc placeholder + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## 3) Reducers + +### Definition Syntax + +```rust +use spacetimedb::{reducer, ReducerContext, Table}; + +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + // Validate input + if text.is_empty() { + return Err("Message cannot be empty".to_string()); + } + + // Insert returns the inserted row + let row = ctx.db.message().insert(Message { + id: 0, // auto-inc placeholder + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + + log::info!("Message {} sent by {:?}", row.id, ctx.sender); + Ok(()) +} +``` + +### Update Pattern (CRITICAL) + +```rust +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Find existing row + let user = ctx.db.user().identity().find(ctx.sender) + .ok_or("User not found")?; + + // ✅ CORRECT — spread existing row, override specific fields + ctx.db.user().identity().update(User { + name: Some(name), + ..user // Preserves identity, online, etc. + }); + + Ok(()) +} + +// ❌ WRONG — partial update nulls out other fields! +// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() }); +``` + +### Delete Pattern + +```rust +#[reducer] +pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> { + ctx.db.message().id().delete(&message_id); + Ok(()) +} +``` + +### Lifecycle Hooks + +```rust +#[reducer(init)] +pub fn init(ctx: &ReducerContext) { + // Called when module is first published +} + +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + // ctx.sender is the connecting identity + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + username: None, + online: true, + }); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } +} +``` + +### ReducerContext fields + +```rust +ctx.sender // Identity of the caller +ctx.timestamp // Current timestamp +ctx.db // Database access +ctx.rng // Deterministic RNG (use instead of rand) +``` + +--- + +## 4) Index Access + +### Primary Key / Unique — `.find()` returns `Option` + +```rust +// Primary key lookup +let user = ctx.db.user().identity().find(ctx.sender); + +// Unique column lookup +let user = ctx.db.user().username().find(&"alice".to_string()); + +if let Some(user) = user { + // Found +} +``` + +### BTree Index — `.filter()` returns iterator + +```rust +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + #[index(btree)] + room_id: u64, + + text: String, +} + +// Filter by indexed column +for msg in ctx.db.message().room_id().filter(&room_id) { + // Process each message in room +} +``` + +### No Index — `.iter()` + manual filter + +```rust +// Full table scan +for user in ctx.db.user().iter() { + if user.online { + // Process online users + } +} +``` + +--- + +## 5) Custom Types + +**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.** + +```rust +use spacetimedb::SpacetimeType; + +// Custom struct for table fields +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Custom enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), +} + +// Use in table (DO NOT derive SpacetimeType on the table!) +#[table(accessor = player, public)] +pub struct Player { + #[primary_key] + pub id: Identity, + pub position: Position, + pub status: PlayerStatus, +} +``` + +--- + +## 6) Scheduled Tables + +```rust +use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp}; + +#[table(accessor = cleanup_job, scheduled(cleanup_expired))] +pub struct CleanupJob { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + scheduled_at: ScheduleAt, + target_id: u64, +} + +#[reducer] +pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) { + // Job row is auto-deleted after reducer completes + log::info!("Cleaning up: {}", job.target_id); +} + +// Schedule a job +#[reducer] +pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) { + let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms); + ctx.db.cleanup_job().insert(CleanupJob { + scheduled_id: 0, // auto-inc placeholder + scheduled_at: ScheduleAt::Time(future_time), + target_id, + }); +} + +// Cancel by deleting the row +#[reducer] +pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) { + ctx.db.cleanup_job().scheduled_id().delete(&job_id); +} +``` + +--- + +## 7) Client SDK + +```rust +// Connection pattern +let conn = DbConnection::builder() + .with_uri("http://localhost:3000") + .with_module_name("my-module") + .with_token(load_saved_token()) // None for first connection + .on_connect(on_connected) + .build() + .expect("Failed to connect"); + +// Subscribe in on_connect callback, NOT before! +fn on_connected(conn: &DbConnection, identity: Identity, token: &str) { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); +} +``` + +### ⚠️ CRITICAL: Advance the Connection + +**You MUST call one of these** — without it, no callbacks fire: + +```rust +conn.run_threaded(); // Background thread (simplest) +conn.run_async().await; // Async task +conn.frame_tick()?; // Manual polling (game loops) +``` + +### Table Access & Callbacks + +```rust +// Iterate +for user in ctx.db.user().iter() { ... } + +// Find by primary key +if let Some(user) = ctx.db.user().identity().find(&identity) { ... } + +// Row callbacks +ctx.db.user().on_insert(|ctx, user| { ... }); +ctx.db.user().on_update(|ctx, old, new| { ... }); +ctx.db.user().on_delete(|ctx, user| { ... }); + +// Call reducers +ctx.reducers.set_name("Alice".to_string()).unwrap(); +``` + +--- + +## 8) Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +⚠️ Procedures are currently in beta. API may change. + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + +## 9) Logging + +```rust +use spacetimedb::log; + +log::trace!("Detailed trace"); +log::debug!("Debug info"); +log::info!("Information"); +log::warn!("Warning"); +log::error!("Error occurred"); +``` + +--- + +## 10) Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear database and republish +spacetime publish --clear-database -y --module-path + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --module-path + +# View logs +spacetime logs +``` + +--- + +## 11) Hard Requirements + +**Rust-specific:** + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc. +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk` +6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +7. **Use `ctx.rng`** — not `rand` crate for random numbers +8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers +9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()` +10. **Subscribe in `on_connect` callback** — not before connection is established +11. **Update requires full row** — spread existing row with `..existing` +12. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()` +14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes diff --git a/.cursor/rules/spacetimedb.mdc b/.cursor/rules/spacetimedb.mdc new file mode 100644 index 00000000..31cb4569 --- /dev/null +++ b/.cursor/rules/spacetimedb.mdc @@ -0,0 +1,116 @@ +--- +description: "⛔ MANDATORY: Core SpacetimeDB concepts (all languages)." +globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.rs,**/*.cs +alwaysApply: true +--- +# SpacetimeDB Rules (All Languages) + +## Migrating from 1.0 to 2.0? + +**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules. + +--- + +## Language-Specific Rules + +| Language | Rule File | +|----------|-----------| +| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) | +| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) | +| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) | +| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` | + +--- + +## Core Concepts + +1. **Reducers are transactional** — they do not return data to callers +2. **Reducers must be deterministic** — no filesystem, network, timers, or random +3. **Read data via tables/subscriptions** — not reducer return values +4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering +5. **`ctx.sender` is the authenticated principal** — never trust identity args + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **don't forget this step!** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Index System + +SpacetimeDB automatically creates indexes for: +- Primary key columns +- Columns marked as unique + +You can add explicit indexes on non-unique columns for query performance. + +**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error. + +**Schema ↔ Code coupling:** +- Your query code references indexes by name +- If you add/remove/rename an index in the schema, update all code that uses it +- Removing an index without updating queries causes runtime errors + +--- + +## Commands + +```bash +# Login to allow remote database deployment e.g. to maincloud +spacetime login + +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear and republish +spacetime publish --clear-database -y --module-path + +# Generate client bindings +spacetime generate --lang --out-dir --module-path + +# View logs +spacetime logs +``` + +--- + +## Deployment + +- Maincloud is the spacetimedb hosted cloud and the default location for module publishing +- The default server marked by *** in `spacetime server list` should be used when publishing +- If the default server is maincloud you should publish to maincloud +- Publishing to maincloud is free of charge +- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ +- The database owner can view utilization and performance metrics on the dashboard + +--- + +## Debugging Checklist + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## Editing Behavior + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo +- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users diff --git a/.env.example b/.env.example index ed357201..0a336db4 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image" NODE_SERVER_ADDR=":8081" NODE_SERVER_TARGET="http://127.0.0.1:8081" +# 前端直连 SpacetimeDB 所需配置。 +# 浏览器端当前直接订阅认证/存档/资料库相关 view,并调用对应 procedure。 +VITE_SPACETIME_URI="ws://127.0.0.1:3000" +VITE_SPACETIME_DATABASE_NAME="xushi-p4wfr" + # Local Caddy upstream target used for dist-based testing. CADDY_API_UPSTREAM="http://127.0.0.1:8081" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..aa960fa0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,770 @@ +# SpacetimeDB Rules (All Languages) + +## Migrating from 1.0 to 2.0? + +**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules. + +--- + +## Language-Specific Rules + +| Language | Rule File | +|----------|-----------| +| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) | +| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) | +| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) | +| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` | + +--- + +## Core Concepts + +1. **Reducers are transactional** — they do not return data to callers +2. **Reducers must be deterministic** — no filesystem, network, timers, or random +3. **Read data via tables/subscriptions** — not reducer return values +4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering +5. **`ctx.sender` is the authenticated principal** — never trust identity args + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **don't forget this step!** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Index System + +SpacetimeDB automatically creates indexes for: +- Primary key columns +- Columns marked as unique + +You can add explicit indexes on non-unique columns for query performance. + +**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error. + +**Schema ↔ Code coupling:** +- Your query code references indexes by name +- If you add/remove/rename an index in the schema, update all code that uses it +- Removing an index without updating queries causes runtime errors + +--- + +## Commands + +```bash +# Login to allow remote database deployment e.g. to maincloud +spacetime login + +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear and republish +spacetime publish --clear-database -y --module-path + +# Generate client bindings +spacetime generate --lang --out-dir --module-path + +# View logs +spacetime logs +``` + +--- + +## Deployment + +- Maincloud is the spacetimedb hosted cloud and the default location for module publishing +- The default server marked by *** in `spacetime server list` should be used when publishing +- If the default server is maincloud you should publish to maincloud +- Publishing to maincloud is free of charge +- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ +- The database owner can view utilization and performance metrics on the dashboard + +--- + +## Debugging Checklist + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## Editing Behavior + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo +- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users + + +# SpacetimeDB Rust SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB Rust code: + +### 1. Wrong Crate for Server vs Client + +```rust +// ❌ WRONG — using client crate for server module +use spacetimedb_sdk::*; // This is for CLIENTS only! + +// ✅ CORRECT — use spacetimedb for server modules +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +``` + +### 2. Wrong Table Macro Syntax + +```rust +// ❌ WRONG — using attribute-style like C# +#[spacetimedb::table] +#[primary_key] +pub struct User { ... } + +// ❌ WRONG — SpacetimeType on tables (causes conflicts!) +#[derive(SpacetimeType)] +#[table(accessor = my_table)] +pub struct MyTable { ... } + +// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + name: Option, +} +``` + +### 3. Wrong Table Access Pattern + +```rust +// ❌ WRONG — using ctx.Db or ctx.db() method or field access +ctx.Db.user.Insert(...); +ctx.db().user().insert(...); +ctx.db.player; // Field access + +// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses +ctx.db.user().insert(User { ... }); +ctx.db.user().identity().find(ctx.sender); +ctx.db.player().id().find(&player_id); +``` + +### 4. Wrong Update Pattern + +```rust +// ❌ WRONG — partial update or using .update() directly on table +ctx.db.user().update(User { name: Some("new".into()), ..Default::default() }); + +// ✅ CORRECT — find existing, spread it, update via primary key accessor +if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some("new".into()), ..user }); +} +``` + +### 5. Wrong Reducer Return Type + +```rust +// ❌ WRONG — returning data from reducer +#[reducer] +pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option { ... } + +// ❌ WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } + +// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context +#[reducer] +pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> { + if value.is_empty() { + return Err("Value cannot be empty".to_string()); + } + Ok(()) +} +``` + +### 6. Wrong Client Connection Pattern + +```rust +// ❌ WRONG — subscribing before connected +let conn = DbConnection::builder().build()?; +conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe in on_connect callback +DbConnection::builder() + .on_connect(|conn, identity, token| { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); + }) + .build()?; +``` + +### 7. Forgetting to Advance the Connection + +```rust +// ❌ WRONG — connection never processes messages +let conn = DbConnection::builder().build()?; +// ... callbacks never fire ... + +// ✅ CORRECT — must call one of these to process messages +conn.run_threaded(); // Spawn background thread +// OR +conn.run_async().await; // Async task +// OR (in game loop) +conn.frame_tick()?; // Manual polling +``` + +### 8. Missing Table Trait Import + +```rust +// ❌ WRONG — "no method named `insert` found" +use spacetimedb::{table, reducer, ReducerContext}; +ctx.db.user().insert(...); // ERROR! + +// ✅ CORRECT — import Table trait for table methods +use spacetimedb::{table, reducer, Table, ReducerContext}; +ctx.db.user().insert(...); // Works! +``` + +### 9. Wrong ScheduleAt Variant + +```rust +// ❌ WRONG — At variant doesn't exist +scheduled_at: ScheduleAt::At(future_time), + +// ✅ CORRECT — use Time variant +scheduled_at: ScheduleAt::Time(future_time), +``` + +### 10. Identity to String Conversion + +```rust +// ❌ WRONG — to_hex() returns HexString<32>, not String +let id: String = identity.to_hex(); // Type mismatch! + +// ✅ CORRECT — chain .to_string() +let id: String = identity.to_hex().to_string(); +``` + +### 11. Timestamp Duration Extraction + +```rust +// ❌ WRONG — returns Result, not Duration directly +let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros(); + +// ✅ CORRECT — unwrap the Result +let micros = ctx.timestamp.to_duration_since_unix_epoch() + .unwrap_or_default() + .as_micros(); +``` + +### 12. Borrow After Move + +```rust +// ❌ WRONG — `tool` moved into struct, then borrowed +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if tool == "eraser" { ... } // ERROR: value moved! + +// ✅ CORRECT — check before move, or use clone +let is_eraser = tool == "eraser"; +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if is_eraser { ... } +``` + +### 13. Client SDK Uses Blocking I/O + +The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor. + +### 14. Wrong Schedule Syntax +```rust +// ❌ WRONG — `schedule` is not a valid table type +#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))] + +// ✅ CORRECT — `scheduled` is a valid table type +#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))] +``` +--- + +## 1) Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +--- + +## 2) Table Definition (CRITICAL) + +**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!** + +> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc. + +```rust +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; + +// ❌ WRONG — DO NOT derive SpacetimeType on tables! +#[derive(SpacetimeType)] // REMOVE THIS! +#[table(accessor = task)] +pub struct Task { ... } + +// ✅ CORRECT — just the #[table] attribute +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + + #[unique] + username: Option, + + online: bool, +} + +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + sender: Identity, + text: String, + sent: Timestamp, +} + +// With multi-column index +#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))] +pub struct Task { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner_id: Identity, + pub title: String, +} +``` + +### Table Options + +```rust +#[table(accessor = my_table)] // Private table (default) +#[table(accessor = my_table, public)] // Public table - clients can subscribe +``` + +### Column Attributes + +```rust +#[primary_key] // Primary key (auto-indexed, enables .find()) +#[auto_inc] // Auto-increment (use with #[primary_key]) +#[unique] // Unique constraint (auto-indexed) +#[index(btree)] // B-Tree index for queries +``` + +### Insert returns ROW, not ID + +```rust +let row = ctx.db.task().insert(Task { + id: 0, // auto-inc placeholder + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## 3) Reducers + +### Definition Syntax + +```rust +use spacetimedb::{reducer, ReducerContext, Table}; + +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + // Validate input + if text.is_empty() { + return Err("Message cannot be empty".to_string()); + } + + // Insert returns the inserted row + let row = ctx.db.message().insert(Message { + id: 0, // auto-inc placeholder + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + + log::info!("Message {} sent by {:?}", row.id, ctx.sender); + Ok(()) +} +``` + +### Update Pattern (CRITICAL) + +```rust +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Find existing row + let user = ctx.db.user().identity().find(ctx.sender) + .ok_or("User not found")?; + + // ✅ CORRECT — spread existing row, override specific fields + ctx.db.user().identity().update(User { + name: Some(name), + ..user // Preserves identity, online, etc. + }); + + Ok(()) +} + +// ❌ WRONG — partial update nulls out other fields! +// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() }); +``` + +### Delete Pattern + +```rust +#[reducer] +pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> { + ctx.db.message().id().delete(&message_id); + Ok(()) +} +``` + +### Lifecycle Hooks + +```rust +#[reducer(init)] +pub fn init(ctx: &ReducerContext) { + // Called when module is first published +} + +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + // ctx.sender is the connecting identity + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + username: None, + online: true, + }); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } +} +``` + +### ReducerContext fields + +```rust +ctx.sender // Identity of the caller +ctx.timestamp // Current timestamp +ctx.db // Database access +ctx.rng // Deterministic RNG (use instead of rand) +``` + +--- + +## 4) Index Access + +### Primary Key / Unique — `.find()` returns `Option` + +```rust +// Primary key lookup +let user = ctx.db.user().identity().find(ctx.sender); + +// Unique column lookup +let user = ctx.db.user().username().find(&"alice".to_string()); + +if let Some(user) = user { + // Found +} +``` + +### BTree Index — `.filter()` returns iterator + +```rust +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + #[index(btree)] + room_id: u64, + + text: String, +} + +// Filter by indexed column +for msg in ctx.db.message().room_id().filter(&room_id) { + // Process each message in room +} +``` + +### No Index — `.iter()` + manual filter + +```rust +// Full table scan +for user in ctx.db.user().iter() { + if user.online { + // Process online users + } +} +``` + +--- + +## 5) Custom Types + +**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.** + +```rust +use spacetimedb::SpacetimeType; + +// Custom struct for table fields +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Custom enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), +} + +// Use in table (DO NOT derive SpacetimeType on the table!) +#[table(accessor = player, public)] +pub struct Player { + #[primary_key] + pub id: Identity, + pub position: Position, + pub status: PlayerStatus, +} +``` + +--- + +## 6) Scheduled Tables + +```rust +use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp}; + +#[table(accessor = cleanup_job, scheduled(cleanup_expired))] +pub struct CleanupJob { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + scheduled_at: ScheduleAt, + target_id: u64, +} + +#[reducer] +pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) { + // Job row is auto-deleted after reducer completes + log::info!("Cleaning up: {}", job.target_id); +} + +// Schedule a job +#[reducer] +pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) { + let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms); + ctx.db.cleanup_job().insert(CleanupJob { + scheduled_id: 0, // auto-inc placeholder + scheduled_at: ScheduleAt::Time(future_time), + target_id, + }); +} + +// Cancel by deleting the row +#[reducer] +pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) { + ctx.db.cleanup_job().scheduled_id().delete(&job_id); +} +``` + +--- + +## 7) Client SDK + +```rust +// Connection pattern +let conn = DbConnection::builder() + .with_uri("http://localhost:3000") + .with_module_name("my-module") + .with_token(load_saved_token()) // None for first connection + .on_connect(on_connected) + .build() + .expect("Failed to connect"); + +// Subscribe in on_connect callback, NOT before! +fn on_connected(conn: &DbConnection, identity: Identity, token: &str) { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); +} +``` + +### ⚠️ CRITICAL: Advance the Connection + +**You MUST call one of these** — without it, no callbacks fire: + +```rust +conn.run_threaded(); // Background thread (simplest) +conn.run_async().await; // Async task +conn.frame_tick()?; // Manual polling (game loops) +``` + +### Table Access & Callbacks + +```rust +// Iterate +for user in ctx.db.user().iter() { ... } + +// Find by primary key +if let Some(user) = ctx.db.user().identity().find(&identity) { ... } + +// Row callbacks +ctx.db.user().on_insert(|ctx, user| { ... }); +ctx.db.user().on_update(|ctx, old, new| { ... }); +ctx.db.user().on_delete(|ctx, user| { ... }); + +// Call reducers +ctx.reducers.set_name("Alice".to_string()).unwrap(); +``` + +--- + +## 8) Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +⚠️ Procedures are currently in beta. API may change. + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + +## 9) Logging + +```rust +use spacetimedb::log; + +log::trace!("Detailed trace"); +log::debug!("Debug info"); +log::info!("Information"); +log::warn!("Warning"); +log::error!("Error occurred"); +``` + +--- + +## 10) Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear database and republish +spacetime publish --clear-database -y --module-path + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --module-path + +# View logs +spacetime logs +``` + +--- + +## 11) Hard Requirements + +**Rust-specific:** + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc. +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk` +6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +7. **Use `ctx.rng`** — not `rand` crate for random numbers +8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers +9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()` +10. **Subscribe in `on_connect` callback** — not before connection is established +11. **Update requires full row** — spread existing row with `..existing` +12. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()` +14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes diff --git a/.windsurfrules b/.windsurfrules new file mode 100644 index 00000000..aa960fa0 --- /dev/null +++ b/.windsurfrules @@ -0,0 +1,770 @@ +# SpacetimeDB Rules (All Languages) + +## Migrating from 1.0 to 2.0? + +**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules. + +--- + +## Language-Specific Rules + +| Language | Rule File | +|----------|-----------| +| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) | +| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) | +| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) | +| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` | + +--- + +## Core Concepts + +1. **Reducers are transactional** — they do not return data to callers +2. **Reducers must be deterministic** — no filesystem, network, timers, or random +3. **Read data via tables/subscriptions** — not reducer return values +4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering +5. **`ctx.sender` is the authenticated principal** — never trust identity args + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **don't forget this step!** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Index System + +SpacetimeDB automatically creates indexes for: +- Primary key columns +- Columns marked as unique + +You can add explicit indexes on non-unique columns for query performance. + +**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error. + +**Schema ↔ Code coupling:** +- Your query code references indexes by name +- If you add/remove/rename an index in the schema, update all code that uses it +- Removing an index without updating queries causes runtime errors + +--- + +## Commands + +```bash +# Login to allow remote database deployment e.g. to maincloud +spacetime login + +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear and republish +spacetime publish --clear-database -y --module-path + +# Generate client bindings +spacetime generate --lang --out-dir --module-path + +# View logs +spacetime logs +``` + +--- + +## Deployment + +- Maincloud is the spacetimedb hosted cloud and the default location for module publishing +- The default server marked by *** in `spacetime server list` should be used when publishing +- If the default server is maincloud you should publish to maincloud +- Publishing to maincloud is free of charge +- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ +- The database owner can view utilization and performance metrics on the dashboard + +--- + +## Debugging Checklist + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## Editing Behavior + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo +- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users + + +# SpacetimeDB Rust SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB Rust code: + +### 1. Wrong Crate for Server vs Client + +```rust +// ❌ WRONG — using client crate for server module +use spacetimedb_sdk::*; // This is for CLIENTS only! + +// ✅ CORRECT — use spacetimedb for server modules +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +``` + +### 2. Wrong Table Macro Syntax + +```rust +// ❌ WRONG — using attribute-style like C# +#[spacetimedb::table] +#[primary_key] +pub struct User { ... } + +// ❌ WRONG — SpacetimeType on tables (causes conflicts!) +#[derive(SpacetimeType)] +#[table(accessor = my_table)] +pub struct MyTable { ... } + +// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + name: Option, +} +``` + +### 3. Wrong Table Access Pattern + +```rust +// ❌ WRONG — using ctx.Db or ctx.db() method or field access +ctx.Db.user.Insert(...); +ctx.db().user().insert(...); +ctx.db.player; // Field access + +// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses +ctx.db.user().insert(User { ... }); +ctx.db.user().identity().find(ctx.sender); +ctx.db.player().id().find(&player_id); +``` + +### 4. Wrong Update Pattern + +```rust +// ❌ WRONG — partial update or using .update() directly on table +ctx.db.user().update(User { name: Some("new".into()), ..Default::default() }); + +// ✅ CORRECT — find existing, spread it, update via primary key accessor +if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some("new".into()), ..user }); +} +``` + +### 5. Wrong Reducer Return Type + +```rust +// ❌ WRONG — returning data from reducer +#[reducer] +pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option { ... } + +// ❌ WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } + +// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context +#[reducer] +pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> { + if value.is_empty() { + return Err("Value cannot be empty".to_string()); + } + Ok(()) +} +``` + +### 6. Wrong Client Connection Pattern + +```rust +// ❌ WRONG — subscribing before connected +let conn = DbConnection::builder().build()?; +conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe in on_connect callback +DbConnection::builder() + .on_connect(|conn, identity, token| { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); + }) + .build()?; +``` + +### 7. Forgetting to Advance the Connection + +```rust +// ❌ WRONG — connection never processes messages +let conn = DbConnection::builder().build()?; +// ... callbacks never fire ... + +// ✅ CORRECT — must call one of these to process messages +conn.run_threaded(); // Spawn background thread +// OR +conn.run_async().await; // Async task +// OR (in game loop) +conn.frame_tick()?; // Manual polling +``` + +### 8. Missing Table Trait Import + +```rust +// ❌ WRONG — "no method named `insert` found" +use spacetimedb::{table, reducer, ReducerContext}; +ctx.db.user().insert(...); // ERROR! + +// ✅ CORRECT — import Table trait for table methods +use spacetimedb::{table, reducer, Table, ReducerContext}; +ctx.db.user().insert(...); // Works! +``` + +### 9. Wrong ScheduleAt Variant + +```rust +// ❌ WRONG — At variant doesn't exist +scheduled_at: ScheduleAt::At(future_time), + +// ✅ CORRECT — use Time variant +scheduled_at: ScheduleAt::Time(future_time), +``` + +### 10. Identity to String Conversion + +```rust +// ❌ WRONG — to_hex() returns HexString<32>, not String +let id: String = identity.to_hex(); // Type mismatch! + +// ✅ CORRECT — chain .to_string() +let id: String = identity.to_hex().to_string(); +``` + +### 11. Timestamp Duration Extraction + +```rust +// ❌ WRONG — returns Result, not Duration directly +let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros(); + +// ✅ CORRECT — unwrap the Result +let micros = ctx.timestamp.to_duration_since_unix_epoch() + .unwrap_or_default() + .as_micros(); +``` + +### 12. Borrow After Move + +```rust +// ❌ WRONG — `tool` moved into struct, then borrowed +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if tool == "eraser" { ... } // ERROR: value moved! + +// ✅ CORRECT — check before move, or use clone +let is_eraser = tool == "eraser"; +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if is_eraser { ... } +``` + +### 13. Client SDK Uses Blocking I/O + +The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor. + +### 14. Wrong Schedule Syntax +```rust +// ❌ WRONG — `schedule` is not a valid table type +#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))] + +// ✅ CORRECT — `scheduled` is a valid table type +#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))] +``` +--- + +## 1) Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +--- + +## 2) Table Definition (CRITICAL) + +**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!** + +> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc. + +```rust +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; + +// ❌ WRONG — DO NOT derive SpacetimeType on tables! +#[derive(SpacetimeType)] // REMOVE THIS! +#[table(accessor = task)] +pub struct Task { ... } + +// ✅ CORRECT — just the #[table] attribute +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + + #[unique] + username: Option, + + online: bool, +} + +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + sender: Identity, + text: String, + sent: Timestamp, +} + +// With multi-column index +#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))] +pub struct Task { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner_id: Identity, + pub title: String, +} +``` + +### Table Options + +```rust +#[table(accessor = my_table)] // Private table (default) +#[table(accessor = my_table, public)] // Public table - clients can subscribe +``` + +### Column Attributes + +```rust +#[primary_key] // Primary key (auto-indexed, enables .find()) +#[auto_inc] // Auto-increment (use with #[primary_key]) +#[unique] // Unique constraint (auto-indexed) +#[index(btree)] // B-Tree index for queries +``` + +### Insert returns ROW, not ID + +```rust +let row = ctx.db.task().insert(Task { + id: 0, // auto-inc placeholder + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## 3) Reducers + +### Definition Syntax + +```rust +use spacetimedb::{reducer, ReducerContext, Table}; + +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + // Validate input + if text.is_empty() { + return Err("Message cannot be empty".to_string()); + } + + // Insert returns the inserted row + let row = ctx.db.message().insert(Message { + id: 0, // auto-inc placeholder + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + + log::info!("Message {} sent by {:?}", row.id, ctx.sender); + Ok(()) +} +``` + +### Update Pattern (CRITICAL) + +```rust +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Find existing row + let user = ctx.db.user().identity().find(ctx.sender) + .ok_or("User not found")?; + + // ✅ CORRECT — spread existing row, override specific fields + ctx.db.user().identity().update(User { + name: Some(name), + ..user // Preserves identity, online, etc. + }); + + Ok(()) +} + +// ❌ WRONG — partial update nulls out other fields! +// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() }); +``` + +### Delete Pattern + +```rust +#[reducer] +pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> { + ctx.db.message().id().delete(&message_id); + Ok(()) +} +``` + +### Lifecycle Hooks + +```rust +#[reducer(init)] +pub fn init(ctx: &ReducerContext) { + // Called when module is first published +} + +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + // ctx.sender is the connecting identity + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + username: None, + online: true, + }); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } +} +``` + +### ReducerContext fields + +```rust +ctx.sender // Identity of the caller +ctx.timestamp // Current timestamp +ctx.db // Database access +ctx.rng // Deterministic RNG (use instead of rand) +``` + +--- + +## 4) Index Access + +### Primary Key / Unique — `.find()` returns `Option` + +```rust +// Primary key lookup +let user = ctx.db.user().identity().find(ctx.sender); + +// Unique column lookup +let user = ctx.db.user().username().find(&"alice".to_string()); + +if let Some(user) = user { + // Found +} +``` + +### BTree Index — `.filter()` returns iterator + +```rust +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + #[index(btree)] + room_id: u64, + + text: String, +} + +// Filter by indexed column +for msg in ctx.db.message().room_id().filter(&room_id) { + // Process each message in room +} +``` + +### No Index — `.iter()` + manual filter + +```rust +// Full table scan +for user in ctx.db.user().iter() { + if user.online { + // Process online users + } +} +``` + +--- + +## 5) Custom Types + +**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.** + +```rust +use spacetimedb::SpacetimeType; + +// Custom struct for table fields +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Custom enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), +} + +// Use in table (DO NOT derive SpacetimeType on the table!) +#[table(accessor = player, public)] +pub struct Player { + #[primary_key] + pub id: Identity, + pub position: Position, + pub status: PlayerStatus, +} +``` + +--- + +## 6) Scheduled Tables + +```rust +use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp}; + +#[table(accessor = cleanup_job, scheduled(cleanup_expired))] +pub struct CleanupJob { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + scheduled_at: ScheduleAt, + target_id: u64, +} + +#[reducer] +pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) { + // Job row is auto-deleted after reducer completes + log::info!("Cleaning up: {}", job.target_id); +} + +// Schedule a job +#[reducer] +pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) { + let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms); + ctx.db.cleanup_job().insert(CleanupJob { + scheduled_id: 0, // auto-inc placeholder + scheduled_at: ScheduleAt::Time(future_time), + target_id, + }); +} + +// Cancel by deleting the row +#[reducer] +pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) { + ctx.db.cleanup_job().scheduled_id().delete(&job_id); +} +``` + +--- + +## 7) Client SDK + +```rust +// Connection pattern +let conn = DbConnection::builder() + .with_uri("http://localhost:3000") + .with_module_name("my-module") + .with_token(load_saved_token()) // None for first connection + .on_connect(on_connected) + .build() + .expect("Failed to connect"); + +// Subscribe in on_connect callback, NOT before! +fn on_connected(conn: &DbConnection, identity: Identity, token: &str) { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); +} +``` + +### ⚠️ CRITICAL: Advance the Connection + +**You MUST call one of these** — without it, no callbacks fire: + +```rust +conn.run_threaded(); // Background thread (simplest) +conn.run_async().await; // Async task +conn.frame_tick()?; // Manual polling (game loops) +``` + +### Table Access & Callbacks + +```rust +// Iterate +for user in ctx.db.user().iter() { ... } + +// Find by primary key +if let Some(user) = ctx.db.user().identity().find(&identity) { ... } + +// Row callbacks +ctx.db.user().on_insert(|ctx, user| { ... }); +ctx.db.user().on_update(|ctx, old, new| { ... }); +ctx.db.user().on_delete(|ctx, user| { ... }); + +// Call reducers +ctx.reducers.set_name("Alice".to_string()).unwrap(); +``` + +--- + +## 8) Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +⚠️ Procedures are currently in beta. API may change. + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + +## 9) Logging + +```rust +use spacetimedb::log; + +log::trace!("Detailed trace"); +log::debug!("Debug info"); +log::info!("Information"); +log::warn!("Warning"); +log::error!("Error occurred"); +``` + +--- + +## 10) Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear database and republish +spacetime publish --clear-database -y --module-path + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --module-path + +# View logs +spacetime logs +``` + +--- + +## 11) Hard Requirements + +**Rust-specific:** + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc. +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk` +6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +7. **Use `ctx.rng`** — not `rand` crate for random numbers +8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers +9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()` +10. **Subscribe in `on_connect` callback** — not before connection is established +11. **Update requires full row** — spread existing row with `..existing` +12. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()` +14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes diff --git a/AGENTS.md b/AGENTS.md index d2e357ef..fb66a71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,3 +91,774 @@ docs/ ├─ PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md └─ SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md ``` + +# SpacetimeDB Rules (All Languages) + +## Migrating from 1.0 to 2.0? + +**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules. + +--- + +## Language-Specific Rules + +| Language | Rule File | +|----------|-----------| +| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) | +| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) | +| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) | +| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` | + +--- + +## Core Concepts + +1. **Reducers are transactional** — they do not return data to callers +2. **Reducers must be deterministic** — no filesystem, network, timers, or random +3. **Read data via tables/subscriptions** — not reducer return values +4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering +5. **`ctx.sender` is the authenticated principal** — never trust identity args + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **don't forget this step!** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Index System + +SpacetimeDB automatically creates indexes for: +- Primary key columns +- Columns marked as unique + +You can add explicit indexes on non-unique columns for query performance. + +**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error. + +**Schema ↔ Code coupling:** +- Your query code references indexes by name +- If you add/remove/rename an index in the schema, update all code that uses it +- Removing an index without updating queries causes runtime errors + +--- + +## Commands + +```bash +# Login to allow remote database deployment e.g. to maincloud +spacetime login + +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear and republish +spacetime publish --clear-database -y --module-path + +# Generate client bindings +spacetime generate --lang --out-dir --module-path + +# View logs +spacetime logs +``` + +--- + +## Deployment + +- Maincloud is the spacetimedb hosted cloud and the default location for module publishing +- The default server marked by *** in `spacetime server list` should be used when publishing +- If the default server is maincloud you should publish to maincloud +- Publishing to maincloud is free of charge +- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ +- The database owner can view utilization and performance metrics on the dashboard + +--- + +## Debugging Checklist + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## Editing Behavior + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo +- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users + + +# SpacetimeDB Rust SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB Rust code: + +### 1. Wrong Crate for Server vs Client + +```rust +// ❌ WRONG — using client crate for server module +use spacetimedb_sdk::*; // This is for CLIENTS only! + +// ✅ CORRECT — use spacetimedb for server modules +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +``` + +### 2. Wrong Table Macro Syntax + +```rust +// ❌ WRONG — using attribute-style like C# +#[spacetimedb::table] +#[primary_key] +pub struct User { ... } + +// ❌ WRONG — SpacetimeType on tables (causes conflicts!) +#[derive(SpacetimeType)] +#[table(accessor = my_table)] +pub struct MyTable { ... } + +// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + name: Option, +} +``` + +### 3. Wrong Table Access Pattern + +```rust +// ❌ WRONG — using ctx.Db or ctx.db() method or field access +ctx.Db.user.Insert(...); +ctx.db().user().insert(...); +ctx.db.player; // Field access + +// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses +ctx.db.user().insert(User { ... }); +ctx.db.user().identity().find(ctx.sender); +ctx.db.player().id().find(&player_id); +``` + +### 4. Wrong Update Pattern + +```rust +// ❌ WRONG — partial update or using .update() directly on table +ctx.db.user().update(User { name: Some("new".into()), ..Default::default() }); + +// ✅ CORRECT — find existing, spread it, update via primary key accessor +if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some("new".into()), ..user }); +} +``` + +### 5. Wrong Reducer Return Type + +```rust +// ❌ WRONG — returning data from reducer +#[reducer] +pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option { ... } + +// ❌ WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } + +// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context +#[reducer] +pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> { + if value.is_empty() { + return Err("Value cannot be empty".to_string()); + } + Ok(()) +} +``` + +### 6. Wrong Client Connection Pattern + +```rust +// ❌ WRONG — subscribing before connected +let conn = DbConnection::builder().build()?; +conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe in on_connect callback +DbConnection::builder() + .on_connect(|conn, identity, token| { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); + }) + .build()?; +``` + +### 7. Forgetting to Advance the Connection + +```rust +// ❌ WRONG — connection never processes messages +let conn = DbConnection::builder().build()?; +// ... callbacks never fire ... + +// ✅ CORRECT — must call one of these to process messages +conn.run_threaded(); // Spawn background thread +// OR +conn.run_async().await; // Async task +// OR (in game loop) +conn.frame_tick()?; // Manual polling +``` + +### 8. Missing Table Trait Import + +```rust +// ❌ WRONG — "no method named `insert` found" +use spacetimedb::{table, reducer, ReducerContext}; +ctx.db.user().insert(...); // ERROR! + +// ✅ CORRECT — import Table trait for table methods +use spacetimedb::{table, reducer, Table, ReducerContext}; +ctx.db.user().insert(...); // Works! +``` + +### 9. Wrong ScheduleAt Variant + +```rust +// ❌ WRONG — At variant doesn't exist +scheduled_at: ScheduleAt::At(future_time), + +// ✅ CORRECT — use Time variant +scheduled_at: ScheduleAt::Time(future_time), +``` + +### 10. Identity to String Conversion + +```rust +// ❌ WRONG — to_hex() returns HexString<32>, not String +let id: String = identity.to_hex(); // Type mismatch! + +// ✅ CORRECT — chain .to_string() +let id: String = identity.to_hex().to_string(); +``` + +### 11. Timestamp Duration Extraction + +```rust +// ❌ WRONG — returns Result, not Duration directly +let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros(); + +// ✅ CORRECT — unwrap the Result +let micros = ctx.timestamp.to_duration_since_unix_epoch() + .unwrap_or_default() + .as_micros(); +``` + +### 12. Borrow After Move + +```rust +// ❌ WRONG — `tool` moved into struct, then borrowed +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if tool == "eraser" { ... } // ERROR: value moved! + +// ✅ CORRECT — check before move, or use clone +let is_eraser = tool == "eraser"; +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if is_eraser { ... } +``` + +### 13. Client SDK Uses Blocking I/O + +The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor. + +### 14. Wrong Schedule Syntax +```rust +// ❌ WRONG — `schedule` is not a valid table type +#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))] + +// ✅ CORRECT — `scheduled` is a valid table type +#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))] +``` +--- + +## 1) Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +--- + +## 2) Table Definition (CRITICAL) + +**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!** + +> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc. + +```rust +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; + +// ❌ WRONG — DO NOT derive SpacetimeType on tables! +#[derive(SpacetimeType)] // REMOVE THIS! +#[table(accessor = task)] +pub struct Task { ... } + +// ✅ CORRECT — just the #[table] attribute +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + + #[unique] + username: Option, + + online: bool, +} + +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + sender: Identity, + text: String, + sent: Timestamp, +} + +// With multi-column index +#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))] +pub struct Task { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner_id: Identity, + pub title: String, +} +``` + +### Table Options + +```rust +#[table(accessor = my_table)] // Private table (default) +#[table(accessor = my_table, public)] // Public table - clients can subscribe +``` + +### Column Attributes + +```rust +#[primary_key] // Primary key (auto-indexed, enables .find()) +#[auto_inc] // Auto-increment (use with #[primary_key]) +#[unique] // Unique constraint (auto-indexed) +#[index(btree)] // B-Tree index for queries +``` + +### Insert returns ROW, not ID + +```rust +let row = ctx.db.task().insert(Task { + id: 0, // auto-inc placeholder + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## 3) Reducers + +### Definition Syntax + +```rust +use spacetimedb::{reducer, ReducerContext, Table}; + +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + // Validate input + if text.is_empty() { + return Err("Message cannot be empty".to_string()); + } + + // Insert returns the inserted row + let row = ctx.db.message().insert(Message { + id: 0, // auto-inc placeholder + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + + log::info!("Message {} sent by {:?}", row.id, ctx.sender); + Ok(()) +} +``` + +### Update Pattern (CRITICAL) + +```rust +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Find existing row + let user = ctx.db.user().identity().find(ctx.sender) + .ok_or("User not found")?; + + // ✅ CORRECT — spread existing row, override specific fields + ctx.db.user().identity().update(User { + name: Some(name), + ..user // Preserves identity, online, etc. + }); + + Ok(()) +} + +// ❌ WRONG — partial update nulls out other fields! +// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() }); +``` + +### Delete Pattern + +```rust +#[reducer] +pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> { + ctx.db.message().id().delete(&message_id); + Ok(()) +} +``` + +### Lifecycle Hooks + +```rust +#[reducer(init)] +pub fn init(ctx: &ReducerContext) { + // Called when module is first published +} + +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + // ctx.sender is the connecting identity + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + username: None, + online: true, + }); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } +} +``` + +### ReducerContext fields + +```rust +ctx.sender // Identity of the caller +ctx.timestamp // Current timestamp +ctx.db // Database access +ctx.rng // Deterministic RNG (use instead of rand) +``` + +--- + +## 4) Index Access + +### Primary Key / Unique — `.find()` returns `Option` + +```rust +// Primary key lookup +let user = ctx.db.user().identity().find(ctx.sender); + +// Unique column lookup +let user = ctx.db.user().username().find(&"alice".to_string()); + +if let Some(user) = user { + // Found +} +``` + +### BTree Index — `.filter()` returns iterator + +```rust +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + #[index(btree)] + room_id: u64, + + text: String, +} + +// Filter by indexed column +for msg in ctx.db.message().room_id().filter(&room_id) { + // Process each message in room +} +``` + +### No Index — `.iter()` + manual filter + +```rust +// Full table scan +for user in ctx.db.user().iter() { + if user.online { + // Process online users + } +} +``` + +--- + +## 5) Custom Types + +**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.** + +```rust +use spacetimedb::SpacetimeType; + +// Custom struct for table fields +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Custom enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), +} + +// Use in table (DO NOT derive SpacetimeType on the table!) +#[table(accessor = player, public)] +pub struct Player { + #[primary_key] + pub id: Identity, + pub position: Position, + pub status: PlayerStatus, +} +``` + +--- + +## 6) Scheduled Tables + +```rust +use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp}; + +#[table(accessor = cleanup_job, scheduled(cleanup_expired))] +pub struct CleanupJob { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + scheduled_at: ScheduleAt, + target_id: u64, +} + +#[reducer] +pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) { + // Job row is auto-deleted after reducer completes + log::info!("Cleaning up: {}", job.target_id); +} + +// Schedule a job +#[reducer] +pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) { + let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms); + ctx.db.cleanup_job().insert(CleanupJob { + scheduled_id: 0, // auto-inc placeholder + scheduled_at: ScheduleAt::Time(future_time), + target_id, + }); +} + +// Cancel by deleting the row +#[reducer] +pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) { + ctx.db.cleanup_job().scheduled_id().delete(&job_id); +} +``` + +--- + +## 7) Client SDK + +```rust +// Connection pattern +let conn = DbConnection::builder() + .with_uri("http://localhost:3000") + .with_module_name("my-module") + .with_token(load_saved_token()) // None for first connection + .on_connect(on_connected) + .build() + .expect("Failed to connect"); + +// Subscribe in on_connect callback, NOT before! +fn on_connected(conn: &DbConnection, identity: Identity, token: &str) { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); +} +``` + +### ⚠️ CRITICAL: Advance the Connection + +**You MUST call one of these** — without it, no callbacks fire: + +```rust +conn.run_threaded(); // Background thread (simplest) +conn.run_async().await; // Async task +conn.frame_tick()?; // Manual polling (game loops) +``` + +### Table Access & Callbacks + +```rust +// Iterate +for user in ctx.db.user().iter() { ... } + +// Find by primary key +if let Some(user) = ctx.db.user().identity().find(&identity) { ... } + +// Row callbacks +ctx.db.user().on_insert(|ctx, user| { ... }); +ctx.db.user().on_update(|ctx, old, new| { ... }); +ctx.db.user().on_delete(|ctx, user| { ... }); + +// Call reducers +ctx.reducers.set_name("Alice".to_string()).unwrap(); +``` + +--- + +## 8) Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +⚠️ Procedures are currently in beta. API may change. + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + +## 9) Logging + +```rust +use spacetimedb::log; + +log::trace!("Detailed trace"); +log::debug!("Debug info"); +log::info!("Information"); +log::warn!("Warning"); +log::error!("Error occurred"); +``` + +--- + +## 10) Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear database and republish +spacetime publish --clear-database -y --module-path + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --module-path + +# View logs +spacetime logs +``` + +--- + +## 11) Hard Requirements + +**Rust-specific:** + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc. +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk` +6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +7. **Use `ctx.rng`** — not `rand` crate for random numbers +8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers +9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()` +10. **Subscribe in `on_connect` callback** — not before connection is established +11. **Update requires full row** — spread existing row with `..existing` +12. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()` +14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..aa960fa0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,770 @@ +# SpacetimeDB Rules (All Languages) + +## Migrating from 1.0 to 2.0? + +**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules. + +--- + +## Language-Specific Rules + +| Language | Rule File | +|----------|-----------| +| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) | +| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) | +| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) | +| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` | + +--- + +## Core Concepts + +1. **Reducers are transactional** — they do not return data to callers +2. **Reducers must be deterministic** — no filesystem, network, timers, or random +3. **Read data via tables/subscriptions** — not reducer return values +4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering +5. **`ctx.sender` is the authenticated principal** — never trust identity args + +--- + +## Feature Implementation Checklist + +When implementing a feature that spans backend and client: + +1. **Backend:** Define table(s) to store the data +2. **Backend:** Define reducer(s) to mutate the data +3. **Client:** Subscribe to the table(s) +4. **Client:** Call the reducer(s) from UI — **don't forget this step!** +5. **Client:** Render the data from the table(s) + +**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them. + +--- + +## Index System + +SpacetimeDB automatically creates indexes for: +- Primary key columns +- Columns marked as unique + +You can add explicit indexes on non-unique columns for query performance. + +**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error. + +**Schema ↔ Code coupling:** +- Your query code references indexes by name +- If you add/remove/rename an index in the schema, update all code that uses it +- Removing an index without updating queries causes runtime errors + +--- + +## Commands + +```bash +# Login to allow remote database deployment e.g. to maincloud +spacetime login + +# Start local SpacetimeDB +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear and republish +spacetime publish --clear-database -y --module-path + +# Generate client bindings +spacetime generate --lang --out-dir --module-path + +# View logs +spacetime logs +``` + +--- + +## Deployment + +- Maincloud is the spacetimedb hosted cloud and the default location for module publishing +- The default server marked by *** in `spacetime server list` should be used when publishing +- If the default server is maincloud you should publish to maincloud +- Publishing to maincloud is free of charge +- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@/ +- The database owner can view utilization and performance metrics on the dashboard + +--- + +## Debugging Checklist + +1. Is SpacetimeDB server running? (`spacetime start`) +2. Is the module published? (`spacetime publish`) +3. Are client bindings generated? (`spacetime generate`) +4. Check server logs for errors (`spacetime logs `) +5. **Is the reducer actually being called from the client?** + +--- + +## Editing Behavior + +- Make the smallest change necessary +- Do NOT touch unrelated files, configs, or dependencies +- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo +- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users + + +# SpacetimeDB Rust SDK + +## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS + +These are **actual errors** observed when LLMs generate SpacetimeDB Rust code: + +### 1. Wrong Crate for Server vs Client + +```rust +// ❌ WRONG — using client crate for server module +use spacetimedb_sdk::*; // This is for CLIENTS only! + +// ✅ CORRECT — use spacetimedb for server modules +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; +``` + +### 2. Wrong Table Macro Syntax + +```rust +// ❌ WRONG — using attribute-style like C# +#[spacetimedb::table] +#[primary_key] +pub struct User { ... } + +// ❌ WRONG — SpacetimeType on tables (causes conflicts!) +#[derive(SpacetimeType)] +#[table(accessor = my_table)] +pub struct MyTable { ... } + +// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + name: Option, +} +``` + +### 3. Wrong Table Access Pattern + +```rust +// ❌ WRONG — using ctx.Db or ctx.db() method or field access +ctx.Db.user.Insert(...); +ctx.db().user().insert(...); +ctx.db.player; // Field access + +// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses +ctx.db.user().insert(User { ... }); +ctx.db.user().identity().find(ctx.sender); +ctx.db.player().id().find(&player_id); +``` + +### 4. Wrong Update Pattern + +```rust +// ❌ WRONG — partial update or using .update() directly on table +ctx.db.user().update(User { name: Some("new".into()), ..Default::default() }); + +// ✅ CORRECT — find existing, spread it, update via primary key accessor +if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { name: Some("new".into()), ..user }); +} +``` + +### 5. Wrong Reducer Return Type + +```rust +// ❌ WRONG — returning data from reducer +#[reducer] +pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option { ... } + +// ❌ WRONG — mutable context +pub fn my_reducer(ctx: &mut ReducerContext, ...) { } + +// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context +#[reducer] +pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> { + if value.is_empty() { + return Err("Value cannot be empty".to_string()); + } + Ok(()) +} +``` + +### 6. Wrong Client Connection Pattern + +```rust +// ❌ WRONG — subscribing before connected +let conn = DbConnection::builder().build()?; +conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET! + +// ✅ CORRECT — subscribe in on_connect callback +DbConnection::builder() + .on_connect(|conn, identity, token| { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); + }) + .build()?; +``` + +### 7. Forgetting to Advance the Connection + +```rust +// ❌ WRONG — connection never processes messages +let conn = DbConnection::builder().build()?; +// ... callbacks never fire ... + +// ✅ CORRECT — must call one of these to process messages +conn.run_threaded(); // Spawn background thread +// OR +conn.run_async().await; // Async task +// OR (in game loop) +conn.frame_tick()?; // Manual polling +``` + +### 8. Missing Table Trait Import + +```rust +// ❌ WRONG — "no method named `insert` found" +use spacetimedb::{table, reducer, ReducerContext}; +ctx.db.user().insert(...); // ERROR! + +// ✅ CORRECT — import Table trait for table methods +use spacetimedb::{table, reducer, Table, ReducerContext}; +ctx.db.user().insert(...); // Works! +``` + +### 9. Wrong ScheduleAt Variant + +```rust +// ❌ WRONG — At variant doesn't exist +scheduled_at: ScheduleAt::At(future_time), + +// ✅ CORRECT — use Time variant +scheduled_at: ScheduleAt::Time(future_time), +``` + +### 10. Identity to String Conversion + +```rust +// ❌ WRONG — to_hex() returns HexString<32>, not String +let id: String = identity.to_hex(); // Type mismatch! + +// ✅ CORRECT — chain .to_string() +let id: String = identity.to_hex().to_string(); +``` + +### 11. Timestamp Duration Extraction + +```rust +// ❌ WRONG — returns Result, not Duration directly +let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros(); + +// ✅ CORRECT — unwrap the Result +let micros = ctx.timestamp.to_duration_since_unix_epoch() + .unwrap_or_default() + .as_micros(); +``` + +### 12. Borrow After Move + +```rust +// ❌ WRONG — `tool` moved into struct, then borrowed +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if tool == "eraser" { ... } // ERROR: value moved! + +// ✅ CORRECT — check before move, or use clone +let is_eraser = tool == "eraser"; +ctx.db.stroke().insert(Stroke { tool, color, ... }); +if is_eraser { ... } +``` + +### 13. Client SDK Uses Blocking I/O + +The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor. + +### 14. Wrong Schedule Syntax +```rust +// ❌ WRONG — `schedule` is not a valid table type +#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))] + +// ✅ CORRECT — `scheduled` is a valid table type +#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))] +``` +--- + +## 1) Common Mistakes Table + +### Server-side errors + +| Wrong | Right | Error | +|-------|-------|-------| +| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros | +| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" | +| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index | +| `&mut ReducerContext` | `&ReducerContext` | Wrong context type | +| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" | +| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed | +| Missing `public` on table | Add `public` flag | Clients can't subscribe | +| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path | +| Network/filesystem in reducer | Use procedures instead | Sandbox violation | +| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed | + +--- + +## 2) Table Definition (CRITICAL) + +**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!** + +> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc. + +```rust +use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp}; + +// ❌ WRONG — DO NOT derive SpacetimeType on tables! +#[derive(SpacetimeType)] // REMOVE THIS! +#[table(accessor = task)] +pub struct Task { ... } + +// ✅ CORRECT — just the #[table] attribute +#[table(accessor = user, public)] +pub struct User { + #[primary_key] + identity: Identity, + + #[unique] + username: Option, + + online: bool, +} + +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + sender: Identity, + text: String, + sent: Timestamp, +} + +// With multi-column index +#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))] +pub struct Task { + #[primary_key] + #[auto_inc] + pub id: u64, + pub owner_id: Identity, + pub title: String, +} +``` + +### Table Options + +```rust +#[table(accessor = my_table)] // Private table (default) +#[table(accessor = my_table, public)] // Public table - clients can subscribe +``` + +### Column Attributes + +```rust +#[primary_key] // Primary key (auto-indexed, enables .find()) +#[auto_inc] // Auto-increment (use with #[primary_key]) +#[unique] // Unique constraint (auto-indexed) +#[index(btree)] // B-Tree index for queries +``` + +### Insert returns ROW, not ID + +```rust +let row = ctx.db.task().insert(Task { + id: 0, // auto-inc placeholder + owner_id: ctx.sender, + title: "New task".to_string(), + created_at: ctx.timestamp, +}); +let new_id = row.id; // Get the actual ID +``` + +--- + +## 3) Reducers + +### Definition Syntax + +```rust +use spacetimedb::{reducer, ReducerContext, Table}; + +#[reducer] +pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> { + // Validate input + if text.is_empty() { + return Err("Message cannot be empty".to_string()); + } + + // Insert returns the inserted row + let row = ctx.db.message().insert(Message { + id: 0, // auto-inc placeholder + sender: ctx.sender, + text, + sent: ctx.timestamp, + }); + + log::info!("Message {} sent by {:?}", row.id, ctx.sender); + Ok(()) +} +``` + +### Update Pattern (CRITICAL) + +```rust +#[reducer] +pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> { + // Find existing row + let user = ctx.db.user().identity().find(ctx.sender) + .ok_or("User not found")?; + + // ✅ CORRECT — spread existing row, override specific fields + ctx.db.user().identity().update(User { + name: Some(name), + ..user // Preserves identity, online, etc. + }); + + Ok(()) +} + +// ❌ WRONG — partial update nulls out other fields! +// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() }); +``` + +### Delete Pattern + +```rust +#[reducer] +pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> { + ctx.db.message().id().delete(&message_id); + Ok(()) +} +``` + +### Lifecycle Hooks + +```rust +#[reducer(init)] +pub fn init(ctx: &ReducerContext) { + // Called when module is first published +} + +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + // ctx.sender is the connecting identity + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: true, ..user }); + } else { + ctx.db.user().insert(User { + identity: ctx.sender, + username: None, + online: true, + }); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(ctx: &ReducerContext) { + if let Some(user) = ctx.db.user().identity().find(ctx.sender) { + ctx.db.user().identity().update(User { online: false, ..user }); + } +} +``` + +### ReducerContext fields + +```rust +ctx.sender // Identity of the caller +ctx.timestamp // Current timestamp +ctx.db // Database access +ctx.rng // Deterministic RNG (use instead of rand) +``` + +--- + +## 4) Index Access + +### Primary Key / Unique — `.find()` returns `Option` + +```rust +// Primary key lookup +let user = ctx.db.user().identity().find(ctx.sender); + +// Unique column lookup +let user = ctx.db.user().username().find(&"alice".to_string()); + +if let Some(user) = user { + // Found +} +``` + +### BTree Index — `.filter()` returns iterator + +```rust +#[table(accessor = message, public)] +pub struct Message { + #[primary_key] + #[auto_inc] + id: u64, + + #[index(btree)] + room_id: u64, + + text: String, +} + +// Filter by indexed column +for msg in ctx.db.message().room_id().filter(&room_id) { + // Process each message in room +} +``` + +### No Index — `.iter()` + manual filter + +```rust +// Full table scan +for user in ctx.db.user().iter() { + if user.online { + // Process online users + } +} +``` + +--- + +## 5) Custom Types + +**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.** + +```rust +use spacetimedb::SpacetimeType; + +// Custom struct for table fields +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub struct Position { + pub x: i32, + pub y: i32, +} + +// Custom enum +#[derive(SpacetimeType, Clone, Debug, PartialEq)] +pub enum PlayerStatus { + Idle, + Walking(Position), + Fighting(Identity), +} + +// Use in table (DO NOT derive SpacetimeType on the table!) +#[table(accessor = player, public)] +pub struct Player { + #[primary_key] + pub id: Identity, + pub position: Position, + pub status: PlayerStatus, +} +``` + +--- + +## 6) Scheduled Tables + +```rust +use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp}; + +#[table(accessor = cleanup_job, scheduled(cleanup_expired))] +pub struct CleanupJob { + #[primary_key] + #[auto_inc] + scheduled_id: u64, + + scheduled_at: ScheduleAt, + target_id: u64, +} + +#[reducer] +pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) { + // Job row is auto-deleted after reducer completes + log::info!("Cleaning up: {}", job.target_id); +} + +// Schedule a job +#[reducer] +pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) { + let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms); + ctx.db.cleanup_job().insert(CleanupJob { + scheduled_id: 0, // auto-inc placeholder + scheduled_at: ScheduleAt::Time(future_time), + target_id, + }); +} + +// Cancel by deleting the row +#[reducer] +pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) { + ctx.db.cleanup_job().scheduled_id().delete(&job_id); +} +``` + +--- + +## 7) Client SDK + +```rust +// Connection pattern +let conn = DbConnection::builder() + .with_uri("http://localhost:3000") + .with_module_name("my-module") + .with_token(load_saved_token()) // None for first connection + .on_connect(on_connected) + .build() + .expect("Failed to connect"); + +// Subscribe in on_connect callback, NOT before! +fn on_connected(conn: &DbConnection, identity: Identity, token: &str) { + conn.subscription_builder() + .on_applied(|ctx| println!("Ready!")) + .subscribe_to_all_tables(); +} +``` + +### ⚠️ CRITICAL: Advance the Connection + +**You MUST call one of these** — without it, no callbacks fire: + +```rust +conn.run_threaded(); // Background thread (simplest) +conn.run_async().await; // Async task +conn.frame_tick()?; // Manual polling (game loops) +``` + +### Table Access & Callbacks + +```rust +// Iterate +for user in ctx.db.user().iter() { ... } + +// Find by primary key +if let Some(user) = ctx.db.user().identity().find(&identity) { ... } + +// Row callbacks +ctx.db.user().on_insert(|ctx, user| { ... }); +ctx.db.user().on_update(|ctx, old, new| { ... }); +ctx.db.user().on_delete(|ctx, user| { ... }); + +// Call reducers +ctx.reducers.set_name("Alice".to_string()).unwrap(); +``` + +--- + +## 8) Procedures (Beta) + +**Procedures are for side effects (HTTP, filesystem) that reducers can't do.** + +⚠️ Procedures are currently in beta. API may change. + +```rust +use spacetimedb::{procedure, ProcedureContext}; + +// Simple procedure +#[procedure] +fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 { + a as u64 + b as u64 +} + +// Procedure with database access +#[procedure] +fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> { + // HTTP request (allowed in procedures, not reducers) + let data = fetch_from_url(&url)?; + + // Database access requires explicit transaction + ctx.try_with_tx(|tx| { + tx.db.external_data().insert(ExternalData { + id: 0, + content: data, + }); + Ok(()) + })?; + + Ok(()) +} +``` + +### Key differences from reducers + +| Reducers | Procedures | +|----------|------------| +| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) | +| Direct `ctx.db` access | Must use `ctx.with_tx()` | +| No HTTP/network | HTTP allowed | +| No return values | Can return data | + +--- + +## 9) Logging + +```rust +use spacetimedb::log; + +log::trace!("Detailed trace"); +log::debug!("Debug info"); +log::info!("Information"); +log::warn!("Warning"); +log::error!("Error occurred"); +``` + +--- + +## 10) Commands + +```bash +# Start local server +spacetime start + +# Publish module +spacetime publish --module-path + +# Clear database and republish +spacetime publish --clear-database -y --module-path + +# Generate bindings +spacetime generate --lang rust --out-dir /src/module_bindings --module-path + +# View logs +spacetime logs +``` + +--- + +## 11) Hard Requirements + +**Rust-specific:** + +1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this +2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc. +3. **Use `&ReducerContext`** — not `&mut ReducerContext` +4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table` +5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk` +6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG +7. **Use `ctx.rng`** — not `rand` crate for random numbers +8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers +9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()` +10. **Subscribe in `on_connect` callback** — not before connection is established +11. **Update requires full row** — spread existing row with `..existing` +12. **DO NOT edit generated bindings** — regenerate with `spacetime generate` +13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()` +14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes diff --git a/docs/technical/SPACETIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md b/docs/technical/SPACETIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md new file mode 100644 index 00000000..39e8851b --- /dev/null +++ b/docs/technical/SPACETIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md @@ -0,0 +1,196 @@ +# SpacetimeDB Server-Node 迁移实现说明 + +## 本次落地范围 + +本次已经把 `server-node` 里与运行时存档、用户认证状态、自定义世界资料库相关的数据库层迁移到 Rust SpacetimeDB 模块中,模块代码位于 `spacetimedb/src/`,按职责拆成以下文件: + +- `lib.rs` + - 只保留模块入口和生命周期 reducer。 +- `types.rs` + - 统一放表结构、枚举、view/procedure 的输入输出类型。 +- `config.rs` + - `app_config` 单例表默认值、读取与客户端配置 view。 +- `auth.rs` + - 游客建档、JWT/Identity 建档、短信验证、验证提示事件、踢下线事件、认证审计。 +- `runtime.rs` + - 快照、运行时设置、资料库、自定义世界会话、浏览历史、资料统计与相关 view/procedure。 + +## 当前模块设计 + +### 1. `app_config` 单例表 + +原先散落在 Node `AppConfig` 中、且与认证/验证流程相关的配置,已经迁移到 `app_config` 单例表。 + +当前策略: + +- `init` reducer 会自动插入一行默认配置,主键固定为 `id = 1` +- 本地环境通过 `spacetime sql` 更新这一行 +- 客户端只通过 `client_app_config` view 读取必要的公开配置,不直接读整张表 + +### 2. 默认游客登录 + +连接进入模块时: + +- `client_connected` 会按 `ctx.sender` 自动建档 +- 无 JWT 时默认为 `guest` +- 有 JWT 时使用 SpacetimeDB 自带 `sender_auth().jwt()` 建档,`login_provider` 标记为 `jwt` +- 用户主键按 `user_` 生成,避免再依赖原先 Node 自签 access token / refresh token 流程 + +### 3. 短信验证门禁 + +当前行为已经按你的要求落地: + +- 是否需要短信验证由 `app_config.sms_verification_required` 决定 +- 除短信发送 / 短信校验 procedure 外,其余 runtime procedure 统一走门禁 +- 若用户未完成短信验证: + - 先发 `verification_prompt_event` + - 再发 `kick_event` + - procedure 直接返回 `kicked = true` + +### 4. 登录即弹验证窗 + +`client_connected` 中新增了这条行为: + +- 如果命中“已存在用户,且未完成短信验证” +- 立即发 `verification_prompt_event` +- 前端订阅到事件后即可弹出手机号验证窗口 + +### 5. 客户端同步方式 + +遵循“尽量不公开表”的要求,当前数据同步面以 `view` 为主: + +- `client_app_config` +- `my_auth_state` +- `my_auth_audit_logs` +- `my_user_sessions` +- `my_auth_risk_blocks` +- `my_snapshot` +- `my_runtime_settings` +- `my_profile_dashboard` +- `my_profile_wallet_ledger` +- `my_profile_played_worlds` +- `my_browse_history` +- `my_custom_world_profiles` +- `my_custom_world_sessions` +- `published_custom_world_gallery` + +当前保留为 `public event table` 的只有两类事件: + +- `verification_prompt_event` +- `kick_event` + +这是因为客户端要订阅并即时响应事件,而 event table 不能被 view 读取。 + +### 6. 账户弹窗同步面 + +为了承接客户端账户弹窗,本轮补了: + +- `my_user_sessions` + - 用于读取当前账号关联的会话列表 +- `my_auth_risk_blocks` + - 用于读取当前账号手机号/IP 对应的保护记录 +- `lift_my_risk_block` + - 用于当前账号自助解除手机号或当前连接 IP 的保护 +- `revoke_user_session` + - 用于撤销指定会话,并通过事件通知目标连接断开 +- `logout_all_user_sessions` + - 用于撤销当前账号全部会话,并广播撤销事件 +- `session_revocation_event` + - 用于通知目标连接当前 session 已失效 + +说明: + +- 当前 `session` 已按连接维度追踪,不再只是 identity 级别的审计记录 +- 被撤销的连接在再次调用受保护 procedure 时会被踢下线 +- 前端也会订阅 `session_revocation_event`,在目标 session 被撤销时主动断开当前连接 + +## 本地初始化 + +### 1. 构建 / 发布本地模块 + +```bash +spacetime start +spacetime publish genarrative-local --clear-database -y --module-path ./spacetimedb +``` + +### 2. 初始化单例配置 + +初始化 SQL 已放到: + +- `scripts/spacetime/init_local_app_config.sql` + +执行方式: + +```bash +spacetime sql genarrative-local "$(tr '\n' ' ' < scripts/spacetime/init_local_app_config.sql)" +``` + +如果你不想走命令替换,也可以直接把 SQL 内容整段复制到 `spacetime sql genarrative-local ""` 里执行。 + +## 当前已验证状态 + +已完成: + +- `cargo check` +- `spacetime build` + +说明: + +- 当前模块可以通过 Rust 编译和 Spacetime 模块构建 +- `spacetime.json` 已切到 TypeScript 绑定输出目录 `src/spacetime/generated` +- 前端已开始接入 Spacetime 连接与绑定生成代码 + +## 当前客户端改造 + +本轮已经把前端里最核心的账号与运行时存储链路切到 Spacetime: + +- `src/spacetime/client.ts` + - 统一维护浏览器端 Spacetime 连接、订阅和事件桥。 +- `src/spacetime/mappers.ts` + - 负责把生成绑定里的 view/event 数据映射回现有前端契约类型。 +- `src/services/authService.ts` + - 已从 `/api/auth/*` 切到 Spacetime 连接、view、procedure。 +- `src/services/storageService.ts` + - 已从 `/api/runtime/*` 的存档/设置/资料库接口切到 Spacetime。 +- `src/services/authService.ts` + - 现在也会读取 `my_user_sessions` / `my_auth_risk_blocks`,并调用 `lift_my_risk_block`。 +- `src/components/auth/AuthGate.tsx` + - 已改成默认游客建连,并监听 `verification_prompt_event` / `kick_event`。 +- `src/components/auth/PhoneVerificationModal.tsx` + - 新增短信验证弹窗,收到验证提示事件后直接弹出。 + +### 当前客户端行为 + +- 页面启动后会直接连到 SpacetimeDB,并复用/写回 Spacetime token +- 若账号未完成短信验证: + - 连接阶段收到 `verification_prompt_event` 会弹出验证窗 + - 调用受保护 procedure 后收到 `kick_event` 也会重新弹出验证窗 +- 存档、设置、个人看板、浏览历史、自定义世界资料库与作品广场已经改走 Spacetime +- 账户弹窗里的“当前安全状态 / 会话列表”已经开始读取真实 STDB view +- 账户弹窗里的“移除设备 / 全部退出”已经开始调用真实 STDB procedure +- 作品广场详情为支持客户端恢复完整 profile,新补了 `published_custom_world_profiles` view + +### 当前仍保留的旧链路 + +- `/api/runtime/story/*` 相关故事运行时接口 +- AI / 资源生成相关 Express 路由 +- 账号弹窗中的“解除保护 / 移除设备 / 全部退出”都已经走真实 STDB procedure +- `runtimeStoryService.ts` 这条故事运行时链路仍未迁移,还是当前最大的遗留 + +### 前端环境变量 + +请在本地配置: + +- `VITE_SPACETIME_URI` +- `VITE_SPACETIME_DATABASE_NAME` + +示例已补到 `.env.example`。 + +## 后续建议 + +下一步如果继续推进,建议按这个顺序: + +1. 前端接入 `verification_prompt_event` / `kick_event` +2. 补齐账号弹窗需要的 session / risk block view 与对应操作 procedure +3. 把原 `/api/runtime/story/*` 运行时动作接口继续向 Spacetime 迁移 +4. 再决定是否保留 `server-node` 作为纯 HTTP/AI 代理层 diff --git a/package-lock.json b/package-lock.json index 8ed494ac..dee09dec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "motion": "^12.23.24", "react": "^19.0.0", "react-dom": "^19.0.0", + "spacetimedb": "^2.1.0", "vite": "^6.2.0" }, "devDependencies": { @@ -2289,6 +2290,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -3685,6 +3706,12 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -4761,7 +4788,6 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4831,6 +4857,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -5067,6 +5109,15 @@ } ] }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5264,6 +5315,50 @@ "node": ">=0.10.0" } }, + "node_modules/spacetimedb": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spacetimedb/-/spacetimedb-2.1.0.tgz", + "integrity": "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==", + "license": "ISC", + "dependencies": { + "base64-js": "^1.5.1", + "headers-polyfill": "^4.0.3", + "object-inspect": "^1.13.4", + "prettier": "^3.3.3", + "pure-rand": "^7.0.1", + "safe-stable-stringify": "^2.5.0", + "statuses": "^2.0.2", + "url-polyfill": "^1.1.14" + }, + "peerDependencies": { + "@angular/core": ">=17.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "undici": "^6.19.2", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "undici": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5618,6 +5713,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8704,6 +8805,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "baseline-browser-mapping": { "version": "2.10.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", @@ -9698,6 +9804,11 @@ "function-bind": "^1.1.2" } }, + "headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==" + }, "html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -10387,8 +10498,7 @@ "prettier": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", - "dev": true + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==" }, "pretty-format": { "version": "29.7.0", @@ -10433,6 +10543,11 @@ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true }, + "pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==" + }, "qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -10580,6 +10695,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" }, + "safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" + }, "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -10733,6 +10853,21 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "spacetimedb": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spacetimedb/-/spacetimedb-2.1.0.tgz", + "integrity": "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==", + "requires": { + "base64-js": "^1.5.1", + "headers-polyfill": "^4.0.3", + "object-inspect": "^1.13.4", + "prettier": "^3.3.3", + "pure-rand": "^7.0.1", + "safe-stable-stringify": "^2.5.0", + "statuses": "^2.0.2", + "url-polyfill": "^1.1.14" + } + }, "stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -10977,6 +11112,11 @@ "requires-port": "^1.0.0" } }, + "url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==" + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 42655d8c..f702172b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "motion": "^12.23.24", "react": "^19.0.0", "react-dom": "^19.0.0", + "spacetimedb": "^2.1.0", "vite": "^6.2.0" }, "devDependencies": { diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 6ee28cc6..c141f32e 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -1,5 +1,5 @@ export type AuthBindingStatus = 'active' | 'pending_bind_phone'; -export type AuthLoginMethod = 'password' | 'phone' | 'wechat'; +export type AuthLoginMethod = 'guest' | 'jwt' | 'password' | 'phone' | 'wechat'; export type AuthUser = { id: string; diff --git a/scripts/spacetime/init_local_app_config.sql b/scripts/spacetime/init_local_app_config.sql new file mode 100644 index 00000000..102d6280 --- /dev/null +++ b/scripts/spacetime/init_local_app_config.sql @@ -0,0 +1,50 @@ +UPDATE app_config +SET guest_login_enabled = TRUE, + sms_auth_enabled = TRUE, + sms_verification_required = TRUE, + sms_provider = 'mock', + sms_endpoint = 'dypnsapi.aliyuncs.com', + sms_access_key_id = '', + sms_access_key_secret = '', + sms_sign_name = '', + sms_template_code = '', + sms_template_param_key = 'code', + sms_country_code = '86', + sms_scheme_name = '', + sms_code_length = 6, + sms_code_type = 1, + sms_valid_time_seconds = 300, + sms_interval_seconds = 60, + sms_duplicate_policy = 1, + sms_case_auth_policy = 1, + sms_return_verify_code = FALSE, + sms_mock_verify_code = '123456', + sms_max_send_per_phone_per_day = 20, + sms_max_send_per_ip_per_hour = 30, + sms_max_verify_failures_per_phone_per_hour = 12, + sms_max_verify_failures_per_ip_per_hour = 24, + sms_captcha_ttl_seconds = 180, + sms_captcha_trigger_verify_failures_per_phone = 3, + sms_captcha_trigger_verify_failures_per_ip = 5, + sms_block_phone_failure_threshold = 6, + sms_block_ip_failure_threshold = 10, + sms_block_phone_duration_minutes = 30, + sms_block_ip_duration_minutes = 30, + default_music_volume = 0.42, + default_guest_display_name_prefix = '游客', + kick_message_unverified = '账号尚未完成短信验证,请先完成验证', + wechat_enabled = FALSE, + wechat_provider = 'mock', + wechat_app_id = '', + wechat_app_secret = '', + wechat_authorize_endpoint = 'https://open.weixin.qq.com/connect/qrconnect', + wechat_access_token_endpoint = 'https://api.weixin.qq.com/sns/oauth2/access_token', + wechat_user_info_endpoint = 'https://api.weixin.qq.com/sns/userinfo', + wechat_callback_path = '/api/auth/wechat/callback', + wechat_default_redirect_path = '/', + wechat_mock_user_id = 'mock_wechat_user', + wechat_mock_union_id = 'mock_wechat_union', + wechat_mock_display_name = '微信旅人', + wechat_mock_avatar_url = '', + updated_at_ms = 1744934400000 +WHERE id = 1; diff --git a/spacetime.json b/spacetime.json new file mode 100644 index 00000000..f5bd4b55 --- /dev/null +++ b/spacetime.json @@ -0,0 +1,10 @@ +{ + "server": "maincloud", + "module-path": "./spacetimedb", + "generate": [ + { + "language": "typescript", + "out-dir": "./src/spacetime/generated" + } + ] +} diff --git a/spacetime.local.json b/spacetime.local.json new file mode 100644 index 00000000..3bfb7d04 --- /dev/null +++ b/spacetime.local.json @@ -0,0 +1,3 @@ +{ + "database": "xushi-p4wfr" +} \ No newline at end of file diff --git a/spacetimedb/.gitignore b/spacetimedb/.gitignore new file mode 100644 index 00000000..9f970225 --- /dev/null +++ b/spacetimedb/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/spacetimedb/Cargo.lock b/spacetimedb/Cargo.lock new file mode 100644 index 00000000..00acef3e --- /dev/null +++ b/spacetimedb/Cargo.lock @@ -0,0 +1,996 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake3" +version = "1.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "decorum" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf" +dependencies = [ + "approx", + "num-traits", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ethnum" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +dependencies = [ + "serde", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lean_string" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a262b6ae1dd9c2d3cf7977a816578b03bf8fb60b61545c395880f95eefc5b24" +dependencies = [ + "castaway", + "itoa", + "ryu", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "second-stack" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spacetimedb" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591f9068644aab6808e7612a869dedde7eeb26df78027a19bc9dc597cc649678" +dependencies = [ + "anyhow", + "bytemuck", + "bytes", + "derive_more", + "getrandom 0.2.17", + "http", + "log", + "rand 0.8.6", + "scoped-tls", + "serde_json", + "spacetimedb-bindings-macro", + "spacetimedb-bindings-sys", + "spacetimedb-lib", + "spacetimedb-primitives", + "spacetimedb-query-builder", +] + +[[package]] +name = "spacetimedb-bindings-macro" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f68bf4810d838be622c13efd4cd64e0a9ce8cd340deaa730f0c92caee845f9" +dependencies = [ + "heck 0.4.1", + "humantime", + "proc-macro2", + "quote", + "spacetimedb-primitives", + "syn", +] + +[[package]] +name = "spacetimedb-bindings-sys" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c2fe9f4124a599c9deae8f8231be3ae5a49bc5b2eef5e04c04b2632cf4cc0b4" +dependencies = [ + "spacetimedb-primitives", +] + +[[package]] +name = "spacetimedb-lib" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "672c0dd16feced67155a0dee7bd38d30f7725321c8177cb871a21c3d8749ae97" +dependencies = [ + "anyhow", + "bitflags", + "blake3", + "chrono", + "derive_more", + "enum-as-inner", + "hex", + "itertools", + "log", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "spacetimedb-sats", + "thiserror", +] + +[[package]] +name = "spacetimedb-primitives" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba5d7497d54aa8d4254f78a0bef12606bb05e62f8dea8b69abc9b241508e8b7" +dependencies = [ + "bitflags", + "either", + "enum-as-inner", + "itertools", + "nohash-hasher", +] + +[[package]] +name = "spacetimedb-query-builder" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d04c6e41e05273f14405ac6f429477626677d46528b561a509b7b78b45128f30" +dependencies = [ + "spacetimedb-lib", +] + +[[package]] +name = "spacetimedb-sats" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfde33ec86d80881da8b00c42096bf0382bef8e1bc35e9b6faaa42d77cbf503c" +dependencies = [ + "anyhow", + "arrayvec", + "bitflags", + "bytemuck", + "bytes", + "chrono", + "decorum", + "derive_more", + "enum-as-inner", + "ethnum", + "hex", + "itertools", + "lean_string", + "rand 0.9.4", + "second-stack", + "sha3", + "smallvec", + "spacetimedb-bindings-macro", + "spacetimedb-primitives", + "thiserror", + "uuid", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xushi" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "spacetimedb", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/spacetimedb/Cargo.toml b/spacetimedb/Cargo.toml new file mode 100644 index 00000000..4df82529 --- /dev/null +++ b/spacetimedb/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "xushi" +version = "0.1.0" +edition = "2024" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib"] + +[dependencies] +spacetimedb = { version = "2.1.0", features = ["unstable"] } +log = "0.4" +serde_json = "1.0" diff --git a/spacetimedb/src/auth.rs b/spacetimedb/src/auth.rs new file mode 100644 index 00000000..313e5040 --- /dev/null +++ b/spacetimedb/src/auth.rs @@ -0,0 +1,1174 @@ +use spacetimedb::{procedure, view, ProcedureContext, ReducerContext, Table, TxContext, ViewContext}; + +use crate::common::{ + guest_identity_id, ip_key, jwt_exp_ms, jwt_identity_id, mask_mainland_phone_number, + normalize_client_type, normalize_mainland_china_phone_number, normalize_optional_string, + phone_identity_id, remaining_seconds, request_meta_ip, + request_meta_user_agent, session_id_for_identity_hex, timestamp_ms, + user_id_for_identity_hex, validate_sms_verify_code, MAX_AUTH_AUDIT_LOGS, +}; +use crate::config::{default_app_config, ensure_app_config_row, load_app_config_read_only}; +use crate::types::*; + +pub(crate) struct UserProvisioning { + pub user: User, + pub config: AppConfig, + pub existed: bool, +} + +fn current_session_id_for_reducer(ctx: &ReducerContext) -> String { + let session_key = ctx + .connection_id() + .map(|id| id.to_hex().to_string()) + .unwrap_or_else(|| ctx.sender().to_hex().to_string()); + session_id_for_identity_hex(&session_key) +} + +fn current_session_id_for_tx(tx: &TxContext) -> String { + let session_key = tx + .connection_id() + .map(|id| id.to_hex().to_string()) + .unwrap_or_else(|| tx.sender().to_hex().to_string()); + session_id_for_identity_hex(&session_key) +} + +#[view(accessor = my_auth_state, public)] +pub fn my_auth_state_view(ctx: &ViewContext) -> Option { + let config = load_app_config_read_only().unwrap_or_else(|| default_app_config(0)); + find_user_by_identity_read_only(ctx.sender()).map(|user| to_auth_state_view(&config, &user)) +} + +#[view(accessor = my_auth_audit_logs, public)] +pub fn my_auth_audit_logs_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .auth_audit_log() + .user_id() + .filter(&user.id) + .map(|row| AuthAuditLogView { + id: row.id, + event_type: row.event_type, + detail: row.detail, + ip: row.ip, + user_agent: row.user_agent, + created_at_ms: row.created_at_ms, + }) + .collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.created_at_ms)); + rows.truncate(MAX_AUTH_AUDIT_LOGS); + rows +} + +#[view(accessor = my_user_sessions, public)] +pub fn my_user_sessions_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .user_session() + .user_id() + .filter(&user.id) + .filter(|row| row.revoked_at_ms.is_none()) + .map(|row| AuthSessionView { + session_id: row.id.clone(), + client_type: row.client_type, + user_agent: row.user_agent, + ip: row.ip, + is_current: false, + created_at_ms: row.created_at_ms, + last_seen_at_ms: row.last_seen_at_ms, + expires_at_ms: row.expires_at_ms, + }) + .collect(); + rows.sort_by_key(|row| std::cmp::Reverse(row.last_seen_at_ms)); + rows +} + +#[view(accessor = my_auth_risk_blocks, public)] +pub fn my_auth_risk_blocks_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let known_ips: std::collections::BTreeSet<_> = ctx + .db + .user_session() + .user_id() + .filter(&user.id) + .filter_map(|row| row.ip) + .collect(); + + let phone_key = user.phone_number.clone(); + + let mut rows: Vec<_> = ctx + .db + .auth_risk_block() + .scope_type() + .filter(&RiskBlockScopeType::Phone) + .filter(|row| row.lifted_at_ms.is_none()) + .filter(|row| { + phone_key + .as_ref() + .map(|value| value == &row.scope_key) + .unwrap_or(false) + }) + .map(|row| AuthRiskBlockView { + scope_type: row.scope_type, + scope_key: row.scope_key, + reason: row.reason, + expires_at_ms: row.expires_at_ms, + }) + .collect(); + rows.extend( + ctx.db + .auth_risk_block() + .scope_type() + .filter(&RiskBlockScopeType::Ip) + .filter(|row| row.lifted_at_ms.is_none()) + .filter(|row| known_ips.contains(&row.scope_key)) + .map(|row| AuthRiskBlockView { + scope_type: row.scope_type, + scope_key: row.scope_key, + reason: row.reason, + expires_at_ms: row.expires_at_ms, + }), + ); + rows.sort_by_key(|row| std::cmp::Reverse(row.expires_at_ms)); + rows +} + +#[procedure] +pub fn lift_my_risk_block( + ctx: &mut ProcedureContext, + meta: RequestMeta, + scope_type: RiskBlockScopeType, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "lift_my_risk_block") { + Ok(user) => { + let scope_key = match scope_type { + RiskBlockScopeType::Phone => match user.phone_number.clone() { + Some(value) => value, + None => { + return MutationResult::error( + "risk_scope_unavailable", + "当前账号没有可解除的手机号保护记录", + ) + } + }, + RiskBlockScopeType::Ip => { + let current_session_id = + session_id_for_identity_hex(&tx.sender().to_hex().to_string()); + match tx + .db + .user_session() + .id() + .find(¤t_session_id) + .and_then(|row| row.ip) + { + Some(value) => value, + None => { + return MutationResult::error( + "risk_scope_unavailable", + "当前连接没有可解除的 IP 保护记录", + ) + } + } + } + }; + + let now_ms = timestamp_ms(tx.timestamp); + let target_ids: Vec<_> = tx + .db + .auth_risk_block() + .scope_type() + .filter(&scope_type) + .filter(|row| row.scope_key == scope_key) + .filter(|row| row.lifted_at_ms.is_none()) + .filter(|row| row.expires_at_ms > now_ms) + .map(|row| row.id) + .collect(); + + if target_ids.is_empty() { + return MutationResult::ok("risk_block_absent", "当前没有生效中的保护记录"); + } + + for id in target_ids { + if let Some(existing) = tx.db.auth_risk_block().id().find(&id) { + tx.db.auth_risk_block().id().update(AuthRiskBlock { + lifted_at_ms: Some(now_ms), + updated_at_ms: now_ms, + ..existing + }); + } + } + + create_auth_audit_log( + tx, + &user.id, + match scope_type { + RiskBlockScopeType::Phone => "risk_unblock_phone", + RiskBlockScopeType::Ip => "risk_unblock_ip", + }, + "已手动解除风险保护", + &meta, + None, + ); + MutationResult::ok("risk_block_lifted", "风险保护已解除") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn revoke_user_session( + ctx: &mut ProcedureContext, + meta: RequestMeta, + session_id: String, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "revoke_user_session") { + Ok(user) => { + let normalized_session_id = normalize_optional_string(Some(&session_id)); + let Some(normalized_session_id) = normalized_session_id else { + return MutationResult::error("invalid_session_id", "sessionId 不能为空"); + }; + let Some(existing) = tx.db.user_session().id().find(&normalized_session_id) else { + return MutationResult::error("session_not_found", "未找到目标会话"); + }; + if existing.user_id != user.id { + return MutationResult::error("session_forbidden", "不能操作其他账号的会话"); + } + let now_ms = timestamp_ms(tx.timestamp); + tx.db.user_session().id().update(UserSession { + revoked_at_ms: Some(now_ms), + updated_at_ms: now_ms, + ..existing + }); + emit_session_revocation_event( + tx, + &normalized_session_id, + "session_revoked", + "当前会话已被移除", + ); + create_auth_audit_log( + tx, + &user.id, + "revoke_session", + &format!("已移除会话 {}", normalized_session_id), + &meta, + None, + ); + MutationResult::ok("session_revoked", "会话已移除") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn logout_all_user_sessions(ctx: &mut ProcedureContext, meta: RequestMeta) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "logout_all_user_sessions") { + Ok(user) => { + let now_ms = timestamp_ms(tx.timestamp); + let target_ids: Vec<_> = tx + .db + .user_session() + .user_id() + .filter(&user.id) + .filter(|row| row.revoked_at_ms.is_none()) + .map(|row| row.id) + .collect(); + + for session_id in &target_ids { + if let Some(existing) = tx.db.user_session().id().find(session_id) { + tx.db.user_session().id().update(UserSession { + revoked_at_ms: Some(now_ms), + updated_at_ms: now_ms, + ..existing + }); + } + emit_session_revocation_event( + tx, + session_id, + "logout_all", + "当前账号已从全部会话退出", + ); + } + + create_auth_audit_log( + tx, + &user.id, + "logout_all", + "已注销全部会话", + &meta, + None, + ); + MutationResult::ok("logout_all_completed", "全部会话已注销") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn send_sms_verification_code( + ctx: &mut ProcedureContext, + meta: RequestMeta, + phone_number: String, + scene: SmsAuthScene, +) -> SmsSendCodeResult { + ctx.with_tx(|tx| { + let provisioned = provision_user_with_meta(tx, Some(&meta)); + let config = provisioned.config; + if !config.sms_auth_enabled { + return SmsSendCodeResult::error("sms_disabled", "短信验证能力未启用"); + } + + let normalized_phone = match normalize_mainland_china_phone_number(&phone_number) { + Ok(phone) => phone, + Err(message) => return SmsSendCodeResult::error("invalid_phone", &message), + }; + let now_ms = timestamp_ms(tx.timestamp); + let current_ip_key = ip_key(meta.ip.as_ref()); + + if let Some(block) = + find_active_risk_block(tx, RiskBlockScopeType::Phone, &normalized_phone.e164, now_ms) + { + return SmsSendCodeResult::error( + "phone_blocked", + &format!( + "手机号已被风控拦截,请稍后再试(剩余 {} 秒)", + remaining_seconds(now_ms, block.expires_at_ms) + ), + ); + } + if !current_ip_key.is_empty() { + if let Some(block) = + find_active_risk_block(tx, RiskBlockScopeType::Ip, ¤t_ip_key, now_ms) + { + return SmsSendCodeResult::error( + "ip_blocked", + &format!( + "当前 IP 已被风控拦截,请稍后再试(剩余 {} 秒)", + remaining_seconds(now_ms, block.expires_at_ms) + ), + ); + } + } + + let phone_send_count = count_sms_events_by_phone( + tx, + &normalized_phone.e164, + SmsAuthAction::SendCode, + None, + now_ms.saturating_sub(86_400_000), + ); + if phone_send_count >= usize::from(config.sms_max_send_per_phone_per_day) { + return SmsSendCodeResult::error("phone_send_limit", "手机号今日发送次数已达上限"); + } + + let ip_send_count = count_sms_events_by_ip( + tx, + ¤t_ip_key, + SmsAuthAction::SendCode, + None, + now_ms.saturating_sub(3_600_000), + ); + if ip_send_count >= usize::from(config.sms_max_send_per_ip_per_hour) { + return SmsSendCodeResult::error("ip_send_limit", "当前 IP 一小时内发送次数已达上限"); + } + + if config.sms_provider != "mock" { + record_sms_auth_event( + tx, + &meta, + &normalized_phone.e164, + scene.clone(), + SmsAuthAction::SendCode, + false, + ); + return SmsSendCodeResult::error( + "sms_provider_not_implemented", + "当前 Rust Spacetime 模块仅落地了 mock 短信验证流程", + ); + } + + record_sms_auth_event( + tx, + &meta, + &normalized_phone.e164, + scene.clone(), + SmsAuthAction::SendCode, + true, + ); + SmsSendCodeResult::ok( + "sms_code_sent", + "验证码已发送(mock)", + config.sms_interval_seconds, + config.sms_valid_time_seconds, + ) + }) +} + +#[procedure] +pub fn verify_sms_code( + ctx: &mut ProcedureContext, + meta: RequestMeta, + phone_number: String, + code: String, +) -> SmsVerifyCodeResult { + ctx.with_tx(|tx| { + let provisioned = provision_user_with_meta(tx, Some(&meta)); + let config = provisioned.config; + let mut user = provisioned.user; + + if !config.sms_auth_enabled { + return SmsVerifyCodeResult::error("sms_disabled", "短信验证能力未启用"); + } + + let normalized_phone = match normalize_mainland_china_phone_number(&phone_number) { + Ok(phone) => phone, + Err(message) => return SmsVerifyCodeResult::error("invalid_phone", &message), + }; + let normalized_code = match validate_sms_verify_code(&code) { + Ok(value) => value, + Err(message) => return SmsVerifyCodeResult::error("invalid_code", &message), + }; + let now_ms = timestamp_ms(tx.timestamp); + let current_ip_key = ip_key(meta.ip.as_ref()); + + if let Some(block) = + find_active_risk_block(tx, RiskBlockScopeType::Phone, &normalized_phone.e164, now_ms) + { + return SmsVerifyCodeResult::error( + "phone_blocked", + &format!( + "手机号已被风控拦截,请稍后再试(剩余 {} 秒)", + remaining_seconds(now_ms, block.expires_at_ms) + ), + ); + } + if !current_ip_key.is_empty() { + if let Some(block) = + find_active_risk_block(tx, RiskBlockScopeType::Ip, ¤t_ip_key, now_ms) + { + return SmsVerifyCodeResult::error( + "ip_blocked", + &format!( + "当前 IP 已被风控拦截,请稍后再试(剩余 {} 秒)", + remaining_seconds(now_ms, block.expires_at_ms) + ), + ); + } + } + + if config.sms_provider != "mock" { + record_sms_auth_event( + tx, + &meta, + &normalized_phone.e164, + SmsAuthScene::Login, + SmsAuthAction::VerifyCode, + false, + ); + return SmsVerifyCodeResult::error( + "sms_provider_not_implemented", + "当前 Rust Spacetime 模块仅落地了 mock 短信验证流程", + ); + } + + if normalized_code != config.sms_mock_verify_code.trim() { + record_sms_auth_event( + tx, + &meta, + &normalized_phone.e164, + SmsAuthScene::Login, + SmsAuthAction::VerifyCode, + false, + ); + + let phone_failure_count = count_sms_events_by_phone( + tx, + &normalized_phone.e164, + SmsAuthAction::VerifyCode, + Some(false), + now_ms.saturating_sub(3_600_000), + ); + if phone_failure_count >= usize::from(config.sms_block_phone_failure_threshold) { + create_or_refresh_risk_block( + tx, + RiskBlockScopeType::Phone, + &normalized_phone.e164, + "sms_verify_failed_too_many_times", + now_ms + u64::from(config.sms_block_phone_duration_minutes) * 60_000, + ); + } + + let ip_failure_count = count_sms_events_by_ip( + tx, + ¤t_ip_key, + SmsAuthAction::VerifyCode, + Some(false), + now_ms.saturating_sub(3_600_000), + ); + if !current_ip_key.is_empty() + && ip_failure_count >= usize::from(config.sms_block_ip_failure_threshold) + { + create_or_refresh_risk_block( + tx, + RiskBlockScopeType::Ip, + ¤t_ip_key, + "sms_verify_failed_too_many_times", + now_ms + u64::from(config.sms_block_ip_duration_minutes) * 60_000, + ); + } + + create_auth_audit_log( + tx, + &user.id, + "sms_verify_failed", + "短信验证码校验失败", + &meta, + None, + ); + return SmsVerifyCodeResult::error("sms_code_invalid", "验证码错误"); + } + + if let Some(existing_user) = find_user_by_phone_number(tx, &normalized_phone.e164) { + if existing_user.id != user.id { + return SmsVerifyCodeResult::error("phone_already_bound", "手机号已绑定其他账号"); + } + } + + record_sms_auth_event( + tx, + &meta, + &normalized_phone.e164, + SmsAuthScene::Login, + SmsAuthAction::VerifyCode, + true, + ); + user.phone_number = Some(normalized_phone.e164.clone()); + user.phone_verified_at_ms = Some(now_ms); + user.account_status = AccountStatus::Active; + if user.login_provider == LoginProvider::Guest { + user.login_provider = LoginProvider::Phone; + } + user.updated_at_ms = now_ms; + tx.db.user().id().update(user.clone()); + + ensure_phone_identity(tx, &user, &normalized_phone.e164); + create_auth_audit_log(tx, &user.id, "sms_verified", "短信验证已通过", &meta, None); + SmsVerifyCodeResult::ok("短信验证通过") + }) +} + +pub fn provision_user(ctx: &ReducerContext) -> UserProvisioning { + let config = ctx + .db + .app_config() + .id() + .find(&crate::common::APP_CONFIG_SINGLETON_ID) + .unwrap_or_else(|| default_app_config(timestamp_ms(ctx.timestamp))); + let now_ms = timestamp_ms(ctx.timestamp); + let sender = ctx.sender(); + let sender_hex = sender.to_hex().to_string(); + if let Some(existing) = ctx.db.user().identity().find(sender) { + let updated = reconcile_user_row( + existing, + &config, + now_ms, + resolve_login_provider_from_auth(ctx.sender_auth().has_jwt()), + build_guest_display_name(&config, &sender_hex), + ); + ctx.db.user().id().update(updated.clone()); + ensure_sender_identity(ctx, &updated); + upsert_user_session(ctx, &updated, None); + return UserProvisioning { + user: updated, + config, + existed: true, + }; + } + + let row = User { + id: user_id_for_identity_hex(&sender_hex), + identity: sender, + username: None, + password_hash: None, + token_version: 1, + display_name: build_guest_display_name(&config, &sender_hex), + login_provider: resolve_login_provider_from_auth(ctx.sender_auth().has_jwt()), + account_status: if config.sms_verification_required { + AccountStatus::PendingSmsVerification + } else { + AccountStatus::Active + }, + phone_number: None, + phone_verified_at_ms: None, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }; + let user = ctx.db.user().insert(row); + ensure_sender_identity(ctx, &user); + upsert_user_session(ctx, &user, None); + UserProvisioning { + user, + config, + existed: false, + } +} + +pub fn provision_user_with_meta(tx: &TxContext, meta: Option<&RequestMeta>) -> UserProvisioning { + let config = ensure_app_config_row(tx); + let now_ms = timestamp_ms(tx.timestamp); + let sender = tx.sender(); + let sender_hex = sender.to_hex().to_string(); + if let Some(existing) = tx.db.user().identity().find(sender) { + let updated = reconcile_user_row( + existing, + &config, + now_ms, + resolve_login_provider_from_auth(tx.sender_auth().has_jwt()), + build_guest_display_name(&config, &sender_hex), + ); + tx.db.user().id().update(updated.clone()); + ensure_sender_identity_tx(tx, &updated); + upsert_user_session_tx(tx, &updated, meta); + return UserProvisioning { + user: updated, + config, + existed: true, + }; + } + + let row = User { + id: user_id_for_identity_hex(&sender_hex), + identity: sender, + username: None, + password_hash: None, + token_version: 1, + display_name: build_guest_display_name(&config, &sender_hex), + login_provider: resolve_login_provider_from_auth(tx.sender_auth().has_jwt()), + account_status: if config.sms_verification_required { + AccountStatus::PendingSmsVerification + } else { + AccountStatus::Active + }, + phone_number: None, + phone_verified_at_ms: None, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }; + let user = tx.db.user().insert(row); + ensure_sender_identity_tx(tx, &user); + upsert_user_session_tx(tx, &user, meta); + UserProvisioning { + user, + config, + existed: false, + } +} + +pub fn guard_user_action( + tx: &TxContext, + meta: &RequestMeta, + operation_name: &str, +) -> Result { + let provisioned = provision_user_with_meta(tx, Some(meta)); + let current_session_id = current_session_id_for_tx(tx); + if let Some(current_session) = tx.db.user_session().id().find(¤t_session_id) { + if current_session.revoked_at_ms.is_some() { + emit_session_revocation_event( + tx, + ¤t_session_id, + "session_revoked", + "当前会话已失效,请重新建立连接", + ); + emit_kick_event(tx, "session_revoked", "当前会话已失效,请重新连接"); + return Err(MutationResult::kicked( + "session_revoked", + "当前会话已失效,请重新连接", + )); + } + } + if provisioned.user.account_status == AccountStatus::Disabled { + emit_kick_event(tx, "account_disabled", "账号已被禁用"); + create_auth_audit_log( + tx, + &provisioned.user.id, + "account_disabled", + &format!("调用 {} 时命中禁用账号", operation_name), + meta, + None, + ); + return Err(MutationResult::kicked("account_disabled", "账号已被禁用")); + } + if needs_sms_verification(&provisioned.config, &provisioned.user) { + emit_verification_prompt_tx(tx, &provisioned.user, "请先完成短信验证"); + emit_kick_event( + tx, + "sms_verification_required", + &provisioned.config.kick_message_unverified, + ); + create_auth_audit_log( + tx, + &provisioned.user.id, + "sms_verification_required", + &format!("调用 {} 时命中短信验证门禁", operation_name), + meta, + None, + ); + return Err(MutationResult::kicked( + "sms_verification_required", + &provisioned.config.kick_message_unverified, + )); + } + Ok(provisioned.user) +} + +pub fn emit_verification_prompt(ctx: &ReducerContext, user: &User, detail: &str) { + ctx.db + .verification_prompt_event() + .insert(VerificationPromptEvent { + target_identity: ctx.sender(), + phone_number_masked: user + .phone_number + .as_ref() + .and_then(|value| mask_mainland_phone_number(value)), + title: "需要完成短信验证".to_string(), + detail: detail.to_string(), + issued_at_ms: timestamp_ms(ctx.timestamp), + }); +} + +pub fn needs_sms_verification(config: &AppConfig, user: &User) -> bool { + config.sms_verification_required && user.phone_verified_at_ms.is_none() +} + +pub fn find_user_by_identity_read_only(identity: spacetimedb::Identity) -> Option { + ViewContext::new(identity).db.user().identity().find(identity) +} + +fn to_auth_state_view(config: &AppConfig, user: &User) -> AuthStateView { + AuthStateView { + user_id: user.id.clone(), + identity: user.identity, + display_name: user.display_name.clone(), + phone_number_masked: user + .phone_number + .as_ref() + .and_then(|value| mask_mainland_phone_number(value)), + login_provider: user.login_provider.clone(), + account_status: user.account_status.clone(), + sms_verification_required: config.sms_verification_required, + sms_verified: user.phone_verified_at_ms.is_some(), + jwt_present: user.login_provider == LoginProvider::Jwt, + } +} + +fn resolve_login_provider_from_auth(has_jwt: bool) -> LoginProvider { + if has_jwt { + LoginProvider::Jwt + } else { + LoginProvider::Guest + } +} + +fn build_guest_display_name(config: &AppConfig, sender_hex: &str) -> String { + let suffix = sender_hex.get(..6).unwrap_or(sender_hex); + format!("{}{}", config.default_guest_display_name_prefix, suffix) +} + +fn reconcile_user_row( + mut user: User, + config: &AppConfig, + now_ms: u64, + login_provider: LoginProvider, + fallback_display_name: String, +) -> User { + if user.display_name.trim().is_empty() { + user.display_name = fallback_display_name; + } + if user.account_status != AccountStatus::Disabled { + user.account_status = if config.sms_verification_required && user.phone_verified_at_ms.is_none() + { + AccountStatus::PendingSmsVerification + } else { + AccountStatus::Active + }; + } + if user.phone_verified_at_ms.is_some() && login_provider == LoginProvider::Guest { + user.login_provider = LoginProvider::Phone; + } else { + user.login_provider = login_provider; + } + user.updated_at_ms = now_ms; + user +} + +fn ensure_sender_identity(ctx: &ReducerContext, user: &User) { + let now_ms = timestamp_ms(ctx.timestamp); + let sender_hex = ctx.sender().to_hex().to_string(); + if let Some(jwt) = ctx.sender_auth().jwt() { + let identity_id = jwt_identity_id(&sender_hex); + let next_row = AuthIdentity { + id: identity_id.clone(), + user_id: user.id.clone(), + provider: AuthIdentityProvider::Jwt, + provider_uid: jwt.subject().to_string(), + provider_union_id: None, + display_name: Some(user.display_name.clone()), + avatar_url: None, + is_verified: true, + meta_json: Some(jwt.raw_payload().to_string()), + created_at_ms: ctx + .db + .auth_identity() + .id() + .find(&identity_id) + .map(|row| row.created_at_ms) + .unwrap_or(now_ms), + updated_at_ms: now_ms, + }; + upsert_auth_identity_reducer(ctx, next_row); + } else { + let identity_id = guest_identity_id(&sender_hex); + let next_row = AuthIdentity { + id: identity_id.clone(), + user_id: user.id.clone(), + provider: AuthIdentityProvider::Guest, + provider_uid: sender_hex, + provider_union_id: None, + display_name: Some(user.display_name.clone()), + avatar_url: None, + is_verified: true, + meta_json: None, + created_at_ms: ctx + .db + .auth_identity() + .id() + .find(&identity_id) + .map(|row| row.created_at_ms) + .unwrap_or(now_ms), + updated_at_ms: now_ms, + }; + upsert_auth_identity_reducer(ctx, next_row); + } +} + +fn ensure_sender_identity_tx(tx: &TxContext, user: &User) { + let now_ms = timestamp_ms(tx.timestamp); + let sender_hex = tx.sender().to_hex().to_string(); + if let Some(jwt) = tx.sender_auth().jwt() { + let identity_id = jwt_identity_id(&sender_hex); + let next_row = AuthIdentity { + id: identity_id.clone(), + user_id: user.id.clone(), + provider: AuthIdentityProvider::Jwt, + provider_uid: jwt.subject().to_string(), + provider_union_id: None, + display_name: Some(user.display_name.clone()), + avatar_url: None, + is_verified: true, + meta_json: Some(jwt.raw_payload().to_string()), + created_at_ms: tx + .db + .auth_identity() + .id() + .find(&identity_id) + .map(|row| row.created_at_ms) + .unwrap_or(now_ms), + updated_at_ms: now_ms, + }; + upsert_auth_identity_tx(tx, next_row); + } else { + let identity_id = guest_identity_id(&sender_hex); + let next_row = AuthIdentity { + id: identity_id.clone(), + user_id: user.id.clone(), + provider: AuthIdentityProvider::Guest, + provider_uid: sender_hex, + provider_union_id: None, + display_name: Some(user.display_name.clone()), + avatar_url: None, + is_verified: true, + meta_json: None, + created_at_ms: tx + .db + .auth_identity() + .id() + .find(&identity_id) + .map(|row| row.created_at_ms) + .unwrap_or(now_ms), + updated_at_ms: now_ms, + }; + upsert_auth_identity_tx(tx, next_row); + } +} + +fn ensure_phone_identity(tx: &TxContext, user: &User, phone_number: &str) { + let now_ms = timestamp_ms(tx.timestamp); + let identity_id = phone_identity_id(phone_number); + let row = AuthIdentity { + id: identity_id.clone(), + user_id: user.id.clone(), + provider: AuthIdentityProvider::Phone, + provider_uid: phone_number.to_string(), + provider_union_id: None, + display_name: Some(user.display_name.clone()), + avatar_url: None, + is_verified: true, + meta_json: None, + created_at_ms: tx + .db + .auth_identity() + .id() + .find(&identity_id) + .map(|item| item.created_at_ms) + .unwrap_or(now_ms), + updated_at_ms: now_ms, + }; + upsert_auth_identity_tx(tx, row); +} + +fn upsert_auth_identity_reducer(ctx: &ReducerContext, row: AuthIdentity) { + if ctx.db.auth_identity().id().find(&row.id).is_some() { + ctx.db.auth_identity().id().update(row); + } else { + ctx.db.auth_identity().insert(row); + } +} + +fn upsert_auth_identity_tx(tx: &TxContext, row: AuthIdentity) { + if tx.db.auth_identity().id().find(&row.id).is_some() { + tx.db.auth_identity().id().update(row); + } else { + tx.db.auth_identity().insert(row); + } +} + +fn upsert_user_session(ctx: &ReducerContext, user: &User, meta: Option<&RequestMeta>) { + let now_ms = timestamp_ms(ctx.timestamp); + let sender_hex = ctx.sender().to_hex().to_string(); + let session_id = current_session_id_for_reducer(ctx); + let existing = ctx.db.user_session().id().find(&session_id); + let row = UserSession { + id: session_id.clone(), + user_id: user.id.clone(), + refresh_token_hash: sender_hex, + client_type: normalize_client_type(meta.map(|value| value.client_type.as_str())), + user_agent: meta.and_then(|value| normalize_optional_string(value.user_agent.as_deref())), + ip: meta.and_then(|value| normalize_optional_string(value.ip.as_deref())), + expires_at_ms: ctx.sender_auth().jwt().and_then(jwt_exp_ms), + revoked_at_ms: existing.as_ref().and_then(|row| row.revoked_at_ms), + created_at_ms: existing.as_ref().map(|row| row.created_at_ms).unwrap_or(now_ms), + updated_at_ms: now_ms, + last_seen_at_ms: now_ms, + }; + if existing.is_some() { + ctx.db.user_session().id().update(row); + } else { + ctx.db.user_session().insert(row); + } +} + +fn upsert_user_session_tx(tx: &TxContext, user: &User, meta: Option<&RequestMeta>) { + let now_ms = timestamp_ms(tx.timestamp); + let sender_hex = tx.sender().to_hex().to_string(); + let session_id = current_session_id_for_tx(tx); + let existing = tx.db.user_session().id().find(&session_id); + let row = UserSession { + id: session_id.clone(), + user_id: user.id.clone(), + refresh_token_hash: sender_hex, + client_type: normalize_client_type(meta.map(|value| value.client_type.as_str())), + user_agent: meta.and_then(|value| normalize_optional_string(value.user_agent.as_deref())), + ip: meta.and_then(|value| normalize_optional_string(value.ip.as_deref())), + expires_at_ms: tx.sender_auth().jwt().and_then(jwt_exp_ms), + revoked_at_ms: existing.as_ref().and_then(|row| row.revoked_at_ms), + created_at_ms: existing.as_ref().map(|row| row.created_at_ms).unwrap_or(now_ms), + updated_at_ms: now_ms, + last_seen_at_ms: now_ms, + }; + if existing.is_some() { + tx.db.user_session().id().update(row); + } else { + tx.db.user_session().insert(row); + } +} + +fn emit_verification_prompt_tx(tx: &TxContext, user: &User, detail: &str) { + tx.db + .verification_prompt_event() + .insert(VerificationPromptEvent { + target_identity: tx.sender(), + phone_number_masked: user + .phone_number + .as_ref() + .and_then(|value| mask_mainland_phone_number(value)), + title: "需要完成短信验证".to_string(), + detail: detail.to_string(), + issued_at_ms: timestamp_ms(tx.timestamp), + }); +} + +fn emit_kick_event(tx: &TxContext, reason_code: &str, message: &str) { + tx.db.kick_event().insert(KickEvent { + target_identity: tx.sender(), + reason_code: reason_code.to_string(), + message: message.to_string(), + issued_at_ms: timestamp_ms(tx.timestamp), + }); +} + +fn emit_session_revocation_event( + tx: &TxContext, + target_session_id: &str, + reason_code: &str, + message: &str, +) { + tx.db + .session_revocation_event() + .insert(SessionRevocationEventRow { + target_session_id: target_session_id.to_string(), + reason_code: reason_code.to_string(), + message: message.to_string(), + issued_at_ms: timestamp_ms(tx.timestamp), + }); +} + +fn create_auth_audit_log( + tx: &TxContext, + user_id: &str, + event_type: &str, + detail: &str, + meta: &RequestMeta, + meta_json: Option, +) { + tx.db.auth_audit_log().insert(AuthAuditLog { + id: 0, + user_id: user_id.to_string(), + event_type: event_type.to_string(), + detail: detail.to_string(), + ip: request_meta_ip(meta), + user_agent: request_meta_user_agent(meta), + meta_json, + created_at_ms: timestamp_ms(tx.timestamp), + }); +} + +fn record_sms_auth_event( + tx: &TxContext, + meta: &RequestMeta, + phone_number: &str, + scene: SmsAuthScene, + action: SmsAuthAction, + success: bool, +) { + let ip = request_meta_ip(meta); + tx.db.sms_auth_event().insert(SmsAuthEvent { + id: 0, + identity: tx.sender(), + phone_number: phone_number.to_string(), + scene, + action, + success, + ip_key: ip_key(ip.as_ref()), + ip, + user_agent: request_meta_user_agent(meta), + created_at_ms: timestamp_ms(tx.timestamp), + }); +} + +fn count_sms_events_by_phone( + tx: &TxContext, + phone_number: &str, + action: SmsAuthAction, + success: Option, + since_ms: u64, +) -> usize { + tx.db + .sms_auth_event() + .phone_number() + .filter(&phone_number.to_string()) + .filter(|row| row.action == action) + .filter(|row| row.created_at_ms >= since_ms) + .filter(|row| success.map(|value| row.success == value).unwrap_or(true)) + .count() +} + +fn count_sms_events_by_ip( + tx: &TxContext, + current_ip_key: &str, + action: SmsAuthAction, + success: Option, + since_ms: u64, +) -> usize { + if current_ip_key.is_empty() { + return 0; + } + tx.db + .sms_auth_event() + .ip_key() + .filter(¤t_ip_key.to_string()) + .filter(|row| row.action == action) + .filter(|row| row.created_at_ms >= since_ms) + .filter(|row| success.map(|value| row.success == value).unwrap_or(true)) + .count() +} + +fn find_active_risk_block( + tx: &TxContext, + scope_type: RiskBlockScopeType, + scope_key: &str, + now_ms: u64, +) -> Option { + tx.db + .auth_risk_block() + .scope_type() + .filter(&scope_type) + .filter(|row| row.scope_key == scope_key) + .filter(|row| row.lifted_at_ms.is_none()) + .filter(|row| row.expires_at_ms > now_ms) + .max_by_key(|row| row.expires_at_ms) +} + +fn create_or_refresh_risk_block( + tx: &TxContext, + scope_type: RiskBlockScopeType, + scope_key: &str, + reason: &str, + expires_at_ms: u64, +) { + let now_ms = timestamp_ms(tx.timestamp); + if let Some(existing) = find_active_risk_block(tx, scope_type.clone(), scope_key, now_ms) { + tx.db.auth_risk_block().id().update(crate::types::AuthRiskBlock { + reason: reason.to_string(), + expires_at_ms, + updated_at_ms: now_ms, + ..existing + }); + return; + } + + tx.db + .auth_risk_block() + .insert(crate::types::AuthRiskBlock { + id: 0, + scope_type, + scope_key: scope_key.to_string(), + reason: reason.to_string(), + expires_at_ms, + lifted_at_ms: None, + created_at_ms: now_ms, + updated_at_ms: now_ms, + }); +} + +fn find_user_by_phone_number(tx: &TxContext, phone_number: &str) -> Option { + tx.db + .user() + .iter() + .find(|row| row.phone_number.as_deref() == Some(phone_number)) +} diff --git a/spacetimedb/src/common.rs b/spacetimedb/src/common.rs new file mode 100644 index 00000000..0c287ea6 --- /dev/null +++ b/spacetimedb/src/common.rs @@ -0,0 +1,302 @@ +use std::cmp::Reverse; + +use serde_json::Value; +use spacetimedb::{JwtClaims, Timestamp}; + +use crate::types::{PlatformBrowseHistoryWriteInput, RequestMeta}; + +pub const APP_CONFIG_SINGLETON_ID: u8 = 1; +pub const SAVE_SNAPSHOT_VERSION: u32 = 2; +pub const MAX_CUSTOM_WORLD_PROFILES: usize = 12; +pub const MAX_PUBLIC_CUSTOM_WORLD_PROFILES: usize = 36; +pub const MAX_WALLET_LEDGER_ENTRIES: usize = 50; +pub const MAX_AUTH_AUDIT_LOGS: usize = 20; + +pub fn timestamp_ms(timestamp: Timestamp) -> u64 { + let millis = timestamp + .to_duration_since_unix_epoch() + .unwrap_or_default() + .as_millis(); + u64::try_from(millis).unwrap_or(u64::MAX) +} + +pub fn jwt_exp_ms(jwt: &JwtClaims) -> Option { + let value = parse_json(jwt.raw_payload())?; + let exp = value.get("exp")?; + let numeric = exp + .as_u64() + .or_else(|| exp.as_i64().and_then(|item| u64::try_from(item).ok()))?; + if numeric > 1_000_000_000_000 { + Some(numeric) + } else { + Some(numeric.saturating_mul(1_000)) + } +} + +pub fn normalize_required_string(value: &str) -> String { + value.trim().to_string() +} + +pub fn normalize_optional_string(value: Option<&str>) -> Option { + value + .map(normalize_required_string) + .filter(|row| !row.is_empty()) +} + +pub fn normalize_client_type(value: Option<&str>) -> String { + normalize_optional_string(value) + .filter(|row| !row.is_empty()) + .unwrap_or_else(|| "spacetime_http".to_string()) +} + +pub fn normalize_saved_at_ms(saved_at_ms: u64, timestamp: Timestamp) -> u64 { + if saved_at_ms == 0 { + timestamp_ms(timestamp) + } else { + saved_at_ms + } +} + +pub fn user_id_for_identity_hex(identity_hex: &str) -> String { + format!("user_{identity_hex}") +} + +pub fn session_id_for_identity_hex(identity_hex: &str) -> String { + format!("usess_{identity_hex}") +} + +pub fn jwt_identity_id(identity_hex: &str) -> String { + format!("authi_jwt_{identity_hex}") +} + +pub fn guest_identity_id(identity_hex: &str) -> String { + format!("authi_guest_{identity_hex}") +} + +pub fn phone_identity_id(phone_number: &str) -> String { + format!("authi_phone_{}", phone_number.replace('+', "")) +} + +pub fn custom_world_profile_key(user_id: &str, profile_id: &str) -> String { + format!("{user_id}:{profile_id}") +} + +pub fn custom_world_session_key(user_id: &str, session_id: &str) -> String { + format!("{user_id}:{session_id}") +} + +pub fn profile_wallet_ledger_id(user_id: &str, source_key: &str) -> String { + format!("ledger:{user_id}:{source_key}") +} + +pub fn profile_played_world_key(user_id: &str, world_key: &str) -> String { + format!("played:{user_id}:{world_key}") +} + +pub fn browse_history_key(user_id: &str, owner_user_id: &str, profile_id: &str) -> String { + format!("browse:{user_id}:{owner_user_id}:{profile_id}") +} + +pub fn ip_key(ip: Option<&String>) -> String { + ip.map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_default() +} + +pub fn remaining_seconds(now_ms: u64, expires_at_ms: u64) -> u64 { + expires_at_ms.saturating_sub(now_ms) / 1_000 +} + +pub struct NormalizedPhoneNumber { + pub e164: String, +} + +pub fn normalize_mainland_china_phone_number( + phone_input: &str, +) -> Result { + let stripped: String = phone_input + .chars() + .filter(|ch| ch.is_ascii_digit() || *ch == '+') + .collect(); + if stripped.trim().is_empty() { + return Err("请输入手机号".to_string()); + } + + let mut national_number = stripped.trim().to_string(); + if national_number.starts_with("+86") { + national_number = national_number[3..].to_string(); + } else if national_number.starts_with("86") && national_number.len() == 13 { + national_number = national_number[2..].to_string(); + } + + let is_valid = national_number.len() == 11 + && national_number.starts_with('1') + && national_number.chars().all(|ch| ch.is_ascii_digit()); + if !is_valid { + return Err("请输入正确的中国大陆手机号".to_string()); + } + + Ok(NormalizedPhoneNumber { + e164: format!("+86{national_number}"), + }) +} + +pub fn mask_mainland_phone_number(phone_number: &str) -> Option { + let normalized = normalize_mainland_china_phone_number(phone_number).ok()?; + let national_number = normalized.e164.trim_start_matches("+86"); + if national_number.len() < 7 { + return Some(national_number.to_string()); + } + Some(format!( + "{}****{}", + &national_number[..3], + &national_number[national_number.len().saturating_sub(4)..] + )) +} + +pub fn validate_sms_verify_code(code: &str) -> Result { + let normalized = code.trim().to_string(); + let is_valid = (4..=8).contains(&normalized.len()) + && normalized.chars().all(|ch| ch.is_ascii_alphanumeric()); + if !is_valid { + return Err("请输入正确的验证码".to_string()); + } + Ok(normalized) +} + +pub fn parse_json(input: &str) -> Option { + serde_json::from_str(input).ok() +} + +pub fn is_valid_json(input: &str) -> bool { + parse_json(input).is_some() +} + +pub fn read_i64_field(value: &Value, key: &str) -> i64 { + value + .get(key) + .and_then(Value::as_i64) + .or_else(|| { + value.get(key) + .and_then(Value::as_u64) + .and_then(|item| i64::try_from(item).ok()) + }) + .or_else(|| { + value.get(key) + .and_then(Value::as_str) + .and_then(|item| item.parse::().ok()) + }) + .unwrap_or(0) +} + +pub fn read_nested_u64(value: &Value, path: &[&str]) -> u64 { + let mut cursor = value; + for key in path { + let Some(next) = cursor.get(*key) else { + return 0; + }; + cursor = next; + } + cursor + .as_u64() + .or_else(|| cursor.as_i64().and_then(|item| u64::try_from(item).ok())) + .or_else(|| cursor.as_str().and_then(|item| item.parse::().ok())) + .unwrap_or(0) +} + +pub fn read_json_string_from_object( + object: &serde_json::Map, + key: &str, +) -> Option { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|row| !row.is_empty()) + .map(str::to_string) +} + +pub fn builtin_world_title(world_type: &str) -> String { + match world_type { + "WUXIA" => "武侠世界".to_string(), + "XIANXIA" => "仙侠世界".to_string(), + _ => "叙事世界".to_string(), + } +} + +pub fn contains_any(source: &str, keywords: &[&str]) -> bool { + keywords.iter().any(|keyword| source.contains(keyword)) +} + +pub fn resolve_author_display_name(candidate: &str, fallback: &str) -> String { + let normalized = normalize_required_string(candidate); + if normalized.is_empty() { + normalize_optional_string(Some(fallback)).unwrap_or_else(|| "玩家".to_string()) + } else { + normalized + } +} + +pub fn sort_desc_by_key(rows: &mut [T], mut key: impl FnMut(&T) -> K) { + rows.sort_by_key(|row| Reverse(key(row))); +} + +pub fn dedupe_browse_history_entries( + entries: Vec, + now_ms: u64, +) -> Vec { + let mut normalized_entries = entries + .into_iter() + .filter_map(|entry| normalize_browse_history_entry(entry, now_ms)) + .collect::>(); + normalized_entries.sort_by_key(|entry| Reverse(entry.visited_at_ms)); + + let mut deduped = Vec::new(); + let mut seen_keys = std::collections::BTreeSet::new(); + for entry in normalized_entries { + let key = format!("{}:{}", entry.owner_user_id, entry.profile_id); + if seen_keys.insert(key) { + deduped.push(entry); + } + } + deduped +} + +fn normalize_browse_history_entry( + entry: PlatformBrowseHistoryWriteInput, + now_ms: u64, +) -> Option { + let owner_user_id = normalize_required_string(&entry.owner_user_id); + let profile_id = normalize_required_string(&entry.profile_id); + let world_name = normalize_required_string(&entry.world_name); + if owner_user_id.is_empty() || profile_id.is_empty() || world_name.is_empty() { + return None; + } + + Some(PlatformBrowseHistoryWriteInput { + owner_user_id, + profile_id, + world_name, + subtitle: normalize_required_string(&entry.subtitle), + summary_text: normalize_required_string(&entry.summary_text), + cover_image_src: entry + .cover_image_src + .and_then(|value| normalize_optional_string(Some(&value))), + theme_mode: entry.theme_mode, + author_display_name: normalize_optional_string(Some(&entry.author_display_name)) + .unwrap_or_else(|| "玩家".to_string()), + visited_at_ms: if entry.visited_at_ms == 0 { + now_ms + } else { + entry.visited_at_ms + }, + }) +} + +pub fn request_meta_ip(meta: &RequestMeta) -> Option { + normalize_optional_string(meta.ip.as_deref()) +} + +pub fn request_meta_user_agent(meta: &RequestMeta) -> Option { + normalize_optional_string(meta.user_agent.as_deref()) +} diff --git a/spacetimedb/src/config.rs b/spacetimedb/src/config.rs new file mode 100644 index 00000000..87de1297 --- /dev/null +++ b/spacetimedb/src/config.rs @@ -0,0 +1,107 @@ +use spacetimedb::{view, AnonymousViewContext, ReducerContext, Table, TxContext}; + +use crate::common::{timestamp_ms, APP_CONFIG_SINGLETON_ID}; +use crate::types::*; + +pub fn default_app_config(now_ms: u64) -> AppConfig { + AppConfig { + id: APP_CONFIG_SINGLETON_ID, + guest_login_enabled: true, + sms_auth_enabled: false, + sms_verification_required: false, + sms_provider: "mock".to_string(), + sms_endpoint: "dypnsapi.aliyuncs.com".to_string(), + sms_access_key_id: String::new(), + sms_access_key_secret: String::new(), + sms_sign_name: String::new(), + sms_template_code: String::new(), + sms_template_param_key: "code".to_string(), + sms_country_code: "86".to_string(), + sms_scheme_name: String::new(), + sms_code_length: 6, + sms_code_type: 1, + sms_valid_time_seconds: 300, + sms_interval_seconds: 60, + sms_duplicate_policy: 1, + sms_case_auth_policy: 1, + sms_return_verify_code: false, + sms_mock_verify_code: "123456".to_string(), + sms_max_send_per_phone_per_day: 20, + sms_max_send_per_ip_per_hour: 30, + sms_max_verify_failures_per_phone_per_hour: 12, + sms_max_verify_failures_per_ip_per_hour: 24, + sms_captcha_ttl_seconds: 180, + sms_captcha_trigger_verify_failures_per_phone: 3, + sms_captcha_trigger_verify_failures_per_ip: 5, + sms_block_phone_failure_threshold: 6, + sms_block_ip_failure_threshold: 10, + sms_block_phone_duration_minutes: 30, + sms_block_ip_duration_minutes: 30, + default_music_volume: 0.42, + default_guest_display_name_prefix: "游客".to_string(), + kick_message_unverified: "账号尚未完成短信验证,请先完成验证".to_string(), + wechat_enabled: false, + wechat_provider: "mock".to_string(), + wechat_app_id: String::new(), + wechat_app_secret: String::new(), + wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(), + wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token".to_string(), + wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(), + wechat_callback_path: "/api/auth/wechat/callback".to_string(), + wechat_default_redirect_path: "/".to_string(), + wechat_mock_user_id: "mock_wechat_user".to_string(), + wechat_mock_union_id: "mock_wechat_union".to_string(), + wechat_mock_display_name: "微信旅人".to_string(), + wechat_mock_avatar_url: String::new(), + updated_at_ms: now_ms, + } +} + +pub fn ensure_default_app_config(ctx: &ReducerContext) { + if ctx.db.app_config().id().find(&APP_CONFIG_SINGLETON_ID).is_none() { + ctx.db + .app_config() + .insert(default_app_config(timestamp_ms(ctx.timestamp))); + } +} + +pub fn ensure_app_config_row(tx: &TxContext) -> AppConfig { + tx.db + .app_config() + .id() + .find(&APP_CONFIG_SINGLETON_ID) + .unwrap_or_else(|| { + let config = default_app_config(timestamp_ms(tx.timestamp)); + tx.db.app_config().insert(config.clone()); + config + }) +} + +pub fn load_app_config_read_only() -> Option { + AnonymousViewContext::default() + .db + .app_config() + .id() + .find(&APP_CONFIG_SINGLETON_ID) +} + +pub fn to_client_app_config_view(config: &AppConfig) -> ClientAppConfigView { + ClientAppConfigView { + guest_login_enabled: config.guest_login_enabled, + sms_auth_enabled: config.sms_auth_enabled, + sms_verification_required: config.sms_verification_required, + sms_provider: config.sms_provider.clone(), + sms_code_length: config.sms_code_length, + sms_valid_time_seconds: config.sms_valid_time_seconds, + sms_interval_seconds: config.sms_interval_seconds, + default_music_volume: config.default_music_volume, + default_guest_display_name_prefix: config.default_guest_display_name_prefix.clone(), + wechat_enabled: config.wechat_enabled, + } +} + +#[view(accessor = client_app_config, public)] +pub fn client_app_config_view(_ctx: &AnonymousViewContext) -> Option { + let config = load_app_config_read_only().unwrap_or_else(|| default_app_config(0)); + Some(to_client_app_config_view(&config)) +} diff --git a/spacetimedb/src/lib.rs b/spacetimedb/src/lib.rs new file mode 100644 index 00000000..e06abc82 --- /dev/null +++ b/spacetimedb/src/lib.rs @@ -0,0 +1,26 @@ +mod auth; +mod common; +mod config; +mod runtime; +mod types; + +use spacetimedb::{reducer, ReducerContext}; + +#[reducer(init)] +pub fn init(ctx: &ReducerContext) { + config::ensure_default_app_config(ctx); + log::info!("genarrative stdb init completed"); +} + +#[reducer(client_connected)] +pub fn client_connected(ctx: &ReducerContext) { + config::ensure_default_app_config(ctx); + let provisioned = auth::provision_user(ctx); + if provisioned.existed && auth::needs_sms_verification(&provisioned.config, &provisioned.user) + { + auth::emit_verification_prompt(ctx, &provisioned.user, "账号尚未完成短信验证,请先验证手机号"); + } +} + +#[reducer(client_disconnected)] +pub fn client_disconnected(_ctx: &ReducerContext) {} diff --git a/spacetimedb/src/runtime.rs b/spacetimedb/src/runtime.rs new file mode 100644 index 00000000..ca76efae --- /dev/null +++ b/spacetimedb/src/runtime.rs @@ -0,0 +1,1034 @@ +use serde_json::Value; +use spacetimedb::{ + procedure, view, AnonymousViewContext, ProcedureContext, Table, TxContext, ViewContext, +}; + +use crate::auth::{find_user_by_identity_read_only, guard_user_action}; +use crate::common::{ + browse_history_key, builtin_world_title, contains_any, custom_world_profile_key, + custom_world_session_key, dedupe_browse_history_entries, is_valid_json, normalize_required_string, + normalize_saved_at_ms, parse_json, profile_played_world_key, profile_wallet_ledger_id, + read_i64_field, read_json_string_from_object, read_nested_u64, resolve_author_display_name, + sort_desc_by_key, timestamp_ms, MAX_CUSTOM_WORLD_PROFILES, MAX_PUBLIC_CUSTOM_WORLD_PROFILES, + MAX_WALLET_LEDGER_ENTRIES, SAVE_SNAPSHOT_VERSION, +}; +use crate::config::{default_app_config, load_app_config_read_only}; +use crate::types::*; + +#[view(accessor = my_snapshot, public)] +pub fn my_snapshot_view(ctx: &ViewContext) -> Option { + let user = find_user_by_identity_read_only(ctx.sender())?; + ctx.db + .saved_snapshot_row() + .user_id() + .find(&user.id) + .map(|snapshot| SnapshotView { + version: snapshot.version, + saved_at_ms: snapshot.saved_at_ms, + game_state_json: snapshot.game_state_json, + bottom_tab: snapshot.bottom_tab, + current_story_json: snapshot.current_story_json, + }) +} + +#[view(accessor = my_runtime_settings, public)] +pub fn my_runtime_settings_view(ctx: &ViewContext) -> Option { + let user = find_user_by_identity_read_only(ctx.sender())?; + let config = load_app_config_read_only().unwrap_or_else(|| default_app_config(0)); + let music_volume = ctx + .db + .runtime_setting() + .user_id() + .find(&user.id) + .map(|row| row.music_volume) + .unwrap_or(config.default_music_volume); + Some(RuntimeSettingsView { music_volume }) +} + +#[view(accessor = my_profile_dashboard, public)] +pub fn my_profile_dashboard_view(ctx: &ViewContext) -> Option { + let user = find_user_by_identity_read_only(ctx.sender())?; + let state = ctx.db.profile_dashboard_state().user_id().find(&user.id); + let played_world_count = ctx + .db + .profile_played_world() + .user_id() + .filter(&user.id) + .count() as u32; + Some(ProfileDashboardView { + wallet_balance: state.as_ref().map(|row| row.wallet_balance).unwrap_or(0), + total_play_time_ms: state.as_ref().map(|row| row.total_play_time_ms).unwrap_or(0), + played_world_count, + updated_at_ms: state.map(|row| row.updated_at_ms), + }) +} + +#[view(accessor = my_profile_wallet_ledger, public)] +pub fn my_profile_wallet_ledger_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .profile_wallet_ledger() + .user_id() + .filter(&user.id) + .map(to_profile_wallet_ledger_view) + .collect(); + sort_desc_by_key(&mut rows, |row| row.created_at_ms); + rows.truncate(MAX_WALLET_LEDGER_ENTRIES); + rows +} + +#[view(accessor = my_profile_played_worlds, public)] +pub fn my_profile_played_worlds_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .profile_played_world() + .user_id() + .filter(&user.id) + .map(to_profile_played_world_view) + .collect(); + sort_desc_by_key(&mut rows, |row| row.last_played_at_ms); + rows +} + +#[view(accessor = my_browse_history, public)] +pub fn my_browse_history_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .user_browse_history() + .user_id() + .filter(&user.id) + .map(to_platform_browse_history_view) + .collect(); + sort_desc_by_key(&mut rows, |row| row.visited_at_ms); + rows +} + +#[view(accessor = my_custom_world_profiles, public)] +pub fn my_custom_world_profiles_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .custom_world_profile() + .user_id() + .filter(&user.id) + .filter(|row| row.deleted_at_ms.is_none()) + .map(to_custom_world_profile_view) + .collect(); + sort_desc_by_key(&mut rows, |row| row.updated_at_ms); + rows.truncate(MAX_CUSTOM_WORLD_PROFILES); + rows +} + +#[view(accessor = published_custom_world_gallery, public)] +pub fn published_custom_world_gallery_view( + ctx: &AnonymousViewContext, +) -> Vec { + let mut rows: Vec<_> = ctx + .db + .custom_world_profile() + .visibility() + .filter(&CustomWorldPublicationStatus::Published) + .filter(|row| row.deleted_at_ms.is_none()) + .map(to_custom_world_gallery_card_view) + .collect(); + rows.sort_by(|left, right| { + right + .published_at_ms + .unwrap_or_default() + .cmp(&left.published_at_ms.unwrap_or_default()) + .then(right.updated_at_ms.cmp(&left.updated_at_ms)) + }); + rows.truncate(MAX_PUBLIC_CUSTOM_WORLD_PROFILES); + rows +} + +#[view(accessor = published_custom_world_profiles, public)] +pub fn published_custom_world_profiles_view( + ctx: &AnonymousViewContext, +) -> Vec { + let mut rows: Vec<_> = ctx + .db + .custom_world_profile() + .visibility() + .filter(&CustomWorldPublicationStatus::Published) + .filter(|row| row.deleted_at_ms.is_none()) + .map(|row| PublishedCustomWorldProfileView { + owner_user_id: row.user_id, + profile_id: row.profile_id, + payload_json: row.payload_json, + visibility: row.visibility, + published_at_ms: row.published_at_ms, + updated_at_ms: row.updated_at_ms, + author_display_name: row.author_display_name, + world_name: row.world_name, + subtitle: row.subtitle, + summary_text: row.summary_text, + cover_image_src: row.cover_image_src, + theme_mode: row.theme_mode, + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + }) + .collect(); + rows.sort_by(|left, right| { + right + .published_at_ms + .unwrap_or_default() + .cmp(&left.published_at_ms.unwrap_or_default()) + .then(right.updated_at_ms.cmp(&left.updated_at_ms)) + }); + rows.truncate(MAX_PUBLIC_CUSTOM_WORLD_PROFILES); + rows +} + +#[view(accessor = my_custom_world_sessions, public)] +pub fn my_custom_world_sessions_view(ctx: &ViewContext) -> Vec { + let Some(user) = find_user_by_identity_read_only(ctx.sender()) else { + return Vec::new(); + }; + let mut rows: Vec<_> = ctx + .db + .custom_world_session() + .user_id() + .filter(&user.id) + .map(to_custom_world_session_view) + .collect(); + sort_desc_by_key(&mut rows, |row| row.updated_at_ms); + rows +} + +#[procedure] +pub fn save_snapshot( + ctx: &mut ProcedureContext, + meta: RequestMeta, + saved_at_ms: u64, + game_state_json: String, + bottom_tab: String, + current_story_json: Option, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "save_snapshot") { + Ok(user) => save_snapshot_impl(tx, &user, saved_at_ms, game_state_json.clone(), bottom_tab.clone(), current_story_json.clone()), + Err(result) => result, + }) +} + +#[procedure] +pub fn delete_snapshot(ctx: &mut ProcedureContext, meta: RequestMeta) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "delete_snapshot") { + Ok(user) => { + tx.db.saved_snapshot_row().user_id().delete(&user.id); + MutationResult::ok("snapshot_deleted", "运行时快照已删除") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn put_runtime_settings( + ctx: &mut ProcedureContext, + meta: RequestMeta, + music_volume: f32, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "put_runtime_settings") { + Ok(user) => { + let config = crate::config::ensure_app_config_row(tx); + let row = RuntimeSetting { + user_id: user.id, + music_volume: if music_volume.is_finite() { + music_volume.clamp(0.0, 1.0) + } else { + config.default_music_volume + }, + updated_at_ms: timestamp_ms(tx.timestamp), + }; + upsert_runtime_setting(tx, row); + MutationResult::ok("runtime_settings_updated", "运行时设置已更新") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn upsert_platform_browse_history( + ctx: &mut ProcedureContext, + meta: RequestMeta, + entries: Vec, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "upsert_platform_browse_history") { + Ok(user) => { + let normalized_entries = + dedupe_browse_history_entries(entries.clone(), timestamp_ms(tx.timestamp)); + for entry in normalized_entries { + let row = UserBrowseHistory { + id: browse_history_key(&user.id, &entry.owner_user_id, &entry.profile_id), + user_id: user.id.clone(), + owner_user_id: entry.owner_user_id, + profile_id: entry.profile_id, + world_name: entry.world_name, + subtitle: entry.subtitle, + summary_text: entry.summary_text, + cover_image_src: entry.cover_image_src, + theme_mode: entry.theme_mode, + author_display_name: entry.author_display_name, + visited_at_ms: entry.visited_at_ms, + }; + upsert_browse_history(tx, row); + } + MutationResult::ok("browse_history_synced", "浏览历史已同步") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn clear_platform_browse_history( + ctx: &mut ProcedureContext, + meta: RequestMeta, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "clear_platform_browse_history") { + Ok(user) => { + let keys: Vec<_> = tx + .db + .user_browse_history() + .user_id() + .filter(&user.id) + .map(|row| row.id) + .collect(); + for key in keys { + tx.db.user_browse_history().id().delete(&key); + } + MutationResult::ok("browse_history_cleared", "浏览历史已清空") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn upsert_custom_world_profile( + ctx: &mut ProcedureContext, + meta: RequestMeta, + profile_id: String, + payload_json: String, + author_display_name: String, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "upsert_custom_world_profile") { + Ok(user) => upsert_custom_world_profile_impl( + tx, + &user, + profile_id.clone(), + payload_json.clone(), + author_display_name.clone(), + ), + Err(result) => result, + }) +} + +#[procedure] +pub fn delete_custom_world_profile( + ctx: &mut ProcedureContext, + meta: RequestMeta, + profile_id: String, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "delete_custom_world_profile") { + Ok(user) => { + let normalized_profile_id = normalize_required_string(&profile_id); + let key = custom_world_profile_key(&user.id, &normalized_profile_id); + let Some(existing) = tx.db.custom_world_profile().id().find(&key) else { + return MutationResult::error("custom_world_not_found", "未找到目标自定义世界档案"); + }; + let now_ms = timestamp_ms(tx.timestamp); + tx.db.custom_world_profile().id().update(CustomWorldProfile { + deleted_at_ms: Some(now_ms), + updated_at_ms: now_ms, + visibility: CustomWorldPublicationStatus::Draft, + published_at_ms: None, + ..existing + }); + MutationResult::ok("custom_world_profile_deleted", "自定义世界档案已软删除") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn upsert_custom_world_session( + ctx: &mut ProcedureContext, + meta: RequestMeta, + session_id: String, + payload_json: String, + created_at_ms: u64, + updated_at_ms: u64, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "upsert_custom_world_session") { + Ok(user) => { + let normalized_session_id = normalize_required_string(&session_id); + if normalized_session_id.is_empty() { + return MutationResult::error("invalid_session_id", "sessionId 不能为空"); + } + if !is_valid_json(&payload_json) { + return MutationResult::error("invalid_session_json", "会话数据 JSON 不合法"); + } + let row = CustomWorldSession { + id: custom_world_session_key(&user.id, &normalized_session_id), + user_id: user.id, + session_id: normalized_session_id, + payload_json: payload_json.clone(), + created_at_ms: if created_at_ms == 0 { + timestamp_ms(tx.timestamp) + } else { + created_at_ms + }, + updated_at_ms: if updated_at_ms == 0 { + timestamp_ms(tx.timestamp) + } else { + updated_at_ms + }, + }; + upsert_custom_world_session_row(tx, row); + MutationResult::ok("custom_world_session_upserted", "自定义世界会话已保存") + } + Err(result) => result, + }) +} + +#[procedure] +pub fn publish_custom_world_profile( + ctx: &mut ProcedureContext, + meta: RequestMeta, + profile_id: String, + author_display_name: String, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "publish_custom_world_profile") { + Ok(user) => publish_or_unpublish_custom_world_profile( + tx, + &user, + profile_id.clone(), + author_display_name.clone(), + true, + ), + Err(result) => result, + }) +} + +#[procedure] +pub fn unpublish_custom_world_profile( + ctx: &mut ProcedureContext, + meta: RequestMeta, + profile_id: String, + author_display_name: String, +) -> MutationResult { + ctx.with_tx(|tx| match guard_user_action(tx, &meta, "unpublish_custom_world_profile") { + Ok(user) => publish_or_unpublish_custom_world_profile( + tx, + &user, + profile_id.clone(), + author_display_name.clone(), + false, + ), + Err(result) => result, + }) +} + +fn save_snapshot_impl( + tx: &TxContext, + user: &User, + saved_at_ms: u64, + game_state_json: String, + bottom_tab: String, + current_story_json: Option, +) -> MutationResult { + let normalized_bottom_tab = normalize_required_string(&bottom_tab); + if normalized_bottom_tab.is_empty() { + return MutationResult::error("invalid_bottom_tab", "bottomTab 不能为空"); + } + + let normalized_saved_at_ms = normalize_saved_at_ms(saved_at_ms, tx.timestamp); + let snapshot = SaveSnapshot { + user_id: user.id.clone(), + version: SAVE_SNAPSHOT_VERSION, + saved_at_ms: normalized_saved_at_ms, + bottom_tab: normalized_bottom_tab, + game_state_json: game_state_json.clone(), + current_story_json, + updated_at_ms: timestamp_ms(tx.timestamp), + }; + upsert_save_snapshot(tx, snapshot); + sync_profile_dashboard_from_snapshot(tx, user, normalized_saved_at_ms, &game_state_json); + sync_custom_world_profile_from_snapshot(tx, user, normalized_saved_at_ms, &game_state_json); + MutationResult::ok("snapshot_saved", "运行时快照已保存") +} + +fn upsert_custom_world_profile_impl( + tx: &TxContext, + user: &User, + profile_id: String, + payload_json: String, + author_display_name: String, +) -> MutationResult { + let normalized_profile_id = normalize_required_string(&profile_id); + if normalized_profile_id.is_empty() { + return MutationResult::error("invalid_profile_id", "profileId 不能为空"); + } + let metadata = match extract_custom_world_metadata(&payload_json) { + Some(metadata) => metadata, + None => return MutationResult::error("invalid_profile_json", "自定义世界配置 JSON 不合法"), + }; + let now_ms = timestamp_ms(tx.timestamp); + let key = custom_world_profile_key(&user.id, &normalized_profile_id); + let existing = tx.db.custom_world_profile().id().find(&key); + let row = CustomWorldProfile { + id: key, + user_id: user.id.clone(), + profile_id: normalized_profile_id, + payload_json, + visibility: existing + .as_ref() + .map(|row| row.visibility.clone()) + .unwrap_or(CustomWorldPublicationStatus::Draft), + published_at_ms: existing.as_ref().and_then(|row| row.published_at_ms), + updated_at_ms: now_ms, + author_display_name: resolve_author_display_name(&author_display_name, &user.display_name), + world_name: metadata.world_name, + subtitle: metadata.subtitle, + summary_text: metadata.summary_text, + cover_image_src: metadata.cover_image_src, + theme_mode: metadata.theme_mode, + playable_npc_count: metadata.playable_npc_count, + landmark_count: metadata.landmark_count, + deleted_at_ms: None, + }; + upsert_custom_world_profile_row(tx, row); + MutationResult::ok("custom_world_profile_upserted", "自定义世界档案已保存") +} + +fn publish_or_unpublish_custom_world_profile( + tx: &TxContext, + user: &User, + profile_id: String, + author_display_name: String, + publish: bool, +) -> MutationResult { + let normalized_profile_id = normalize_required_string(&profile_id); + let key = custom_world_profile_key(&user.id, &normalized_profile_id); + let Some(existing) = tx.db.custom_world_profile().id().find(&key) else { + return MutationResult::error("custom_world_not_found", "未找到目标自定义世界档案"); + }; + let metadata = match extract_custom_world_metadata(&existing.payload_json) { + Some(metadata) => metadata, + None => return MutationResult::error("invalid_profile_json", "自定义世界配置 JSON 不合法"), + }; + let now_ms = timestamp_ms(tx.timestamp); + tx.db.custom_world_profile().id().update(CustomWorldProfile { + visibility: if publish { + CustomWorldPublicationStatus::Published + } else { + CustomWorldPublicationStatus::Draft + }, + published_at_ms: if publish { Some(now_ms) } else { None }, + updated_at_ms: now_ms, + author_display_name: resolve_author_display_name(&author_display_name, &user.display_name), + world_name: metadata.world_name, + subtitle: metadata.subtitle, + summary_text: metadata.summary_text, + cover_image_src: metadata.cover_image_src, + theme_mode: metadata.theme_mode, + playable_npc_count: metadata.playable_npc_count, + landmark_count: metadata.landmark_count, + deleted_at_ms: None, + ..existing + }); + if publish { + MutationResult::ok("custom_world_profile_published", "自定义世界档案已发布") + } else { + MutationResult::ok("custom_world_profile_unpublished", "自定义世界档案已取消发布") + } +} + +fn upsert_save_snapshot(tx: &TxContext, row: SaveSnapshot) { + if tx.db.saved_snapshot_row().user_id().find(&row.user_id).is_some() { + tx.db.saved_snapshot_row().user_id().update(row); + } else { + tx.db.saved_snapshot_row().insert(row); + } +} + +fn upsert_runtime_setting(tx: &TxContext, row: RuntimeSetting) { + if tx.db.runtime_setting().user_id().find(&row.user_id).is_some() { + tx.db.runtime_setting().user_id().update(row); + } else { + tx.db.runtime_setting().insert(row); + } +} + +fn upsert_browse_history(tx: &TxContext, row: UserBrowseHistory) { + if tx.db.user_browse_history().id().find(&row.id).is_some() { + tx.db.user_browse_history().id().update(row); + } else { + tx.db.user_browse_history().insert(row); + } +} + +fn upsert_custom_world_profile_row(tx: &TxContext, row: CustomWorldProfile) { + if tx.db.custom_world_profile().id().find(&row.id).is_some() { + tx.db.custom_world_profile().id().update(row); + } else { + tx.db.custom_world_profile().insert(row); + } +} + +fn upsert_custom_world_session_row(tx: &TxContext, row: CustomWorldSession) { + if tx.db.custom_world_session().id().find(&row.id).is_some() { + tx.db.custom_world_session().id().update(row); + } else { + tx.db.custom_world_session().insert(row); + } +} + +fn upsert_profile_dashboard_state(tx: &TxContext, row: ProfileDashboardState) { + if tx.db.profile_dashboard_state().user_id().find(&row.user_id).is_some() { + tx.db.profile_dashboard_state().user_id().update(row); + } else { + tx.db.profile_dashboard_state().insert(row); + } +} + +fn upsert_profile_played_world(tx: &TxContext, row: ProfilePlayedWorld) { + if tx.db.profile_played_world().id().find(&row.id).is_some() { + tx.db.profile_played_world().id().update(row); + } else { + tx.db.profile_played_world().insert(row); + } +} + +fn upsert_profile_wallet_ledger(tx: &TxContext, row: ProfileWalletLedger) { + if tx.db.profile_wallet_ledger().id().find(&row.id).is_none() { + tx.db.profile_wallet_ledger().insert(row); + } +} + +fn sync_profile_dashboard_from_snapshot( + tx: &TxContext, + user: &User, + saved_at_ms: u64, + game_state_json: &str, +) { + let Some(game_state) = parse_json(game_state_json) else { + return; + }; + let current_state = tx.db.profile_dashboard_state().user_id().find(&user.id); + let current_wallet_balance = current_state.as_ref().map(|row| row.wallet_balance).unwrap_or(0); + let current_total_play_time_ms = current_state + .as_ref() + .map(|row| row.total_play_time_ms) + .unwrap_or(0); + let next_wallet_balance = read_i64_field(&game_state, "playerCurrency").max(0); + if next_wallet_balance != current_wallet_balance { + let source_key = format!("snapshot:{saved_at_ms}:wallet:{next_wallet_balance}"); + upsert_profile_wallet_ledger( + tx, + ProfileWalletLedger { + id: profile_wallet_ledger_id(&user.id, &source_key), + user_id: user.id.clone(), + amount_delta: next_wallet_balance - current_wallet_balance, + balance_after: next_wallet_balance, + source_type: "snapshot_sync".to_string(), + source_key, + created_at_ms: saved_at_ms, + }, + ); + } + + let mut total_play_time_ms = current_total_play_time_ms; + if let Some(world_meta) = resolve_snapshot_world_meta(&game_state) { + let current_world_key = profile_played_world_key(&user.id, &world_meta.world_key); + let current_world = tx.db.profile_played_world().id().find(¤t_world_key); + let observed_play_time_ms = read_nested_u64(&game_state, &["runtimeStats", "playTimeMs"]); + let incremental_play_time_ms = observed_play_time_ms + .saturating_sub(current_world.as_ref().map(|row| row.last_observed_play_time_ms).unwrap_or(0)); + total_play_time_ms = total_play_time_ms.saturating_add(incremental_play_time_ms); + + upsert_profile_played_world( + tx, + ProfilePlayedWorld { + id: current_world_key, + user_id: user.id.clone(), + world_key: world_meta.world_key, + owner_user_id: world_meta.owner_user_id, + profile_id: world_meta.profile_id, + world_type: world_meta.world_type, + world_title: world_meta.world_title, + world_subtitle: world_meta.world_subtitle, + first_played_at_ms: current_world + .as_ref() + .map(|row| row.first_played_at_ms) + .unwrap_or(saved_at_ms), + last_played_at_ms: saved_at_ms, + last_observed_play_time_ms: observed_play_time_ms.max( + current_world + .as_ref() + .map(|row| row.last_observed_play_time_ms) + .unwrap_or(0), + ), + }, + ); + } + + upsert_profile_dashboard_state( + tx, + ProfileDashboardState { + user_id: user.id.clone(), + wallet_balance: next_wallet_balance, + total_play_time_ms, + updated_at_ms: saved_at_ms, + }, + ); +} + +fn sync_custom_world_profile_from_snapshot( + tx: &TxContext, + user: &User, + saved_at_ms: u64, + game_state_json: &str, +) { + let Some(game_state) = parse_json(game_state_json) else { + return; + }; + let Some(custom_world_profile) = game_state.get("customWorldProfile") else { + return; + }; + let Some(profile_object) = custom_world_profile.as_object() else { + return; + }; + + let profile_id = read_json_string_from_object(profile_object, "id") + .or_else(|| read_json_string_from_object(profile_object, "name")) + .unwrap_or_default(); + if profile_id.is_empty() { + return; + } + + let payload_json = serde_json::to_string(custom_world_profile).unwrap_or_else(|_| "{}".to_string()); + let Some(metadata) = extract_custom_world_metadata(&payload_json) else { + return; + }; + + let key = custom_world_profile_key(&user.id, &profile_id); + let existing = tx.db.custom_world_profile().id().find(&key); + upsert_custom_world_profile_row( + tx, + CustomWorldProfile { + id: key, + user_id: user.id.clone(), + profile_id, + payload_json, + visibility: existing + .as_ref() + .map(|row| row.visibility.clone()) + .unwrap_or(CustomWorldPublicationStatus::Draft), + published_at_ms: existing.as_ref().and_then(|row| row.published_at_ms), + updated_at_ms: saved_at_ms, + author_display_name: user.display_name.clone(), + world_name: metadata.world_name, + subtitle: metadata.subtitle, + summary_text: metadata.summary_text, + cover_image_src: metadata.cover_image_src, + theme_mode: metadata.theme_mode, + playable_npc_count: metadata.playable_npc_count, + landmark_count: metadata.landmark_count, + deleted_at_ms: None, + }, + ); +} + +struct SnapshotWorldMeta { + world_key: String, + owner_user_id: Option, + profile_id: Option, + world_type: Option, + world_title: String, + world_subtitle: String, +} + +fn resolve_snapshot_world_meta(game_state: &Value) -> Option { + if let Some(custom_world_profile) = game_state.get("customWorldProfile").and_then(Value::as_object) { + let profile_id = read_json_string_from_object(custom_world_profile, "id").unwrap_or_default(); + let world_title = read_json_string_from_object(custom_world_profile, "name") + .or_else(|| read_json_string_from_object(custom_world_profile, "title")) + .unwrap_or_else(|| "自定义世界".to_string()); + if !profile_id.is_empty() || !world_title.is_empty() { + return Some(SnapshotWorldMeta { + world_key: if profile_id.is_empty() { + format!("custom:{world_title}") + } else { + format!("custom:{profile_id}") + }, + owner_user_id: None, + profile_id: if profile_id.is_empty() { None } else { Some(profile_id) }, + world_type: Some("CUSTOM".to_string()), + world_title, + world_subtitle: read_json_string_from_object(custom_world_profile, "summary") + .or_else(|| read_json_string_from_object(custom_world_profile, "settingText")) + .unwrap_or_default(), + }); + } + } + + let world_type = game_state.get("worldType").and_then(Value::as_str)?.trim().to_string(); + if world_type.is_empty() { + return None; + } + let scene_object = game_state.get("currentScenePreset").and_then(Value::as_object); + let world_title = scene_object + .and_then(|object| read_json_string_from_object(object, "name")) + .unwrap_or_else(|| builtin_world_title(&world_type)); + let world_subtitle = scene_object + .and_then(|object| read_json_string_from_object(object, "summary")) + .or_else(|| scene_object.and_then(|object| read_json_string_from_object(object, "description"))) + .unwrap_or_default(); + Some(SnapshotWorldMeta { + world_key: format!("builtin:{world_type}"), + owner_user_id: None, + profile_id: None, + world_type: Some(world_type), + world_title, + world_subtitle, + }) +} + +struct CustomWorldMetadata { + world_name: String, + subtitle: String, + summary_text: String, + cover_image_src: Option, + theme_mode: CustomWorldThemeMode, + playable_npc_count: u32, + landmark_count: u32, +} + +fn extract_custom_world_metadata(payload_json: &str) -> Option { + let value = parse_json(payload_json)?; + let object = value.as_object()?; + let world_name = + read_json_string_from_object(object, "name").unwrap_or_else(|| "未命名世界".to_string()); + let subtitle = read_json_string_from_object(object, "subtitle").unwrap_or_default(); + let summary_text = read_json_string_from_object(object, "summary").unwrap_or_default(); + let cover_image_src = build_custom_world_cover_image_src(object); + let theme_mode = detect_theme_mode(object); + let playable_npc_count = object + .get("playableNpcs") + .and_then(Value::as_array) + .map(|rows| rows.len() as u32) + .unwrap_or(0); + let landmark_count = object + .get("landmarks") + .and_then(Value::as_array) + .map(|rows| rows.len() as u32) + .unwrap_or(0); + Some(CustomWorldMetadata { + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count, + }) +} + +fn build_custom_world_cover_image_src( + object: &serde_json::Map, +) -> Option { + if let Some(camp_image) = object + .get("camp") + .and_then(Value::as_object) + .and_then(|camp| read_json_string_from_object(camp, "imageSrc")) + { + return Some(camp_image); + } + if let Some(landmark_image) = object + .get("landmarks") + .and_then(Value::as_array) + .and_then(|rows| { + rows.iter().find_map(|entry| { + entry + .as_object() + .and_then(|object| read_json_string_from_object(object, "imageSrc")) + }) + }) + { + return Some(landmark_image); + } + object + .get("playableNpcs") + .and_then(Value::as_array) + .and_then(|rows| { + rows.iter().find_map(|entry| { + entry + .as_object() + .and_then(|object| read_json_string_from_object(object, "imageSrc")) + }) + }) +} + +fn detect_theme_mode(object: &serde_json::Map) -> CustomWorldThemeMode { + let mut source = String::new(); + for key in ["settingText", "summary", "tone", "playerGoal"] { + if let Some(value) = read_json_string_from_object(object, key) { + source.push_str(&value); + source.push(' '); + } + } + + if let Some(semantic_anchor) = object + .get("ownedSettingLayers") + .and_then(Value::as_object) + .and_then(|layers| layers.get("semanticAnchor")) + .and_then(Value::as_object) + { + for key in [ + "genreSignals", + "conflictForms", + "institutionTypes", + "tabooTypes", + "carrierTypes", + "forceSystemTypes", + "atmosphereTags", + ] { + if let Some(rows) = semantic_anchor.get(key).and_then(Value::as_array) { + for row in rows { + if let Some(text) = row.as_str() { + source.push_str(text); + source.push(' '); + } + } + } + } + } + + if let Some(expression_profile) = object + .get("ownedSettingLayers") + .and_then(Value::as_object) + .and_then(|layers| layers.get("expressionProfile")) + .and_then(Value::as_object) + { + if let Some(rows) = expression_profile.get("presentationTone").and_then(Value::as_array) { + for row in rows { + if let Some(text) = row.as_str() { + source.push_str(text); + source.push(' '); + } + } + } + } + + if contains_any(&source, &["机关", "蒸汽", "齿轮", "工坊", "机巧", "城轨", "炮舰"]) { + return CustomWorldThemeMode::Machina; + } + if contains_any(&source, &["海潮", "港湾", "船舟", "湖泊", "澜雾", "海湾"]) { + return CustomWorldThemeMode::Tide; + } + if contains_any(&source, &["裂缝", "裂界", "边境", "前线", "断层", "界桥", "灰域"]) { + return CustomWorldThemeMode::Rift; + } + if contains_any(&source, &["修真", "仙灵", "宗门", "法器", "道脉", "秘境", "云阙"]) { + return CustomWorldThemeMode::Arcane; + } + if contains_any(&source, &["江湖", "门派", "镖局", "朝廷", "刀剑", "侠客", "旧案"]) { + return CustomWorldThemeMode::Martial; + } + CustomWorldThemeMode::Mythic +} + +fn to_profile_wallet_ledger_view(row: ProfileWalletLedger) -> ProfileWalletLedgerView { + ProfileWalletLedgerView { + id: row.id, + amount_delta: row.amount_delta, + balance_after: row.balance_after, + source_type: row.source_type, + created_at_ms: row.created_at_ms, + } +} + +fn to_profile_played_world_view(row: ProfilePlayedWorld) -> ProfilePlayedWorldView { + ProfilePlayedWorldView { + world_key: row.world_key, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + world_type: row.world_type, + world_title: row.world_title, + world_subtitle: row.world_subtitle, + first_played_at_ms: row.first_played_at_ms, + last_played_at_ms: row.last_played_at_ms, + last_observed_play_time_ms: row.last_observed_play_time_ms, + } +} + +fn to_platform_browse_history_view(row: UserBrowseHistory) -> PlatformBrowseHistoryView { + PlatformBrowseHistoryView { + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + world_name: row.world_name, + subtitle: row.subtitle, + summary_text: row.summary_text, + cover_image_src: row.cover_image_src, + theme_mode: row.theme_mode, + author_display_name: row.author_display_name, + visited_at_ms: row.visited_at_ms, + } +} + +fn to_custom_world_profile_view(row: CustomWorldProfile) -> CustomWorldProfileView { + CustomWorldProfileView { + owner_user_id: row.user_id, + profile_id: row.profile_id, + payload_json: row.payload_json, + visibility: row.visibility, + published_at_ms: row.published_at_ms, + updated_at_ms: row.updated_at_ms, + author_display_name: row.author_display_name, + world_name: row.world_name, + subtitle: row.subtitle, + summary_text: row.summary_text, + cover_image_src: row.cover_image_src, + theme_mode: row.theme_mode, + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + } +} + +fn to_custom_world_gallery_card_view(row: CustomWorldProfile) -> CustomWorldGalleryCardView { + CustomWorldGalleryCardView { + owner_user_id: row.user_id, + profile_id: row.profile_id, + visibility: row.visibility, + published_at_ms: row.published_at_ms, + updated_at_ms: row.updated_at_ms, + author_display_name: row.author_display_name, + world_name: row.world_name, + subtitle: row.subtitle, + summary_text: row.summary_text, + cover_image_src: row.cover_image_src, + theme_mode: row.theme_mode, + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + } +} + +fn to_custom_world_session_view(row: CustomWorldSession) -> CustomWorldSessionView { + CustomWorldSessionView { + session_id: row.session_id, + payload_json: row.payload_json, + created_at_ms: row.created_at_ms, + updated_at_ms: row.updated_at_ms, + } +} diff --git a/spacetimedb/src/types.rs b/spacetimedb/src/types.rs new file mode 100644 index 00000000..73f87631 --- /dev/null +++ b/spacetimedb/src/types.rs @@ -0,0 +1,665 @@ +use spacetimedb::{table, Identity, SpacetimeType}; + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum LoginProvider { + Guest, + Jwt, + Phone, + Wechat, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum AccountStatus { + Active, + PendingSmsVerification, + Disabled, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum AuthIdentityProvider { + Guest, + Jwt, + Phone, + Wechat, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum SmsAuthScene { + Login, + BindPhone, + ChangePhone, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum SmsAuthAction { + SendCode, + VerifyCode, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum RiskBlockScopeType { + Phone, + Ip, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum CustomWorldPublicationStatus { + Draft, + Published, +} + +#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)] +pub enum CustomWorldThemeMode { + Martial, + Arcane, + Machina, + Tide, + Rift, + Mythic, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct RequestMeta { + pub client_type: String, + pub user_agent: Option, + pub ip: Option, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct PlatformBrowseHistoryWriteInput { + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub author_display_name: String, + pub visited_at_ms: u64, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct MutationResult { + pub ok: bool, + pub kicked: bool, + pub code: String, + pub message: String, +} + +impl MutationResult { + pub fn ok(code: &str, message: &str) -> Self { + Self { + ok: true, + kicked: false, + code: code.to_string(), + message: message.to_string(), + } + } + + pub fn error(code: &str, message: &str) -> Self { + Self { + ok: false, + kicked: false, + code: code.to_string(), + message: message.to_string(), + } + } + + pub fn kicked(code: &str, message: &str) -> Self { + Self { + ok: false, + kicked: true, + code: code.to_string(), + message: message.to_string(), + } + } +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct SmsSendCodeResult { + pub ok: bool, + pub kicked: bool, + pub code: String, + pub message: String, + pub cooldown_seconds: u32, + pub expires_in_seconds: u32, + pub provider_request_id: Option, +} + +impl SmsSendCodeResult { + pub fn ok(code: &str, message: &str, cooldown_seconds: u32, expires_in_seconds: u32) -> Self { + Self { + ok: true, + kicked: false, + code: code.to_string(), + message: message.to_string(), + cooldown_seconds, + expires_in_seconds, + provider_request_id: None, + } + } + + pub fn error(code: &str, message: &str) -> Self { + Self { + ok: false, + kicked: false, + code: code.to_string(), + message: message.to_string(), + cooldown_seconds: 0, + expires_in_seconds: 0, + provider_request_id: None, + } + } +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct SmsVerifyCodeResult { + pub ok: bool, + pub kicked: bool, + pub code: String, + pub message: String, + pub verified: bool, +} + +impl SmsVerifyCodeResult { + pub fn ok(message: &str) -> Self { + Self { + ok: true, + kicked: false, + code: "sms_verified".to_string(), + message: message.to_string(), + verified: true, + } + } + + pub fn error(code: &str, message: &str) -> Self { + Self { + ok: false, + kicked: false, + code: code.to_string(), + message: message.to_string(), + verified: false, + } + } +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct ClientAppConfigView { + pub guest_login_enabled: bool, + pub sms_auth_enabled: bool, + pub sms_verification_required: bool, + pub sms_provider: String, + pub sms_code_length: u16, + pub sms_valid_time_seconds: u32, + pub sms_interval_seconds: u32, + pub default_music_volume: f32, + pub default_guest_display_name_prefix: String, + pub wechat_enabled: bool, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct AuthStateView { + pub user_id: String, + pub identity: Identity, + pub display_name: String, + pub phone_number_masked: Option, + pub login_provider: LoginProvider, + pub account_status: AccountStatus, + pub sms_verification_required: bool, + pub sms_verified: bool, + pub jwt_present: bool, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct SnapshotView { + pub version: u32, + pub saved_at_ms: u64, + pub game_state_json: String, + pub bottom_tab: String, + pub current_story_json: Option, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct RuntimeSettingsView { + pub music_volume: f32, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct ProfileDashboardView { + pub wallet_balance: i64, + pub total_play_time_ms: u64, + pub played_world_count: u32, + pub updated_at_ms: Option, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct ProfileWalletLedgerView { + pub id: String, + pub amount_delta: i64, + pub balance_after: i64, + pub source_type: String, + pub created_at_ms: u64, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct ProfilePlayedWorldView { + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at_ms: u64, + pub last_played_at_ms: u64, + pub last_observed_play_time_ms: u64, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct PlatformBrowseHistoryView { + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub author_display_name: String, + pub visited_at_ms: u64, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct CustomWorldProfileView { + pub owner_user_id: String, + pub profile_id: String, + pub payload_json: String, + pub visibility: CustomWorldPublicationStatus, + pub published_at_ms: Option, + pub updated_at_ms: u64, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct CustomWorldGalleryCardView { + pub owner_user_id: String, + pub profile_id: String, + pub visibility: CustomWorldPublicationStatus, + pub published_at_ms: Option, + pub updated_at_ms: u64, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct PublishedCustomWorldProfileView { + pub owner_user_id: String, + pub profile_id: String, + pub payload_json: String, + pub visibility: CustomWorldPublicationStatus, + pub published_at_ms: Option, + pub updated_at_ms: u64, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct CustomWorldSessionView { + pub session_id: String, + pub payload_json: String, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct AuthAuditLogView { + pub id: u64, + pub event_type: String, + pub detail: String, + pub ip: Option, + pub user_agent: Option, + pub created_at_ms: u64, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct AuthSessionView { + pub session_id: String, + pub client_type: String, + pub user_agent: Option, + pub ip: Option, + pub is_current: bool, + pub created_at_ms: u64, + pub last_seen_at_ms: u64, + pub expires_at_ms: Option, +} + +#[derive(SpacetimeType, Clone, Debug)] +pub struct AuthRiskBlockView { + pub scope_type: RiskBlockScopeType, + pub scope_key: String, + pub reason: String, + pub expires_at_ms: u64, +} + +#[table(accessor = app_config)] +#[derive(Clone, Debug)] +pub struct AppConfig { + #[primary_key] + pub id: u8, + pub guest_login_enabled: bool, + pub sms_auth_enabled: bool, + pub sms_verification_required: bool, + pub sms_provider: String, + pub sms_endpoint: String, + pub sms_access_key_id: String, + pub sms_access_key_secret: String, + pub sms_sign_name: String, + pub sms_template_code: String, + pub sms_template_param_key: String, + pub sms_country_code: String, + pub sms_scheme_name: String, + pub sms_code_length: u16, + pub sms_code_type: u16, + pub sms_valid_time_seconds: u32, + pub sms_interval_seconds: u32, + pub sms_duplicate_policy: u16, + pub sms_case_auth_policy: u16, + pub sms_return_verify_code: bool, + pub sms_mock_verify_code: String, + pub sms_max_send_per_phone_per_day: u16, + pub sms_max_send_per_ip_per_hour: u16, + pub sms_max_verify_failures_per_phone_per_hour: u16, + pub sms_max_verify_failures_per_ip_per_hour: u16, + pub sms_captcha_ttl_seconds: u32, + pub sms_captcha_trigger_verify_failures_per_phone: u16, + pub sms_captcha_trigger_verify_failures_per_ip: u16, + pub sms_block_phone_failure_threshold: u16, + pub sms_block_ip_failure_threshold: u16, + pub sms_block_phone_duration_minutes: u16, + pub sms_block_ip_duration_minutes: u16, + pub default_music_volume: f32, + pub default_guest_display_name_prefix: String, + pub kick_message_unverified: String, + pub wechat_enabled: bool, + pub wechat_provider: String, + pub wechat_app_id: String, + pub wechat_app_secret: String, + pub wechat_authorize_endpoint: String, + pub wechat_access_token_endpoint: String, + pub wechat_user_info_endpoint: String, + pub wechat_callback_path: String, + pub wechat_default_redirect_path: String, + pub wechat_mock_user_id: String, + pub wechat_mock_union_id: String, + pub wechat_mock_display_name: String, + pub wechat_mock_avatar_url: String, + pub updated_at_ms: u64, +} + +#[table(accessor = user)] +#[derive(Clone, Debug)] +pub struct User { + #[primary_key] + pub id: String, + #[unique] + pub identity: Identity, + pub username: Option, + pub password_hash: Option, + pub token_version: u32, + pub display_name: String, + pub login_provider: LoginProvider, + pub account_status: AccountStatus, + pub phone_number: Option, + pub phone_verified_at_ms: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +#[table(accessor = auth_identity)] +#[derive(Clone, Debug)] +pub struct AuthIdentity { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub provider: AuthIdentityProvider, + pub provider_uid: String, + pub provider_union_id: Option, + pub display_name: Option, + pub avatar_url: Option, + pub is_verified: bool, + pub meta_json: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +#[table(accessor = user_session)] +#[derive(Clone, Debug)] +pub struct UserSession { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub refresh_token_hash: String, + pub client_type: String, + pub user_agent: Option, + pub ip: Option, + pub expires_at_ms: Option, + pub revoked_at_ms: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, + pub last_seen_at_ms: u64, +} + +#[table(accessor = auth_audit_log)] +#[derive(Clone, Debug)] +pub struct AuthAuditLog { + #[primary_key] + #[auto_inc] + pub id: u64, + #[index(btree)] + pub user_id: String, + pub event_type: String, + pub detail: String, + pub ip: Option, + pub user_agent: Option, + pub meta_json: Option, + pub created_at_ms: u64, +} + +#[table(accessor = sms_auth_event)] +#[derive(Clone, Debug)] +pub struct SmsAuthEvent { + #[primary_key] + #[auto_inc] + pub id: u64, + #[index(btree)] + pub identity: Identity, + #[index(btree)] + pub phone_number: String, + pub scene: SmsAuthScene, + pub action: SmsAuthAction, + pub success: bool, + pub ip: Option, + #[index(btree)] + pub ip_key: String, + pub user_agent: Option, + pub created_at_ms: u64, +} + +#[table(accessor = auth_risk_block)] +#[derive(Clone, Debug)] +pub struct AuthRiskBlock { + #[primary_key] + #[auto_inc] + pub id: u64, + #[index(btree)] + pub scope_type: RiskBlockScopeType, + pub scope_key: String, + pub reason: String, + pub expires_at_ms: u64, + pub lifted_at_ms: Option, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +#[table(accessor = saved_snapshot_row)] +#[derive(Clone, Debug)] +pub struct SaveSnapshot { + #[primary_key] + pub user_id: String, + pub version: u32, + pub saved_at_ms: u64, + pub bottom_tab: String, + pub game_state_json: String, + pub current_story_json: Option, + pub updated_at_ms: u64, +} + +#[table(accessor = runtime_setting)] +#[derive(Clone, Debug)] +pub struct RuntimeSetting { + #[primary_key] + pub user_id: String, + pub music_volume: f32, + pub updated_at_ms: u64, +} + +#[table(accessor = custom_world_profile)] +#[derive(Clone, Debug)] +pub struct CustomWorldProfile { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub profile_id: String, + #[index(btree)] + pub visibility: CustomWorldPublicationStatus, + pub payload_json: String, + pub published_at_ms: Option, + pub updated_at_ms: u64, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub deleted_at_ms: Option, +} + +#[table(accessor = custom_world_session)] +#[derive(Clone, Debug)] +pub struct CustomWorldSession { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub session_id: String, + pub payload_json: String, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +#[table(accessor = profile_dashboard_state)] +#[derive(Clone, Debug)] +pub struct ProfileDashboardState { + #[primary_key] + pub user_id: String, + pub wallet_balance: i64, + pub total_play_time_ms: u64, + pub updated_at_ms: u64, +} + +#[table(accessor = profile_wallet_ledger)] +#[derive(Clone, Debug)] +pub struct ProfileWalletLedger { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub amount_delta: i64, + pub balance_after: i64, + pub source_type: String, + pub source_key: String, + pub created_at_ms: u64, +} + +#[table(accessor = profile_played_world)] +#[derive(Clone, Debug)] +pub struct ProfilePlayedWorld { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub world_key: String, + pub owner_user_id: Option, + pub profile_id: Option, + pub world_type: Option, + pub world_title: String, + pub world_subtitle: String, + pub first_played_at_ms: u64, + pub last_played_at_ms: u64, + pub last_observed_play_time_ms: u64, +} + +#[table(accessor = user_browse_history)] +#[derive(Clone, Debug)] +pub struct UserBrowseHistory { + #[primary_key] + pub id: String, + #[index(btree)] + pub user_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: CustomWorldThemeMode, + pub author_display_name: String, + pub visited_at_ms: u64, +} + +#[table(accessor = kick_event, public, event)] +pub struct KickEvent { + pub target_identity: Identity, + pub reason_code: String, + pub message: String, + pub issued_at_ms: u64, +} + +#[table(accessor = verification_prompt_event, public, event)] +pub struct VerificationPromptEvent { + pub target_identity: Identity, + pub phone_number_masked: Option, + pub title: String, + pub detail: String, + pub issued_at_ms: u64, +} + +#[table(accessor = session_revocation_event, public, event)] +pub struct SessionRevocationEventRow { + pub target_session_id: String, + pub reason_code: String, + pub message: String, + pub issued_at_ms: u64, +} diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index 080603e0..f51eb992 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -42,6 +42,10 @@ type AccountModalProps = { function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) { switch (loginMethod) { + case 'guest': + return '游客登录'; + case 'jwt': + return '令牌登录'; case 'wechat': return '微信登录'; case 'phone': diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index b9feb8d3..8c212af6 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -7,70 +7,70 @@ import type { AuthUser } from '../../services/authService'; import { AuthGate } from './AuthGate'; const authMocks = vi.hoisted(() => ({ - getStoredAccessToken: vi.fn(), - ensureAutoAuthUser: vi.fn(), - getAuthLoginOptions: vi.fn(), - consumeAuthCallbackResult: vi.fn(), -})); - -vi.mock('../../services/apiClient', () => ({ - AUTH_STATE_EVENT: 'genarrative-auth-state-changed', - getStoredAccessToken: authMocks.getStoredAccessToken, -})); - -vi.mock('../../services/authService', () => ({ - bindWechatPhone: vi.fn(), - changePhoneNumber: vi.fn(), - consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult, - ensureAutoAuthUser: authMocks.ensureAutoAuthUser, - getAuthAuditLogs: vi.fn(), - getAuthLoginOptions: authMocks.getAuthLoginOptions, - getAuthRiskBlocks: vi.fn(), - getAuthSessions: vi.fn(), - getCaptchaChallengeFromError: vi.fn(() => null), getCurrentAuthUser: vi.fn(), + getAuthAuditLogs: vi.fn(async () => []), + getAuthRiskBlocks: vi.fn(async () => []), + getAuthSessions: vi.fn(async () => []), + getCaptchaChallengeFromError: vi.fn(() => null), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: vi.fn(), logoutAllAuthSessions: vi.fn(), logoutAuthUser: vi.fn(), revokeAuthSession: vi.fn(), sendPhoneLoginCode: vi.fn(), - startWechatLogin: vi.fn(), +})); + +vi.mock('../../services/apiClient', () => ({ + AUTH_STATE_EVENT: 'genarrative-auth-state-changed', +})); + +vi.mock('../../spacetime/client', () => ({ + SPACETIME_KICK_EVENT: 'genarrative-spacetime-kick', + SPACETIME_SESSION_REVOKED_EVENT: 'genarrative-spacetime-session-revoked', + SPACETIME_VERIFICATION_REQUIRED_EVENT: + 'genarrative-spacetime-verification-required', +})); + +vi.mock('../../services/authService', () => ({ + getCurrentAuthUser: authMocks.getCurrentAuthUser, + getAuthAuditLogs: authMocks.getAuthAuditLogs, + getAuthRiskBlocks: authMocks.getAuthRiskBlocks, + getAuthSessions: authMocks.getAuthSessions, + getCaptchaChallengeFromError: authMocks.getCaptchaChallengeFromError, + liftAuthRiskBlock: authMocks.liftAuthRiskBlock, + loginWithPhoneCode: authMocks.loginWithPhoneCode, + logoutAllAuthSessions: authMocks.logoutAllAuthSessions, + logoutAuthUser: authMocks.logoutAuthUser, + revokeAuthSession: authMocks.revokeAuthSession, + sendPhoneLoginCode: authMocks.sendPhoneLoginCode, })); vi.mock('./AccountModal', () => ({ AccountModal: () => null, })); -vi.mock('./BindPhoneScreen', () => ({ - BindPhoneScreen: () =>
绑定手机号
, +vi.mock('./PhoneVerificationModal', () => ({ + PhoneVerificationModal: ({ isOpen }: { isOpen: boolean }) => + isOpen ?
完成短信验证
: null, })); -const mockUser: AuthUser = { +const activeUser: AuthUser = { id: 'user-1', - username: 'tester', + username: 'guest_1', displayName: '测试玩家', - phoneNumberMasked: '138****8000', - loginMethod: 'phone', + phoneNumberMasked: null, + loginMethod: 'guest', bindingStatus: 'active', wechatBound: false, }; beforeEach(() => { vi.clearAllMocks(); - authMocks.getStoredAccessToken.mockReturnValue(null); - authMocks.consumeAuthCallbackResult.mockReturnValue(null); - authMocks.ensureAutoAuthUser.mockResolvedValue({ - user: mockUser, - credentials: { - username: 'guest_tester', - password: 'auto_password', - }, - }); }); -test('auth gate prefers login screen when phone login is available', async () => { - authMocks.getAuthLoginOptions.mockResolvedValue({ +test('auth gate renders app content after spacetime auth session is ready', async () => { + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: activeUser, availableLoginMethods: ['phone'], }); @@ -80,7 +80,23 @@ test('auth gate prefers login screen when phone login is available', async () => , ); - expect(await screen.findByText('账号登录')).toBeTruthy(); - expect(screen.getByText('手机号')).toBeTruthy(); - expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled(); + expect(await screen.findByText('应用内容')).toBeTruthy(); +}); + +test('auth gate opens phone verification modal for pending sms verification user', async () => { + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: { + ...activeUser, + bindingStatus: 'pending_bind_phone', + }, + availableLoginMethods: ['phone'], + }); + + render( + +
应用内容
+
, + ); + + expect(await screen.findByText('完成短信验证')).toBeTruthy(); }); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index f0d76fd6..f7efcdb7 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -1,22 +1,20 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { - AUTH_STATE_EVENT, - getStoredAccessToken, -} from '../../services/apiClient'; + SPACETIME_KICK_EVENT, + SPACETIME_SESSION_REVOKED_EVENT, + SPACETIME_VERIFICATION_REQUIRED_EVENT, + type KickEventDetail, + type SessionRevokedDetail, + type VerificationRequiredDetail, +} from '../../spacetime/client'; import { type AuthAuditLogEntry, type AuthCaptchaChallenge, - type AuthLoginMethod, type AuthRiskBlockSummary, type AuthSessionSummary, type AuthUser, - bindWechatPhone, - changePhoneNumber, - consumeAuthCallbackResult, - ensureAutoAuthUser, getAuthAuditLogs, - getAuthLoginOptions, getAuthRiskBlocks, getAuthSessions, getCaptchaChallengeFromError, @@ -27,41 +25,28 @@ import { logoutAuthUser, revokeAuthSession, sendPhoneLoginCode, - startWechatLogin, } from '../../services/authService'; +import { AUTH_STATE_EVENT } from '../../services/apiClient'; import { AccountModal } from './AccountModal'; import { AuthUiContext } from './AuthUiContext'; -import { BindPhoneScreen } from './BindPhoneScreen'; -import { LoginScreen } from './LoginScreen'; +import { PhoneVerificationModal } from './PhoneVerificationModal'; type AuthGateProps = { children: ReactNode; }; -type AuthStatus = - | 'checking' - | 'recovering' - | 'unauthenticated' - | 'pending_bind_phone' - | 'ready' - | 'error'; - -const allowDevGuestAutoAuth = - import.meta.env.DEV && - import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false'; +type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error'; export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); - const [availableLoginMethods, setAvailableLoginMethods] = useState< - AuthLoginMethod[] - >([]); const [error, setError] = useState(''); const [sendingCode, setSendingCode] = useState(false); - const [loggingIn, setLoggingIn] = useState(false); - const [bindingPhone, setBindingPhone] = useState(false); - const [wechatLoading, setWechatLoading] = useState(false); + const [verifyingPhone, setVerifyingPhone] = useState(false); const [showAccountModal, setShowAccountModal] = useState(false); + const [showVerificationModal, setShowVerificationModal] = useState(false); + const [verificationPrompt, setVerificationPrompt] = + useState(null); const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); @@ -69,9 +54,7 @@ export function AuthGate({ children }: AuthGateProps) { const [loadingAuditLogs, setLoadingAuditLogs] = useState(false); const [riskBlocks, setRiskBlocks] = useState([]); const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false); - const [loginCaptchaChallenge, setLoginCaptchaChallenge] = - useState(null); - const [bindCaptchaChallenge, setBindCaptchaChallenge] = + const [verificationCaptchaChallenge, setVerificationCaptchaChallenge] = useState(null); const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = useState(null); @@ -79,97 +62,8 @@ export function AuthGate({ children }: AuthGateProps) { useEffect(() => { let isActive = true; - const ensureAutoUser = async () => { - if (!isActive) { - return; - } - - setStatus('recovering'); - - try { - const { user: nextUser } = await ensureAutoAuthUser(); - if (!isActive) { - return; - } - - setUser(nextUser); - setStatus('ready'); - setError(''); - } catch (autoAuthError) { - if (!isActive) { - return; - } - - setUser(null); - setStatus('error'); - setError( - autoAuthError instanceof Error - ? autoAuthError.message - : '自动登录失败,请稍后再试。', - ); - } - }; - const hydrate = async () => { - const loadLoginOptions = async () => { - const options = await getAuthLoginOptions(); - if (!isActive) { - return null; - } - - setAvailableLoginMethods(options.availableLoginMethods); - return options; - }; - - const resolveGuestFallback = async () => { - try { - const options = await loadLoginOptions(); - if (!isActive) { - return; - } - - if ( - allowDevGuestAutoAuth && - options && - options.availableLoginMethods.length === 0 - ) { - await ensureAutoUser(); - return; - } - - setUser(null); - setStatus('unauthenticated'); - } catch (optionsError) { - if (!isActive) { - return; - } - - if (allowDevGuestAutoAuth) { - await ensureAutoUser(); - return; - } - - setAvailableLoginMethods([]); - setUser(null); - setError( - optionsError instanceof Error - ? optionsError.message - : '读取登录方式失败,请稍后再试。', - ); - setStatus('unauthenticated'); - } - }; - - const callbackResult = consumeAuthCallbackResult(); - if (callbackResult?.error && isActive) { - setError(callbackResult.error); - } - - const token = getStoredAccessToken(); - if (!token) { - await resolveGuestFallback(); - return; - } + setStatus((current) => (current === 'ready' ? current : 'recovering')); try { const nextSession = await getCurrentAuthUser(); @@ -178,43 +72,99 @@ export function AuthGate({ children }: AuthGateProps) { } if (!nextSession.user) { - setUser(null); - setAvailableLoginMethods(nextSession.availableLoginMethods); - setStatus('unauthenticated'); + setStatus('error'); + setError('账号初始化失败,请刷新页面后重试。'); return; } setUser(nextSession.user); - setAvailableLoginMethods(nextSession.availableLoginMethods); - setStatus( - nextSession.user.bindingStatus === 'pending_bind_phone' - ? 'pending_bind_phone' - : 'ready', - ); - setError(callbackResult?.error ?? ''); - } catch { + setStatus('ready'); + setError(''); + if (nextSession.user.bindingStatus === 'pending_bind_phone') { + setShowVerificationModal(true); + setVerificationPrompt((current) => + current ?? { + phoneNumberMasked: nextSession.user.phoneNumberMasked, + title: '完成短信验证', + detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。', + }, + ); + } + } catch (hydrateError) { if (!isActive) { return; } - await resolveGuestFallback(); + setUser(null); + setStatus('error'); + setError( + hydrateError instanceof Error + ? hydrateError.message + : '账号初始化失败,请稍后再试。', + ); } }; void hydrate(); const handleAuthStateChange = () => { - setStatus('checking'); void hydrate(); }; + const handleVerificationRequired = (event: Event) => { + const detail = (event as CustomEvent).detail; + setVerificationPrompt( + detail ?? { + phoneNumberMasked: user?.phoneNumberMasked ?? null, + title: '完成短信验证', + detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。', + }, + ); + setShowVerificationModal(true); + }; + + const handleKick = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.message) { + setError(detail.message); + } + setShowVerificationModal(true); + }; + + const handleSessionRevoked = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.message) { + setError(detail.message); + } else { + setError('当前连接已失效,请重新建立连接。'); + } + }; + window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange); + window.addEventListener( + SPACETIME_VERIFICATION_REQUIRED_EVENT, + handleVerificationRequired, + ); + window.addEventListener(SPACETIME_KICK_EVENT, handleKick); + window.addEventListener( + SPACETIME_SESSION_REVOKED_EVENT, + handleSessionRevoked, + ); return () => { isActive = false; window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange); + window.removeEventListener( + SPACETIME_VERIFICATION_REQUIRED_EVENT, + handleVerificationRequired, + ); + window.removeEventListener(SPACETIME_KICK_EVENT, handleKick); + window.removeEventListener( + SPACETIME_SESSION_REVOKED_EVENT, + handleSessionRevoked, + ); }; - }, []); + }, [user?.phoneNumberMasked]); useEffect(() => { if (!showAccountModal || status !== 'ready') { @@ -225,75 +175,68 @@ export function AuthGate({ children }: AuthGateProps) { setLoadingRiskBlocks(true); setLoadingSessions(true); setLoadingAuditLogs(true); + void getAuthRiskBlocks() .then((nextBlocks) => { - if (!isActive) { - return; + if (isActive) { + setRiskBlocks(nextBlocks); } - setRiskBlocks(nextBlocks); }) .catch((blockError) => { - if (!isActive) { - return; + if (isActive) { + setError( + blockError instanceof Error + ? blockError.message + : '读取安全状态失败,请稍后再试。', + ); } - setError( - blockError instanceof Error - ? blockError.message - : '读取安全状态失败,请稍后再试。', - ); }) .finally(() => { - if (!isActive) { - return; + if (isActive) { + setLoadingRiskBlocks(false); } - setLoadingRiskBlocks(false); }); + void getAuthSessions() .then((nextSessions) => { - if (!isActive) { - return; + if (isActive) { + setSessions(nextSessions); } - setSessions(nextSessions); }) .catch((sessionError) => { - if (!isActive) { - return; + if (isActive) { + setError( + sessionError instanceof Error + ? sessionError.message + : '读取登录设备失败,请稍后再试。', + ); } - setError( - sessionError instanceof Error - ? sessionError.message - : '读取登录设备失败,请稍后再试。', - ); }) .finally(() => { - if (!isActive) { - return; + if (isActive) { + setLoadingSessions(false); } - setLoadingSessions(false); }); void getAuthAuditLogs() .then((nextLogs) => { - if (!isActive) { - return; + if (isActive) { + setAuditLogs(nextLogs); } - setAuditLogs(nextLogs); }) .catch((auditError) => { - if (!isActive) { - return; + if (isActive) { + setError( + auditError instanceof Error + ? auditError.message + : '读取账号操作记录失败,请稍后再试。', + ); } - setError( - auditError instanceof Error - ? auditError.message - : '读取账号操作记录失败,请稍后再试。', - ); }) .finally(() => { - if (!isActive) { - return; + if (isActive) { + setLoadingAuditLogs(false); } - setLoadingAuditLogs(false); }); return () => { @@ -314,152 +257,19 @@ export function AuthGate({ children }: AuthGateProps) { [user], ); - if (status === 'checking') { + if (status === 'checking' || status === 'recovering') { return (
- 正在校验登录状态... + 正在建立账号连接...
); } - if (status === 'recovering') { - return ( -
- 正在自动创建或恢复账号... -
- ); - } - - if (status === 'unauthenticated') { - return ( - { - setSendingCode(true); - setError(''); - try { - const result = await sendPhoneLoginCode(phone, 'login', captcha); - setLoginCaptchaChallenge(null); - return result; - } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); - if (captchaChallenge) { - setLoginCaptchaChallenge(captchaChallenge); - } - setError( - sendError instanceof Error - ? sendError.message - : '发送验证码失败,请稍后再试。', - ); - throw sendError; - } finally { - setSendingCode(false); - } - }} - onSubmit={async (phone, code) => { - setLoggingIn(true); - setError(''); - try { - const nextUser = await loginWithPhoneCode(phone, code); - setLoginCaptchaChallenge(null); - setUser(nextUser); - setStatus('ready'); - } catch (loginError) { - setError( - loginError instanceof Error - ? loginError.message - : '登录失败,请稍后再试。', - ); - } finally { - setLoggingIn(false); - } - }} - onStartWechatLogin={async () => { - setWechatLoading(true); - setError(''); - try { - await startWechatLogin(); - } catch (wechatError) { - setError( - wechatError instanceof Error - ? wechatError.message - : '微信登录暂不可用,请稍后再试。', - ); - } finally { - setWechatLoading(false); - } - }} - /> - ); - } - - if (status === 'pending_bind_phone' && user) { - return ( - { - setSendingCode(true); - setError(''); - try { - const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha); - setBindCaptchaChallenge(null); - return result; - } catch (sendError) { - const captchaChallenge = getCaptchaChallengeFromError(sendError); - if (captchaChallenge) { - setBindCaptchaChallenge(captchaChallenge); - } - setError( - sendError instanceof Error - ? sendError.message - : '发送验证码失败,请稍后再试。', - ); - throw sendError; - } finally { - setSendingCode(false); - } - }} - onSubmit={async (phone, code) => { - setBindingPhone(true); - setError(''); - try { - const nextUser = await bindWechatPhone(phone, code); - setBindCaptchaChallenge(null); - setUser(nextUser); - setStatus('ready'); - } catch (bindError) { - setError( - bindError instanceof Error - ? bindError.message - : '绑定手机号失败,请稍后再试。', - ); - } finally { - setBindingPhone(false); - } - }} - onLogout={async () => { - await logoutAuthUser(); - setUser(null); - setStatus('unauthenticated'); - }} - /> - ); - } - - if (status !== 'ready' || !user) { + if (status === 'error' || !user) { return (
-
登录状态异常
+
连接状态异常
{error || '账号恢复失败,请刷新页面后重试。'}
@@ -502,6 +312,59 @@ export function AuthGate({ children }: AuthGateProps) {
) : null} + + setShowVerificationModal(false)} + onSendCode={async (phone, captcha) => { + setSendingCode(true); + setError(''); + try { + const result = await sendPhoneLoginCode(phone, 'login', captcha); + setVerificationCaptchaChallenge(null); + return result; + } catch (sendError) { + const captchaChallenge = getCaptchaChallengeFromError(sendError); + if (captchaChallenge) { + setVerificationCaptchaChallenge(captchaChallenge); + } + setError( + sendError instanceof Error + ? sendError.message + : '发送验证码失败,请稍后再试。', + ); + throw sendError; + } finally { + setSendingCode(false); + } + }} + onSubmit={async (phone, code) => { + setVerifyingPhone(true); + setError(''); + try { + const nextUser = await loginWithPhoneCode(phone, code); + setVerificationCaptchaChallenge(null); + setUser(nextUser); + setShowVerificationModal(false); + } catch (verifyError) { + setError( + verifyError instanceof Error + ? verifyError.message + : '短信验证失败,请稍后再试。', + ); + } finally { + setVerifyingPhone(false); + } + }} + /> + { - try { - await revokeAuthSession(sessionId); - setSessions((current) => - current.filter((session) => session.sessionId !== sessionId), - ); - setAuditLogs(await getAuthAuditLogs()); - } catch (revokeError) { - setError( - revokeError instanceof Error - ? revokeError.message - : '移除登录设备失败,请稍后再试。', - ); - } + await revokeAuthSession(sessionId); + setSessions((current) => + current.filter((session) => session.sessionId !== sessionId), + ); }} onLogoutAll={async () => { await logoutAllAuthSessions(); @@ -609,11 +445,12 @@ export function AuthGate({ children }: AuthGateProps) { } }} onChangePhone={async (phone, code) => { - const nextUser = await changePhoneNumber(phone, code); + const nextUser = await loginWithPhoneCode(phone, code); setChangePhoneCaptchaChallenge(null); setUser(nextUser); }} /> + {children} diff --git a/src/components/auth/PhoneVerificationModal.tsx b/src/components/auth/PhoneVerificationModal.tsx new file mode 100644 index 00000000..06f15a03 --- /dev/null +++ b/src/components/auth/PhoneVerificationModal.tsx @@ -0,0 +1,201 @@ +import { useEffect, useState } from 'react'; + +import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService'; +import { CaptchaChallengeField } from './CaptchaChallengeField'; + +type PhoneVerificationModalProps = { + user: AuthUser; + isOpen: boolean; + title?: string; + detail?: string; + sendingCode: boolean; + verifying: boolean; + error: string; + captchaChallenge: AuthCaptchaChallenge | null; + onClose: () => void; + onSendCode: ( + phone: string, + captcha?: { + challengeId?: string; + answer?: string; + }, + ) => Promise<{ + cooldownSeconds: number; + expiresInSeconds: number; + }>; + onSubmit: (phone: string, code: string) => Promise; +}; + +export function PhoneVerificationModal({ + user, + isOpen, + title = '完成短信验证', + detail = '验证手机号后,才能继续进行需要服务端状态写入的操作。', + sendingCode, + verifying, + error, + captchaChallenge, + onClose, + onSendCode, + onSubmit, +}: PhoneVerificationModalProps) { + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [captchaAnswer, setCaptchaAnswer] = useState(''); + const [cooldownSeconds, setCooldownSeconds] = useState(0); + const [hint, setHint] = useState(''); + + useEffect(() => { + if (!isOpen) { + setPhone(''); + setCode(''); + setCaptchaAnswer(''); + setHint(''); + setCooldownSeconds(0); + return; + } + + if (cooldownSeconds <= 0) { + return; + } + + const timeoutId = window.setTimeout(() => { + setCooldownSeconds((current) => Math.max(0, current - 1)); + }, 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [cooldownSeconds, isOpen]); + + if (!isOpen) { + return null; + } + + return ( +
+
event.stopPropagation()} + > +
+
+
+ 验证窗口 +
+
{title}
+
+ +
+ +
+ {detail} +
+ +
+ 当前账号:{user.displayName} +
+ +
{ + event.preventDefault(); + void onSubmit(phone, code); + }} + > + + + + + {hint ? ( +
+ {hint} +
+ ) : null} + + + + {error ? ( +
+ {error} +
+ ) : null} + + + +
+
+ ); +} diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index f80b5729..3b101189 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -217,6 +217,10 @@ function formatSnapshotTime(value: string | null | undefined) { function describeLoginMethod(loginMethod: AuthUser['loginMethod']) { switch (loginMethod) { + case 'guest': + return '游客'; + case 'jwt': + return '令牌'; case 'phone': return '手机号'; case 'wechat': diff --git a/src/module_bindings/add_reducer.rs b/src/module_bindings/add_reducer.rs new file mode 100644 index 00000000..4322d48a --- /dev/null +++ b/src/module_bindings/add_reducer.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct AddArgs { + pub name: String, +} + +impl From for super::Reducer { + fn from(args: AddArgs) -> Self { + Self::Add { name: args.name } + } +} + +impl __sdk::InModule for AddArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `add`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait add { + /// Request that the remote module invoke the reducer `add` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`add:add_then`] to run a callback after the reducer completes. + fn add(&self, name: String) -> __sdk::Result<()> { + self.add_then(name, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `add` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn add_then( + &self, + name: String, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl add for super::RemoteReducers { + fn add_then( + &self, + name: String, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(AddArgs { name }, callback) + } +} diff --git a/src/module_bindings/mod.rs b/src/module_bindings/mod.rs new file mode 100644 index 00000000..16f09c17 --- /dev/null +++ b/src/module_bindings/mod.rs @@ -0,0 +1,817 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +pub mod add_reducer; +pub mod person_table; +pub mod person_type; +pub mod say_hello_reducer; + +pub use add_reducer::add; +pub use person_table::*; +pub use person_type::Person; +pub use say_hello_reducer::say_hello; + +#[derive(Clone, PartialEq, Debug)] + +/// One of the reducers defined by this module. +/// +/// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events +/// to indicate which reducer caused the event. + +pub enum Reducer { + Add { name: String }, + SayHello, +} + +impl __sdk::InModule for Reducer { + type Module = RemoteModule; +} + +impl __sdk::Reducer for Reducer { + fn reducer_name(&self) -> &'static str { + match self { + Reducer::Add { .. } => "add", + Reducer::SayHello => "say_hello", + _ => unreachable!(), + } + } + #[allow(clippy::clone_on_copy)] + fn args_bsatn(&self) -> Result, __sats::bsatn::EncodeError> { + match self { + Reducer::Add { name } => { + __sats::bsatn::to_vec(&add_reducer::AddArgs { name: name.clone() }) + } + Reducer::SayHello => __sats::bsatn::to_vec(&say_hello_reducer::SayHelloArgs {}), + _ => unreachable!(), + } + } +} + +#[derive(Default, Debug)] +#[allow(non_snake_case)] +#[doc(hidden)] +pub struct DbUpdate { + person: __sdk::TableUpdate, +} + +impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { + type Error = __sdk::Error; + fn try_from(raw: __ws::v2::TransactionUpdate) -> Result { + let mut db_update = DbUpdate::default(); + for table_update in __sdk::transaction_update_iter_table_updates(raw) { + match &table_update.table_name[..] { + "person" => db_update + .person + .append(person_table::parse_table_update(table_update)?), + + unknown => { + return Err(__sdk::InternalError::unknown_name( + "table", + unknown, + "DatabaseUpdate", + ) + .into()); + } + } + } + Ok(db_update) + } +} + +impl __sdk::InModule for DbUpdate { + type Module = RemoteModule; +} + +impl __sdk::DbUpdate for DbUpdate { + fn apply_to_client_cache( + &self, + cache: &mut __sdk::ClientCache, + ) -> AppliedDiff<'_> { + let mut diff = AppliedDiff::default(); + + diff.person = cache.apply_diff_to_table::("person", &self.person); + + diff + } + fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { + let mut db_update = DbUpdate::default(); + for table_rows in raw.tables { + match &table_rows.table[..] { + "person" => db_update + .person + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + unknown => { + return Err( + __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), + ); + } + } + } + Ok(db_update) + } + fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result { + let mut db_update = DbUpdate::default(); + for table_rows in raw.tables { + match &table_rows.table[..] { + "person" => db_update + .person + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + unknown => { + return Err( + __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), + ); + } + } + } + Ok(db_update) + } +} + +#[derive(Default)] +#[allow(non_snake_case)] +#[doc(hidden)] +pub struct AppliedDiff<'r> { + person: __sdk::TableAppliedDiff<'r, Person>, + __unused: std::marker::PhantomData<&'r ()>, +} + +impl __sdk::InModule for AppliedDiff<'_> { + type Module = RemoteModule; +} + +impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { + fn invoke_row_callbacks( + &self, + event: &EventContext, + callbacks: &mut __sdk::DbCallbacks, + ) { + callbacks.invoke_table_row_callbacks::("person", &self.person, event); + } +} + +#[doc(hidden)] +#[derive(Debug)] +pub struct RemoteModule; + +impl __sdk::InModule for RemoteModule { + type Module = Self; +} + +/// The `reducers` field of [`EventContext`] and [`DbConnection`], +/// with methods provided by extension traits for each reducer defined by the module. +pub struct RemoteReducers { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteReducers { + type Module = RemoteModule; +} + +/// The `procedures` field of [`DbConnection`] and other [`DbContext`] types, +/// with methods provided by extension traits for each procedure defined by the module. +pub struct RemoteProcedures { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteProcedures { + type Module = RemoteModule; +} + +/// The `db` field of [`EventContext`] and [`DbConnection`], +/// with methods provided by extension traits for each table defined by the module. +pub struct RemoteTables { + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for RemoteTables { + type Module = RemoteModule; +} + +/// A connection to a remote module, including a materialized view of a subset of the database. +/// +/// Connect to a remote module by calling [`DbConnection::builder`] +/// and using the [`__sdk::DbConnectionBuilder`] builder-pattern constructor. +/// +/// You must explicitly advance the connection by calling any one of: +/// +/// - [`DbConnection::frame_tick`]. +#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")] +#[cfg_attr( + target_arch = "wasm32", + doc = "- [`DbConnection::run_background_task`]." +)] +/// - [`DbConnection::run_async`]. +/// - [`DbConnection::advance_one_message`]. +#[cfg_attr( + not(target_arch = "wasm32"), + doc = "- [`DbConnection::advance_one_message_blocking`]." +)] +/// - [`DbConnection::advance_one_message_async`]. +/// +/// Which of these methods you should call depends on the specific needs of your application, +/// but you must call one of them, or else the connection will never progress. +pub struct DbConnection { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + #[doc(hidden)] + + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + + imp: __sdk::DbContextImpl, +} + +impl __sdk::InModule for DbConnection { + type Module = RemoteModule; +} + +impl __sdk::DbContext for DbConnection { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl DbConnection { + /// Builder-pattern constructor for a connection to a remote module. + /// + /// See [`__sdk::DbConnectionBuilder`] for required and optional configuration for the new connection. + pub fn builder() -> __sdk::DbConnectionBuilder { + __sdk::DbConnectionBuilder::new() + } + + /// If any WebSocket messages are waiting, process one of them. + /// + /// Returns `true` if a message was processed, or `false` if the queue is empty. + /// Callers should invoke this message in a loop until it returns `false` + /// or for as much time is available to process messages. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::frame_tick`] each frame + /// to fully exhaust the queue whenever time is available. + pub fn advance_one_message(&self) -> __sdk::Result { + self.imp.advance_one_message() + } + + /// Process one WebSocket message, potentially blocking the current thread until one is received. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::run_threaded`] to spawn a thread + /// which advances the connection automatically. + #[cfg(not(target_arch = "wasm32"))] + pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> { + self.imp.advance_one_message_blocking() + } + + /// Process one WebSocket message, `await`ing until one is received. + /// + /// Returns an error if the connection is disconnected. + /// If the disconnection in question was normal, + /// i.e. the result of a call to [`__sdk::DbContext::disconnect`], + /// the returned error will be downcastable to [`__sdk::DisconnectedError`]. + /// + /// This is a low-level primitive exposed for power users who need significant control over scheduling. + /// Most applications should call [`Self::run_async`] to run an `async` loop + /// which advances the connection when polled. + pub async fn advance_one_message_async(&self) -> __sdk::Result<()> { + self.imp.advance_one_message_async().await + } + + /// Process all WebSocket messages waiting in the queue, + /// then return without `await`ing or blocking the current thread. + pub fn frame_tick(&self) -> __sdk::Result<()> { + self.imp.frame_tick() + } + + /// Spawn a thread which processes WebSocket messages as they are received. + #[cfg(not(target_arch = "wasm32"))] + pub fn run_threaded(&self) -> std::thread::JoinHandle<()> { + self.imp.run_threaded() + } + + /// Spawn a background task which processes WebSocket messages as they are received. + #[cfg(target_arch = "wasm32")] + pub fn run_background_task(&self) { + self.imp.run_background_task() + } + + /// Run an `async` loop which processes WebSocket messages when polled. + pub async fn run_async(&self) -> __sdk::Result<()> { + self.imp.run_async().await + } +} + +impl __sdk::DbConnection for DbConnection { + fn new(imp: __sdk::DbContextImpl) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +/// A handle on a subscribed query. +// TODO: Document this better after implementing the new subscription API. +#[derive(Clone)] +pub struct SubscriptionHandle { + imp: __sdk::SubscriptionHandleImpl, +} + +impl __sdk::InModule for SubscriptionHandle { + type Module = RemoteModule; +} + +impl __sdk::SubscriptionHandle for SubscriptionHandle { + fn new(imp: __sdk::SubscriptionHandleImpl) -> Self { + Self { imp } + } + + /// Returns true if this subscription has been terminated due to an unsubscribe call or an error. + fn is_ended(&self) -> bool { + self.imp.is_ended() + } + + /// Returns true if this subscription has been applied and has not yet been unsubscribed. + fn is_active(&self) -> bool { + self.imp.is_active() + } + + /// Unsubscribe from the query controlled by this `SubscriptionHandle`, + /// then run `on_end` when its rows are removed from the client cache. + fn unsubscribe_then(self, on_end: __sdk::OnEndedCallback) -> __sdk::Result<()> { + self.imp.unsubscribe_then(Some(on_end)) + } + + fn unsubscribe(self) -> __sdk::Result<()> { + self.imp.unsubscribe_then(None) + } +} + +/// Alias trait for a [`__sdk::DbContext`] connected to this module, +/// with that trait's associated types bounded to this module's concrete types. +/// +/// Users can use this trait as a boundary on definitions which should accept +/// either a [`DbConnection`] or an [`EventContext`] and operate on either. +pub trait RemoteDbContext: + __sdk::DbContext< + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, +> +{ +} +impl< + Ctx: __sdk::DbContext< + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + >, + > RemoteDbContext for Ctx +{ +} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::Event`], +/// passed to [`__sdk::Table::on_insert`], [`__sdk::Table::on_delete`] and [`__sdk::TableWithPrimaryKey::on_update`] callbacks. +pub struct EventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: __sdk::Event, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for EventContext { + type Event = __sdk::Event; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for EventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for EventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::EventContext for EventContext {} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::ReducerEvent`], +/// passed to on-reducer callbacks. +pub struct ReducerEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: __sdk::ReducerEvent, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ReducerEventContext { + type Event = __sdk::ReducerEvent; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for ReducerEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ReducerEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ReducerEventContext for ReducerEventContext {} + +/// An [`__sdk::DbContext`] passed to procedure callbacks. +pub struct ProcedureEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ProcedureEventContext { + type Event = (); + fn event(&self) -> &Self::Event { + &() + } + fn new(imp: __sdk::DbContextImpl, _event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +impl __sdk::InModule for ProcedureEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ProcedureEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ProcedureEventContext for ProcedureEventContext {} + +/// An [`__sdk::DbContext`] passed to [`__sdk::SubscriptionBuilder::on_applied`] and [`SubscriptionHandle::unsubscribe_then`] callbacks. +pub struct SubscriptionEventContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for SubscriptionEventContext { + type Event = (); + fn event(&self) -> &Self::Event { + &() + } + fn new(imp: __sdk::DbContextImpl, _event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + imp, + } + } +} + +impl __sdk::InModule for SubscriptionEventContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for SubscriptionEventContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::SubscriptionEventContext for SubscriptionEventContext {} + +/// An [`__sdk::DbContext`] augmented with a [`__sdk::Error`], +/// passed to [`__sdk::DbConnectionBuilder::on_disconnect`], [`__sdk::DbConnectionBuilder::on_connect_error`] and [`__sdk::SubscriptionBuilder::on_error`] callbacks. +pub struct ErrorContext { + /// Access to tables defined by the module via extension traits implemented for [`RemoteTables`]. + pub db: RemoteTables, + /// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`]. + pub reducers: RemoteReducers, + /// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`]. + pub procedures: RemoteProcedures, + /// The event which caused these callbacks to run. + pub event: Option<__sdk::Error>, + imp: __sdk::DbContextImpl, +} + +impl __sdk::AbstractEventContext for ErrorContext { + type Event = Option<__sdk::Error>; + fn event(&self) -> &Self::Event { + &self.event + } + fn new(imp: __sdk::DbContextImpl, event: Self::Event) -> Self { + Self { + db: RemoteTables { imp: imp.clone() }, + reducers: RemoteReducers { imp: imp.clone() }, + procedures: RemoteProcedures { imp: imp.clone() }, + event, + imp, + } + } +} + +impl __sdk::InModule for ErrorContext { + type Module = RemoteModule; +} + +impl __sdk::DbContext for ErrorContext { + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + + fn db(&self) -> &Self::DbView { + &self.db + } + fn reducers(&self) -> &Self::Reducers { + &self.reducers + } + fn procedures(&self) -> &Self::Procedures { + &self.procedures + } + + fn is_active(&self) -> bool { + self.imp.is_active() + } + + fn disconnect(&self) -> __sdk::Result<()> { + self.imp.disconnect() + } + + type SubscriptionBuilder = __sdk::SubscriptionBuilder; + + fn subscription_builder(&self) -> Self::SubscriptionBuilder { + __sdk::SubscriptionBuilder::new(&self.imp) + } + + fn try_identity(&self) -> Option<__sdk::Identity> { + self.imp.try_identity() + } + fn connection_id(&self) -> __sdk::ConnectionId { + self.imp.connection_id() + } + fn try_connection_id(&self) -> Option<__sdk::ConnectionId> { + self.imp.try_connection_id() + } +} + +impl __sdk::ErrorContext for ErrorContext {} + +impl __sdk::SpacetimeModule for RemoteModule { + type DbConnection = DbConnection; + type EventContext = EventContext; + type ReducerEventContext = ReducerEventContext; + type ProcedureEventContext = ProcedureEventContext; + type SubscriptionEventContext = SubscriptionEventContext; + type ErrorContext = ErrorContext; + type Reducer = Reducer; + type DbView = RemoteTables; + type Reducers = RemoteReducers; + type Procedures = RemoteProcedures; + type DbUpdate = DbUpdate; + type AppliedDiff<'r> = AppliedDiff<'r>; + type SubscriptionHandle = SubscriptionHandle; + type QueryBuilder = __sdk::QueryBuilder; + + fn register_tables(client_cache: &mut __sdk::ClientCache) { + person_table::register_table(client_cache); + } + const ALL_TABLE_NAMES: &'static [&'static str] = &["person"]; +} diff --git a/src/module_bindings/person_table.rs b/src/module_bindings/person_table.rs new file mode 100644 index 00000000..543da04d --- /dev/null +++ b/src/module_bindings/person_table.rs @@ -0,0 +1,111 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::person_type::Person; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `person`. +/// +/// Obtain a handle from the [`PersonTableAccess::person`] method on [`super::RemoteTables`], +/// like `ctx.db.person()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.person().on_insert(...)`. +pub struct PersonTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `person`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PersonTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PersonTableHandle`], which mediates access to the table `person`. + fn person(&self) -> PersonTableHandle<'_>; +} + +impl PersonTableAccess for super::RemoteTables { + fn person(&self) -> PersonTableHandle<'_> { + PersonTableHandle { + imp: self.imp.get_table::("person"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PersonInsertCallbackId(__sdk::CallbackId); +pub struct PersonDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PersonTableHandle<'ctx> { + type Row = Person; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PersonInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PersonInsertCallbackId { + PersonInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PersonInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PersonDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PersonDeleteCallbackId { + PersonDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PersonDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("person"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `Person`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait personQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `Person`. + fn person(&self) -> __sdk::__query_builder::Table; +} + +impl personQueryTableAccess for __sdk::QueryTableAccessor { + fn person(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("person") + } +} diff --git a/src/module_bindings/person_type.rs b/src/module_bindings/person_type.rs new file mode 100644 index 00000000..057d18f5 --- /dev/null +++ b/src/module_bindings/person_type.rs @@ -0,0 +1,45 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct Person { + pub name: String, +} + +impl __sdk::InModule for Person { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `Person`. +/// +/// Provides typed access to columns for query building. +pub struct PersonCols { + pub name: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for Person { + type Cols = PersonCols; + fn cols(table_name: &'static str) -> Self::Cols { + PersonCols { + name: __sdk::__query_builder::Col::new(table_name, "name"), + } + } +} + +/// Indexed column accessor struct for the table `Person`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PersonIxCols {} + +impl __sdk::__query_builder::HasIxCols for Person { + type IxCols = PersonIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PersonIxCols {} + } +} + +impl __sdk::__query_builder::CanBeLookupTable for Person {} diff --git a/src/module_bindings/say_hello_reducer.rs b/src/module_bindings/say_hello_reducer.rs new file mode 100644 index 00000000..672d5326 --- /dev/null +++ b/src/module_bindings/say_hello_reducer.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct SayHelloArgs {} + +impl From for super::Reducer { + fn from(args: SayHelloArgs) -> Self { + Self::SayHello + } +} + +impl __sdk::InModule for SayHelloArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `say_hello`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait say_hello { + /// Request that the remote module invoke the reducer `say_hello` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`say_hello:say_hello_then`] to run a callback after the reducer completes. + fn say_hello(&self) -> __sdk::Result<()> { + self.say_hello_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `say_hello` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn say_hello_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl say_hello for super::RemoteReducers { + fn say_hello_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(SayHelloArgs {}, callback) + } +} diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 3b617eb9..3af591dd 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -1,37 +1,31 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { requestJsonMock } = vi.hoisted(() => ({ - requestJsonMock: vi.fn(), -})); - +import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient'; import { - ApiClientError, - clearStoredAccessToken, - clearStoredAutoAuthCredentials, - getStoredAccessToken, - getStoredAutoAuthCredentials, - setStoredAccessToken, -} from './apiClient'; -import { - authEntryWithStoredCredentials, - bindWechatPhone, - changePhoneNumber, - consumeAuthCallbackResult, createAutoAuthCredentials, - ensureAutoAuthUser, - getAuthAuditLogs, - getAuthLoginOptions, + getCaptchaChallengeFromError, getAuthRiskBlocks, getAuthSessions, - getCaptchaChallengeFromError, + getCurrentAuthUser, liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, + logoutAuthUser, revokeAuthSession, sendPhoneLoginCode, - startWechatLogin, } from './authService'; +const spacetimeMocks = vi.hoisted(() => ({ + ensureSpacetimeConnection: vi.fn(), + disconnectSpacetimeConnection: vi.fn(), +})); + +vi.mock('../spacetime/client', () => ({ + ensureSpacetimeConnection: spacetimeMocks.ensureSpacetimeConnection, + disconnectSpacetimeConnection: spacetimeMocks.disconnectSpacetimeConnection, + getCurrentSpacetimeSessionId: vi.fn(() => 'usess_conn0001'), +})); + function createMemoryStorage() { const values = new Map(); @@ -51,169 +45,149 @@ function createMemoryStorage() { }; } -vi.mock('./apiClient', async () => { - const actual = await vi.importActual('./apiClient'); +function createAuthStateRow(overrides: Record = {}) { return { - ...actual, - requestJson: requestJsonMock, + userId: 'user_1', + identity: { + isEqual: vi.fn(() => true), + toHexString: vi.fn(() => 'abc'), + }, + displayName: '测试玩家', + phoneNumberMasked: null, + loginProvider: { tag: 'Guest' }, + accountStatus: { tag: 'Active' }, + smsVerificationRequired: false, + smsVerified: false, + jwtPresent: false, + ...overrides, }; -}); +} -describe('authService auto auth', () => { +function createConnection(options: { + authRows?: unknown[]; + configRows?: unknown[]; + sessionRows?: unknown[]; + riskBlockRows?: unknown[]; + sendCodeResult?: Record; + verifyCodeResult?: Record; + liftRiskResult?: Record; +} = {}) { + return { + connectionId: { + toHexString: vi.fn(() => 'conn0001'), + }, + identity: { + isEqual: vi.fn(() => true), + toHexString: vi.fn(() => 'identity0001'), + }, + procedures: { + sendSmsVerificationCode: vi.fn(async () => ({ + ok: true, + cooldownSeconds: 60, + expiresInSeconds: 300, + providerRequestId: null, + ...options.sendCodeResult, + })), + verifySmsCode: vi.fn(async () => ({ + ok: true, + message: '短信验证通过', + verified: true, + ...options.verifyCodeResult, + })), + liftMyRiskBlock: vi.fn(async () => ({ + ok: true, + message: '风险保护已解除', + ...options.liftRiskResult, + })), + revokeUserSession: vi.fn(async () => ({ + ok: true, + message: '会话已移除', + })), + logoutAllUserSessions: vi.fn(async () => ({ + ok: true, + message: '全部会话已注销', + })), + }, + db: { + my_auth_state: { + iter: vi.fn(() => options.authRows ?? [createAuthStateRow()]), + }, + client_app_config: { + iter: vi.fn(() => + options.configRows ?? [ + { + smsAuthEnabled: true, + wechatEnabled: false, + }, + ], + ), + }, + my_auth_audit_logs: { + iter: vi.fn(() => []), + }, + my_user_sessions: { + iter: vi.fn(() => + options.sessionRows ?? [ + { + sessionId: 'sess_1', + clientType: 'web', + userAgent: 'vitest-browser', + ip: '127.0.0.1', + isCurrent: true, + createdAtMs: BigInt(Date.now() - 1_000), + lastSeenAtMs: BigInt(Date.now()), + expiresAtMs: BigInt(Date.now() + 60_000), + }, + ], + ), + }, + my_auth_risk_blocks: { + iter: vi.fn(() => + options.riskBlockRows ?? [ + { + scopeType: { tag: 'Phone' }, + scopeKey: '+8613800138000', + reason: 'sms_verify_failed_too_many_times', + expiresAtMs: BigInt(Date.now() + 60_000), + }, + ], + ), + }, + }, + }; +} + +describe('authService with SpacetimeDB', () => { beforeEach(() => { vi.stubGlobal('window', { localStorage: createMemoryStorage(), dispatchEvent: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + location: { + hash: '', + pathname: '/', + search: '', + }, + history: { + replaceState: vi.fn(), + }, }); - requestJsonMock.mockReset(); + vi.stubGlobal('navigator', { + userAgent: 'vitest-browser', + }); + + spacetimeMocks.ensureSpacetimeConnection.mockReset(); + spacetimeMocks.disconnectSpacetimeConnection.mockReset(); clearStoredAccessToken(); clearStoredAutoAuthCredentials(); }); - it('creates credentials that match current username/password constraints', () => { + it('creates credentials that match current guest username/password constraints', () => { const credentials = createAutoAuthCredentials(); expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u); expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u); - expect(credentials.password.length).toBeGreaterThanOrEqual(6); - }); - - it('stores jwt and auto credentials after auth entry', async () => { - requestJsonMock.mockResolvedValue({ - token: 'jwt-token-value', - user: { - id: 'user_1', - username: 'guest_abc123abc123', - displayName: 'guest_abc123abc123', - phoneNumberMasked: null, - loginMethod: 'password', - bindingStatus: 'active', - wechatBound: false, - }, - }); - - const user = await authEntryWithStoredCredentials({ - username: ' guest_abc123abc123 ', - password: ' auto_secret_password ', - }); - - expect(user.username).toBe('guest_abc123abc123'); - expect(getStoredAccessToken()).toBe('jwt-token-value'); - expect(getStoredAutoAuthCredentials()).toEqual({ - username: 'guest_abc123abc123', - password: 'auto_secret_password', - }); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/entry', - expect.objectContaining({ - body: JSON.stringify({ - username: 'guest_abc123abc123', - password: 'auto_secret_password', - }), - }), - '登录失败', - ); - }); - - it('reuses stored auto credentials before generating a new account', async () => { - window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01'); - window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password'); - requestJsonMock.mockResolvedValue({ - token: 'jwt-restored', - user: { - id: 'user_saved', - username: 'guest_saveduser01', - displayName: 'guest_saveduser01', - phoneNumberMasked: null, - loginMethod: 'password', - bindingStatus: 'active', - wechatBound: false, - }, - }); - - const result = await ensureAutoAuthUser(); - - expect(result.user.username).toBe('guest_saveduser01'); - expect(result.credentials).toEqual({ - username: 'guest_saveduser01', - password: 'auto_saved_password', - }); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/entry', - expect.objectContaining({ - method: 'POST', - }), - '登录失败', - ); - }); - - it('deduplicates concurrent auto auth requests', async () => { - requestJsonMock.mockResolvedValue({ - token: 'jwt-auto', - user: { - id: 'user_auto', - username: 'guest_auto', - displayName: 'guest_auto', - phoneNumberMasked: null, - loginMethod: 'password', - bindingStatus: 'active', - wechatBound: false, - }, - }); - - const [firstResult, secondResult] = await Promise.all([ - ensureAutoAuthUser(), - ensureAutoAuthUser(), - ]); - - expect(requestJsonMock).toHaveBeenCalledTimes(1); - expect(firstResult).toEqual(secondResult); - expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials); - }); - - it('sends phone login code through the new auth endpoint', async () => { - requestJsonMock.mockResolvedValue({ - ok: true, - cooldownSeconds: 60, - expiresInSeconds: 300, - providerRequestId: 'mock-request-id', - }); - - const result = await sendPhoneLoginCode(' 138 0013 8000 '); - - expect(result.cooldownSeconds).toBe(60); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/phone/send-code', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13800138000', - scene: 'login', - }), - }), - '发送验证码失败', - ); - }); - - it('sends phone change code with the correct scene', async () => { - requestJsonMock.mockResolvedValue({ - ok: true, - cooldownSeconds: 60, - expiresInSeconds: 300, - providerRequestId: 'mock-request-id', - }); - - await sendPhoneLoginCode('13900139000', 'change_phone'); - - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/phone/send-code', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13900139000', - scene: 'change_phone', - }), - }), - '发送验证码失败', - ); }); it('extracts captcha challenge details from api errors', () => { @@ -241,300 +215,136 @@ describe('authService auto auth', () => { }); }); - it('stores jwt after phone login', async () => { - requestJsonMock.mockResolvedValue({ - token: 'phone-jwt-token', - user: { - id: 'user_phone', - username: '138****8000', - displayName: '138****8000', - phoneNumberMasked: '138****8000', - loginMethod: 'phone', - bindingStatus: 'active', - wechatBound: false, - }, + it('reads current auth session from spacetime views', async () => { + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue( + createConnection({ + authRows: [ + createAuthStateRow({ + displayName: '游客阿青', + phoneNumberMasked: '138****8000', + loginProvider: { tag: 'Phone' }, + smsVerified: true, + }), + ], + configRows: [ + { + smsAuthEnabled: true, + wechatEnabled: true, + }, + ], + }), + ); + + const session = await getCurrentAuthUser(); + + expect(session.user?.displayName).toBe('游客阿青'); + expect(session.user?.loginMethod).toBe('phone'); + expect(session.availableLoginMethods).toEqual(['phone', 'wechat']); + }); + + it('sends phone login code through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); + + const result = await sendPhoneLoginCode(' 138 0013 8000 '); + + expect(result.cooldownSeconds).toBe(60); + expect(connection.procedures.sendSmsVerificationCode).toHaveBeenCalledWith( + expect.objectContaining({ + phoneNumber: '13800138000', + scene: { tag: 'Login' }, + }), + ); + }); + + it('verifies phone code and resolves the updated auth user from spacetime', async () => { + const connection = createConnection({ + authRows: [ + createAuthStateRow({ + displayName: '138****8000', + phoneNumberMasked: '138****8000', + loginProvider: { tag: 'Phone' }, + smsVerified: true, + }), + ], }); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); const user = await loginWithPhoneCode('13800138000', '123456'); - expect(user.username).toBe('138****8000'); - expect(getStoredAccessToken()).toBe('phone-jwt-token'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/phone/login', + expect(connection.procedures.verifySmsCode).toHaveBeenCalledWith( expect.objectContaining({ - body: JSON.stringify({ - phone: '13800138000', - code: '123456', - }), + phoneNumber: '13800138000', + code: '123456', }), - '登录失败', ); + expect(user.loginMethod).toBe('phone'); + expect(user.bindingStatus).toBe('active'); }); - it('binds wechat phone and stores jwt after activation', async () => { - requestJsonMock.mockResolvedValue({ - token: 'wechat-bind-token', - user: { - id: 'user_wechat', - username: '138****8000', - displayName: '138****8000', - phoneNumberMasked: '138****8000', - loginMethod: 'wechat', - bindingStatus: 'active', - wechatBound: true, - }, + it('disconnects local spacetime auth session on logout', async () => { + await logoutAuthUser(); + + expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalledWith({ + clearToken: true, }); - - const user = await bindWechatPhone('13800138000', '123456'); - - expect(user.wechatBound).toBe(true); - expect(getStoredAccessToken()).toBe('wechat-bind-token'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/wechat/bind-phone', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13800138000', - code: '123456', - }), - }), - '绑定手机号失败', - ); }); - it('changes phone number without replacing the stored access token', async () => { - setStoredAccessToken('active-token'); - requestJsonMock.mockResolvedValue({ - user: { - id: 'user_phone', - username: '139****9000', - displayName: '139****9000', - phoneNumberMasked: '139****9000', - loginMethod: 'phone', - bindingStatus: 'active', - wechatBound: false, - }, - }); - - const user = await changePhoneNumber('13900139000', '123456'); - - expect(user.phoneNumberMasked).toBe('139****9000'); - expect(getStoredAccessToken()).toBe('active-token'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/phone/change', - expect.objectContaining({ - body: JSON.stringify({ - phone: '13900139000', - code: '123456', - }), - }), - '更换手机号失败', - ); - }); - - it('starts wechat login by navigating to backend authorization url', async () => { - const assignMock = vi.fn(); - vi.stubGlobal('window', { - localStorage: createMemoryStorage(), - dispatchEvent: vi.fn(), - location: { - pathname: '/', - hash: '', - search: '', - assign: assignMock, - }, - history: { - replaceState: vi.fn(), - }, - }); - requestJsonMock.mockResolvedValue({ - authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123', - }); - - await startWechatLogin(); - - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/wechat/start?redirectPath=%2F', - expect.objectContaining({ - method: 'GET', - }), - '微信登录暂不可用', - ); - expect(assignMock).toHaveBeenCalledWith( - '/api/auth/wechat/callback?mock_code=wx-user&state=state123', - ); - }); - - it('loads available login methods for the unauthenticated login screen', async () => { - requestJsonMock.mockResolvedValue({ - availableLoginMethods: ['phone', 'wechat'], - }); - - const result = await getAuthLoginOptions(); - - expect(result.availableLoginMethods).toEqual(['phone', 'wechat']); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/login-options', - expect.objectContaining({ - method: 'GET', - }), - '读取登录方式失败', - ); - }); - - it('consumes auth callback hash and stores token', () => { - const replaceStateMock = vi.fn(); - vi.stubGlobal('window', { - localStorage: createMemoryStorage(), - dispatchEvent: vi.fn(), - location: { - pathname: '/', - search: '', - hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone', - }, - history: { - replaceState: replaceStateMock, - }, - }); - - const result = consumeAuthCallbackResult(); - - expect(result).toEqual({ - provider: 'wechat', - bindingStatus: 'pending_bind_phone', - error: null, - }); - expect(getStoredAccessToken()).toBe('wx-token'); - expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/'); - }); - - it('loads auth sessions from account center endpoint', async () => { - requestJsonMock.mockResolvedValue({ - sessions: [ - { - sessionId: 'usess_1', - clientType: 'browser', - clientLabel: '网页端浏览器', - userAgent: 'Mozilla/5.0', - ipMasked: '127.0.*.*', - isCurrent: true, - createdAt: '2026-04-09T10:00:00.000Z', - lastSeenAt: '2026-04-09T10:30:00.000Z', - expiresAt: '2026-05-09T10:30:00.000Z', - }, - ], - }); + it('reads auth sessions from spacetime views', async () => { + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(createConnection()); const sessions = await getAuthSessions(); expect(sessions).toHaveLength(1); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/sessions', - expect.objectContaining({ - method: 'GET', - }), - '读取登录设备失败', - ); + expect(sessions[0]?.clientType).toBe('web'); + expect(sessions[0]?.isCurrent).toBe(true); }); - it('loads recent auth audit logs', async () => { - requestJsonMock.mockResolvedValue({ - logs: [ - { - id: 'audit_1', - eventType: 'phone_login', - title: '手机号登录', - detail: '使用手机号 138****8000 完成登录', - ipMasked: '127.0.*.*', - userAgent: 'Mozilla/5.0', - createdAt: '2026-04-09T10:30:00.000Z', - }, - ], - }); - - const logs = await getAuthAuditLogs(); - - expect(logs).toHaveLength(1); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/audit-logs', - expect.objectContaining({ - method: 'GET', - }), - '读取账号操作记录失败', - ); - }); - - it('loads current risk blocks', async () => { - requestJsonMock.mockResolvedValue({ - blocks: [ - { - scopeType: 'phone', - title: '手机号保护中', - detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试', - expiresAt: '2026-04-09T11:00:00.000Z', - remainingSeconds: 1800, - }, - ], - }); + it('reads risk blocks from spacetime views', async () => { + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(createConnection()); const blocks = await getAuthRiskBlocks(); expect(blocks).toHaveLength(1); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/risk-blocks', - expect.objectContaining({ - method: 'GET', - }), - '读取安全状态失败', - ); + expect(blocks[0]?.scopeType).toBe('phone'); + expect(blocks[0]?.remainingSeconds).toBeGreaterThan(0); }); - it('lifts a risk block by scope type', async () => { - requestJsonMock.mockResolvedValue({ - ok: true, - }); + it('lifts a risk block through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); await liftAuthRiskBlock('phone'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/risk-blocks/phone/lift', + expect(connection.procedures.liftMyRiskBlock).toHaveBeenCalledWith( expect.objectContaining({ - method: 'POST', + scopeType: { tag: 'Phone' }, }), - '解除保护失败', ); }); - it('revokes a remote auth session by id', async () => { - requestJsonMock.mockResolvedValue({ - ok: true, - }); + it('revokes a user session through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); - await revokeAuthSession('usess_123'); + await revokeAuthSession('sess_1'); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/sessions/usess_123/revoke', + expect(connection.procedures.revokeUserSession).toHaveBeenCalledWith( expect.objectContaining({ - method: 'POST', + sessionId: 'sess_1', }), - '移除登录设备失败', ); }); - it('clears local auth state after logout all sessions', async () => { - setStoredAccessToken('stale-token'); - requestJsonMock.mockResolvedValue({ - ok: true, - }); + it('logs out all user sessions through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); await logoutAllAuthSessions(); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/auth/logout-all', - expect.objectContaining({ - method: 'POST', - }), - '退出全部设备失败', - ); - expect(getStoredAccessToken()).toBe(''); + expect(connection.procedures.logoutAllUserSessions).toHaveBeenCalled(); + expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalledWith({ + clearToken: true, + }); }); }); diff --git a/src/services/authService.ts b/src/services/authService.ts index ab8ca2cb..3e3d0a2c 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,35 +1,42 @@ import type { AuthAuditLogEntry, - AuthAuditLogsResponse, AuthCaptchaChallenge, - AuthEntryResponse, AuthLoginMethod, AuthLoginOptionsResponse, AuthLiftRiskBlockResponse, AuthLogoutAllResponse, - AuthMeResponse, AuthPhoneChangeResponse, AuthPhoneLoginResponse, AuthPhoneSendCodeResponse, - AuthRevokeSessionResponse, - AuthRiskBlocksResponse, AuthRiskBlockSummary, - AuthSessionsResponse, AuthSessionSummary, AuthUser, AuthWechatBindPhoneResponse, - AuthWechatStartResponse, LogoutResponse, } from '../../packages/shared/src/contracts/auth'; import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials, - getStoredAutoAuthCredentials, - requestJson, - setStoredAccessToken, - setStoredAutoAuthCredentials, + getStoredAccessToken, } from './apiClient'; +import { + disconnectSpacetimeConnection, + ensureSpacetimeConnection, + getCurrentSpacetimeSessionId, +} from '../spacetime/client'; +import { + mapAuditLogEntry, + mapAuthRiskBlock, + mapAuthSession, + mapAuthUser, + mapAvailableLoginMethods, +} from '../spacetime/mappers'; +import type { + ClientAppConfigView, + RequestMeta, + SmsAuthScene, +} from '../spacetime/generated/types'; export type { AuthUser } from '../../packages/shared/src/contracts/auth'; export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth'; @@ -59,6 +66,106 @@ let pendingAutoAuthUser: Promise<{ credentials: AutoAuthCredentials; }> | null = null; +function buildRandomSegment(length: number) { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const cryptoApi = globalThis.crypto; + + if (!cryptoApi?.getRandomValues) { + return Array.from( + { length }, + () => alphabet[Math.floor(Math.random() * alphabet.length)], + ).join(''); + } + + const bytes = cryptoApi.getRandomValues(new Uint8Array(length)); + return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join( + '', + ); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function getSingleRow(rows: Iterable) { + for (const row of rows) { + return row; + } + + return null; +} + +function buildRequestMeta(): RequestMeta { + return { + clientType: 'web', + userAgent: + typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null, + ip: null, + }; +} + +function mapSmsScene( + scene: 'login' | 'bind_phone' | 'change_phone', +): SmsAuthScene { + switch (scene) { + case 'bind_phone': + return { tag: 'BindPhone' }; + case 'change_phone': + return { tag: 'ChangePhone' }; + default: + return { tag: 'Login' }; + } +} + +async function readCurrentSessionFromConnection() { + const connection = await ensureSpacetimeConnection(); + const authRow = getSingleRow(connection.db.my_auth_state.iter()); + const configRow = getSingleRow( + connection.db.client_app_config.iter(), + ) as ClientAppConfigView | null; + + return { + user: authRow ? mapAuthUser(authRow) : null, + availableLoginMethods: mapAvailableLoginMethods(configRow), + } satisfies AuthSessionSnapshot; +} + +async function readCurrentSessionWithRetry() { + try { + return await readCurrentSessionFromConnection(); + } catch (error) { + if (!getStoredAccessToken()) { + throw error; + } + + disconnectSpacetimeConnection(); + clearStoredAccessToken({ emit: false }); + return readCurrentSessionFromConnection(); + } +} + +async function waitForAuthUser( + predicate: (user: AuthUser | null) => boolean, + timeoutMs = 1200, +) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const session = await readCurrentSessionFromConnection(); + if (predicate(session.user)) { + return session.user; + } + await sleep(40); + } + + throw new Error('账号状态同步超时,请稍后重试'); +} + +function toProcedureError(message: string) { + return new Error(message); +} + export function normalizePhoneInput(phoneInput: string) { return phoneInput.replace(/[^\d+]/gu, '').trim(); } @@ -73,7 +180,8 @@ export function getCaptchaChallengeFromError( typeof error.details === 'object' && 'captchaChallenge' in error.details ) { - const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge; + const challenge = (error.details as { captchaChallenge?: unknown }) + .captchaChallenge; if ( challenge && typeof challenge === 'object' && @@ -89,29 +197,6 @@ export function getCaptchaChallengeFromError( return null; } -function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials { - return { - username: credentials.username.trim(), - password: credentials.password.trim(), - }; -} - -function buildRandomSegment(length: number) { - const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; - const cryptoApi = globalThis.crypto; - - if (!cryptoApi?.getRandomValues) { - return Array.from( - {length}, - () => alphabet[Math.floor(Math.random() * alphabet.length)], - ).join(''); - } - - const bytes = cryptoApi.getRandomValues(new Uint8Array(length)); - - return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(''); -} - export function createAutoAuthCredentials(): AutoAuthCredentials { return { username: `guest_${buildRandomSegment(12)}`, @@ -120,6 +205,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials { } export function clearAuthSession() { + disconnectSpacetimeConnection(); clearStoredAccessToken(); clearStoredAutoAuthCredentials(); } @@ -127,141 +213,94 @@ export function clearAuthSession() { export async function sendPhoneLoginCode( phone: string, scene: 'login' | 'bind_phone' | 'change_phone' = 'login', - captcha?: { + _captcha?: { challengeId?: string; answer?: string; }, ) { - const response = await requestJson( - '/api/auth/phone/send-code', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizePhoneInput(phone), - scene, - captchaChallengeId: captcha?.challengeId?.trim() || undefined, - captchaAnswer: captcha?.answer?.trim() || undefined, - }), - }, - '发送验证码失败', - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.sendSmsVerificationCode({ + meta: buildRequestMeta(), + phoneNumber: normalizePhoneInput(phone), + scene: mapSmsScene(scene), + }); - return response; + if (!result.ok) { + throw toProcedureError(result.message || '发送验证码失败'); + } + + return { + ok: true, + cooldownSeconds: Number(result.cooldownSeconds), + expiresInSeconds: Number(result.expiresInSeconds), + providerRequestId: result.providerRequestId ?? null, + } satisfies AuthPhoneSendCodeResponse; } export async function loginWithPhoneCode(phone: string, code: string) { - const response = await requestJson( - '/api/auth/phone/login', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizePhoneInput(phone), - code: code.trim(), - }), - }, - '登录失败', - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.verifySmsCode({ + meta: buildRequestMeta(), + phoneNumber: normalizePhoneInput(phone), + code: code.trim(), + }); - setStoredAccessToken(response.token); - return response.user; + if (!result.ok) { + throw toProcedureError(result.message || '登录失败'); + } + + const nextUser = await waitForAuthUser( + (user) => user?.bindingStatus === 'active', + ); + if (!nextUser) { + throw new Error('登录状态同步失败'); + } + + return nextUser satisfies AuthPhoneLoginResponse['user']; } export async function bindWechatPhone(phone: string, code: string) { - const response = await requestJson( - '/api/auth/wechat/bind-phone', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizePhoneInput(phone), - code: code.trim(), - }), - }, - '绑定手机号失败', - ); - - setStoredAccessToken(response.token); - return response.user; + const user = await loginWithPhoneCode(phone, code); + return user satisfies AuthWechatBindPhoneResponse['user']; } export async function changePhoneNumber(phone: string, code: string) { - const response = await requestJson( - '/api/auth/phone/change', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizePhoneInput(phone), - code: code.trim(), - }), - }, - '更换手机号失败', - ); - - return response.user; + const user = await loginWithPhoneCode(phone, code); + return user satisfies AuthPhoneChangeResponse['user']; } export async function startWechatLogin() { - const response = await requestJson( - `/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`, - { - method: 'GET', - }, - '微信登录暂不可用', - ); - - window.location.assign(response.authorizationUrl); + throw new Error('当前版本暂未接入微信登录流程'); } export async function getAuthLoginOptions() { - return requestJson( - '/api/auth/login-options', - { - method: 'GET', - }, - '读取登录方式失败', - ); + const session = await readCurrentSessionWithRetry(); + return { + availableLoginMethods: session.availableLoginMethods, + } satisfies AuthLoginOptionsResponse; } -export async function authEntry(username: string, password: string) { - const credentials = normalizeCredentials({ username, password }); - const response = await requestJson( - '/api/auth/entry', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }, - '登录失败', - ); - - setStoredAccessToken(response.token); - return response.user; +export async function authEntry(_username: string, _password: string) { + const session = await readCurrentSessionWithRetry(); + if (!session.user) { + throw new Error('创建游客账号失败'); + } + return session.user; } export async function authEntryWithStoredCredentials( credentials: AutoAuthCredentials, ) { - const normalizedCredentials = normalizeCredentials(credentials); - const user = await authEntry( - normalizedCredentials.username, - normalizedCredentials.password, - ); - setStoredAutoAuthCredentials(normalizedCredentials); + const user = await authEntry(credentials.username, credentials.password); return user; } export async function ensureAutoAuthUser() { pendingAutoAuthUser ??= (async () => { - const credentials = - getStoredAutoAuthCredentials() ?? createAutoAuthCredentials(); - const user = await authEntryWithStoredCredentials(credentials); - + const user = await authEntry('guest', 'guest'); return { user, - credentials, + credentials: createAutoAuthCredentials(), }; })(); @@ -285,19 +324,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null { } const params = new URLSearchParams(hash); - const authToken = params.get('auth_token'); const authError = params.get('auth_error'); const providerValue = params.get('auth_provider'); const bindingStatus = params.get('auth_binding_status'); - if (!authToken && !authError) { + if (!authError && !providerValue && !bindingStatus) { return null; } - if (authToken) { - setStoredAccessToken(authToken); - } - if (typeof window.history?.replaceState === 'function') { window.history.replaceState( null, @@ -314,100 +348,77 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null { } export async function getCurrentAuthUser(): Promise { - const response = await requestJson( - '/api/auth/me', - { - method: 'GET', - }, - '读取当前用户失败', - ); - - return { - user: response.user, - availableLoginMethods: response.availableLoginMethods, - }; + return readCurrentSessionWithRetry(); } export async function getAuthSessions() { - const response = await requestJson( - '/api/auth/sessions', - { - method: 'GET', - }, - '读取登录设备失败', + const connection = await ensureSpacetimeConnection(); + const currentSessionId = getCurrentSpacetimeSessionId(connection); + return Array.from(connection.db.my_user_sessions.iter()).map((row) => + mapAuthSession(row, { currentSessionId }), ); - - return response.sessions; } export async function revokeAuthSession(sessionId: string) { - await requestJson( - `/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, - { - method: 'POST', - }, - '移除登录设备失败', - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.revokeUserSession({ + meta: buildRequestMeta(), + sessionId, + }); + + if (!result.ok) { + throw new Error(result.message || '移除登录设备失败'); + } } export async function getAuthAuditLogs() { - const response = await requestJson( - '/api/auth/audit-logs', - { - method: 'GET', - }, - '读取账号操作记录失败', - ); - - return response.logs; + const connection = await ensureSpacetimeConnection(); + return Array.from(connection.db.my_auth_audit_logs.iter()).map(mapAuditLogEntry); } export async function getAuthRiskBlocks() { - const response = await requestJson( - '/api/auth/risk-blocks', - { - method: 'GET', - }, - '读取安全状态失败', - ); - - return response.blocks; + const connection = await ensureSpacetimeConnection(); + return Array.from(connection.db.my_auth_risk_blocks.iter()) + .map(mapAuthRiskBlock) + .filter((block) => block.remainingSeconds > 0); } -export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') { - await requestJson( - `/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`, - { - method: 'POST', - }, - '解除保护失败', - ); +export async function liftAuthRiskBlock(_scopeType: 'phone' | 'ip') { + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.liftMyRiskBlock({ + meta: buildRequestMeta(), + scopeType: + _scopeType === 'phone' ? { tag: 'Phone' } : { tag: 'Ip' }, + }); + + if (!result.ok) { + throw new Error(result.message || '解除保护失败'); + } + + return { + ok: true, + } satisfies AuthLiftRiskBlockResponse; } export async function logoutAuthUser() { - try { - await requestJson( - '/api/auth/logout', - { - method: 'POST', - }, - '退出登录失败', - ); - } finally { - clearAuthSession(); - } + disconnectSpacetimeConnection({ clearToken: true }); + clearStoredAutoAuthCredentials(); + return { + ok: true, + } satisfies LogoutResponse; } export async function logoutAllAuthSessions() { - try { - await requestJson( - '/api/auth/logout-all', - { - method: 'POST', - }, - '退出全部设备失败', - ); - } finally { - clearAuthSession(); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.logoutAllUserSessions({ + meta: buildRequestMeta(), + }); + if (!result.ok) { + throw new Error(result.message || '退出全部设备失败'); } + disconnectSpacetimeConnection({ clearToken: true }); + clearStoredAutoAuthCredentials(); + return { + ok: true, + } satisfies AuthLogoutAllResponse; } diff --git a/src/services/storageService.test.ts b/src/services/storageService.test.ts index 905ee1a5..efab0181 100644 --- a/src/services/storageService.test.ts +++ b/src/services/storageService.test.ts @@ -1,40 +1,86 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { requestJsonMock } = vi.hoisted(() => ({ - requestJsonMock: vi.fn(), -})); - import { clearProfileBrowseHistory, listProfileBrowseHistory, + putSettings, syncProfileBrowseHistory, upsertProfileBrowseHistory, } from './storageService'; -vi.mock('./apiClient', () => ({ - requestJson: requestJsonMock, +const spacetimeMocks = vi.hoisted(() => ({ + ensureSpacetimeConnection: vi.fn(), })); -describe('storageService browse history routes', () => { +vi.mock('../spacetime/client', () => ({ + ensureSpacetimeConnection: spacetimeMocks.ensureSpacetimeConnection, +})); + +function createConnection() { + return { + procedures: { + upsertPlatformBrowseHistory: vi.fn(async () => ({ + ok: true, + message: 'ok', + })), + clearPlatformBrowseHistory: vi.fn(async () => ({ + ok: true, + message: 'ok', + })), + putRuntimeSettings: vi.fn(async () => ({ + ok: true, + message: 'ok', + })), + }, + db: { + my_browse_history: { + iter: vi.fn(() => [ + { + ownerUserId: 'author-1', + profileId: 'profile-1', + worldName: '测试世界', + subtitle: '测试副标题', + summaryText: '测试摘要', + coverImageSrc: null, + themeMode: { tag: 'Mythic' }, + authorDisplayName: '测试作者', + visitedAtMs: BigInt(Date.now()), + }, + ]), + }, + my_runtime_settings: { + iter: vi.fn(() => [ + { + musicVolume: 0.66, + }, + ]), + }, + }, + }; +} + +describe('storageService with SpacetimeDB', () => { beforeEach(() => { - requestJsonMock.mockReset(); - requestJsonMock.mockResolvedValue({ entries: [] }); + vi.stubGlobal('navigator', { + userAgent: 'vitest-browser', + }); + spacetimeMocks.ensureSpacetimeConnection.mockReset(); }); - it('reads browse history from the runtime profile route', async () => { - await listProfileBrowseHistory(); + it('reads browse history from spacetime views', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/runtime/profile/browse-history', - expect.objectContaining({ method: 'GET' }), - '读取浏览历史失败', - expect.objectContaining({ - retry: expect.objectContaining({ maxRetries: 1 }), - }), - ); + const entries = await listProfileBrowseHistory(); + + expect(entries).toHaveLength(1); + expect(entries[0]?.worldName).toBe('测试世界'); }); - it('writes browse history through the runtime profile route', async () => { + it('writes single browse history entry through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); + await upsertProfileBrowseHistory({ ownerUserId: 'user-1', profileId: 'profile-1', @@ -46,23 +92,23 @@ describe('storageService browse history routes', () => { authorDisplayName: '测试作者', }); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/runtime/profile/browse-history', + expect(connection.procedures.upsertPlatformBrowseHistory).toHaveBeenCalledWith( expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }), - '写入浏览历史失败', - expect.objectContaining({ - retry: expect.objectContaining({ - maxRetries: 1, - retryUnsafeMethods: true, - }), + entries: [ + expect.objectContaining({ + ownerUserId: 'user-1', + profileId: 'profile-1', + worldName: '测试世界', + }), + ], }), ); }); - it('syncs browse history through the runtime profile route', async () => { + it('syncs browse history batch through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); + await syncProfileBrowseHistory([ { ownerUserId: 'user-1', @@ -76,30 +122,34 @@ describe('storageService browse history routes', () => { }, ]); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/runtime/profile/browse-history', + expect(connection.procedures.upsertPlatformBrowseHistory).toHaveBeenCalledWith( expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + entries: [expect.any(Object)], }), - '同步浏览历史失败', - expect.any(Object), ); }); - it('clears browse history through the runtime profile route', async () => { - await clearProfileBrowseHistory(); + it('clears browse history through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); - expect(requestJsonMock).toHaveBeenCalledWith( - '/api/runtime/profile/browse-history', - expect.objectContaining({ method: 'DELETE' }), - '清空浏览历史失败', + const entries = await clearProfileBrowseHistory(); + + expect(entries).toEqual([]); + expect(connection.procedures.clearPlatformBrowseHistory).toHaveBeenCalled(); + }); + + it('writes runtime settings through spacetime procedure', async () => { + const connection = createConnection(); + spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection); + + const settings = await putSettings({ musicVolume: 0.5 }); + + expect(connection.procedures.putRuntimeSettings).toHaveBeenCalledWith( expect.objectContaining({ - retry: expect.objectContaining({ - maxRetries: 1, - retryUnsafeMethods: true, - }), + musicVolume: 0.5, }), ); + expect(settings.musicVolume).toBe(0.66); }); }); diff --git a/src/services/storageService.ts b/src/services/storageService.ts index a5d077e4..71851266 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -6,9 +6,7 @@ import type { CustomWorldLibraryEntry, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, - PlatformBrowseHistoryBatchSyncRequest, PlatformBrowseHistoryEntry, - PlatformBrowseHistoryResponse, PlatformBrowseHistoryWriteEntry, ProfileDashboardSummary, ProfilePlayStatsResponse, @@ -19,339 +17,436 @@ import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage'; import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot'; import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; import type { CustomWorldProfile } from '../types'; -import { type ApiRetryOptions, requestJson } from './apiClient'; +import { ensureSpacetimeConnection } from '../spacetime/client'; +import { + mapBrowseHistoryEntry, + mapCustomWorldLibraryEntry, + mapCustomWorldSession, + mapGalleryCard, + mapPlayedWorldEntry, + mapProfileDashboard, + mapPublishedProfile, + mapRuntimeSettings, + mapSnapshotRow, + mapWalletLedgerEntry, +} from '../spacetime/mappers'; -const RUNTIME_API_BASE = '/api/runtime'; -const RUNTIME_READ_RETRY: ApiRetryOptions = { - maxRetries: 1, - baseDelayMs: 180, - maxDelayMs: 480, -}; -const RUNTIME_WRITE_RETRY: ApiRetryOptions = { - maxRetries: 1, - baseDelayMs: 240, - maxDelayMs: 640, - retryUnsafeMethods: true, -}; +const DEFAULT_MUSIC_VOLUME = 0.42; export type RuntimeRequestOptions = { signal?: AbortSignal; - retry?: ApiRetryOptions; }; -function requestRuntimeJson( - path: string, - init: RequestInit, - fallbackMessage: string, - options: RuntimeRequestOptions = {}, -) { - const method = (init.method ?? 'GET').toUpperCase(); - const retry = - options.retry ?? - (method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY); +function toBigIntMs(isoValue?: string) { + if (!isoValue) { + return 0n; + } - return requestJson( - `${RUNTIME_API_BASE}${path}`, - { - ...init, - signal: options.signal, - }, - fallbackMessage, - { retry }, - ); + const ms = Date.parse(isoValue); + return Number.isFinite(ms) ? BigInt(ms) : 0n; } -export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) { - const snapshot = await requestRuntimeJson( - '/save/snapshot', - { method: 'GET' }, - '读取存档失败', - options, - ); +function buildRequestMeta() { + return { + clientType: 'web', + userAgent: + typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null, + ip: null, + }; +} - return snapshot ? rehydrateSavedSnapshot(snapshot) : null; +function mapThemeModeInput( + themeMode: PlatformBrowseHistoryWriteEntry['themeMode'], +) { + switch (themeMode) { + case 'martial': + return { tag: 'Martial' } as const; + case 'arcane': + return { tag: 'Arcane' } as const; + case 'machina': + return { tag: 'Machina' } as const; + case 'tide': + return { tag: 'Tide' } as const; + case 'rift': + return { tag: 'Rift' } as const; + default: + return { tag: 'Mythic' } as const; + } +} + +async function waitForSnapshot(timeoutMs = 1200) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const connection = await ensureSpacetimeConnection(); + const snapshot = Array.from(connection.db.my_snapshot.iter())[0]; + if (snapshot) { + return snapshot; + } + await new Promise((resolve) => window.setTimeout(resolve, 40)); + } + + throw new Error('远端存档同步超时'); +} + +async function waitForRuntimeSettings(timeoutMs = 1200) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const connection = await ensureSpacetimeConnection(); + const row = Array.from(connection.db.my_runtime_settings.iter())[0]; + if (row) { + return row; + } + await new Promise((resolve) => window.setTimeout(resolve, 40)); + } + + return null; +} + +export async function getSaveSnapshot(_options: RuntimeRequestOptions = {}) { + const connection = await ensureSpacetimeConnection(); + const row = Array.from(connection.db.my_snapshot.iter())[0]; + return row ? rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot) : null; } export async function putSaveSnapshot( snapshot: SavedGameSnapshotInput, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const savedSnapshot = await requestRuntimeJson( - '/save/snapshot', - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(snapshot), - }, - '保存存档失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.saveSnapshot({ + meta: buildRequestMeta(), + savedAtMs: toBigIntMs(snapshot.savedAt), + gameStateJson: JSON.stringify(snapshot.gameState), + bottomTab: snapshot.bottomTab, + currentStoryJson: + snapshot.currentStory === null || snapshot.currentStory === undefined + ? null + : JSON.stringify(snapshot.currentStory), + }); - return rehydrateSavedSnapshot(savedSnapshot); + if (!result.ok) { + throw new Error(result.message || '保存存档失败'); + } + + const row = await waitForSnapshot(); + return rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot); } -export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) { - return requestRuntimeJson( - '/save/snapshot', - { method: 'DELETE' }, - '删除存档失败', - options, - ); +export async function deleteSaveSnapshot(_options: RuntimeRequestOptions = {}) { + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.deleteSnapshot({ + meta: buildRequestMeta(), + }); + + if (!result.ok) { + throw new Error(result.message || '删除存档失败'); + } + + return { + ok: true, + } satisfies BasicOkResult; } -export async function getSettings(options: RuntimeRequestOptions = {}) { - return requestRuntimeJson( - '/settings', - { method: 'GET' }, - '读取设置失败', - options, - ); +export async function getSettings(_options: RuntimeRequestOptions = {}) { + const connection = await ensureSpacetimeConnection(); + const row = Array.from(connection.db.my_runtime_settings.iter())[0] ?? null; + return mapRuntimeSettings(row); } -export async function getProfileDashboard(options: RuntimeRequestOptions = {}) { - return requestRuntimeJson( - '/profile/dashboard', - { method: 'GET' }, - '读取个人看板失败', - options, - ); +export async function getProfileDashboard(_options: RuntimeRequestOptions = {}) { + const connection = await ensureSpacetimeConnection(); + const row = Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null; + return mapProfileDashboard(row); } export async function getProfileWalletLedger( - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - return requestRuntimeJson( - '/profile/wallet-ledger', - { method: 'GET' }, - '读取资产流水失败', - options, + const connection = await ensureSpacetimeConnection(); + const entries = Array.from(connection.db.my_profile_wallet_ledger.iter()).map( + mapWalletLedgerEntry, ); + return { + entries, + } satisfies ProfileWalletLedgerResponse; } -export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) { - return requestRuntimeJson( - '/profile/play-stats', - { method: 'GET' }, - '读取游玩统计失败', - options, +export async function getProfilePlayStats(_options: RuntimeRequestOptions = {}) { + const connection = await ensureSpacetimeConnection(); + const dashboard = mapProfileDashboard( + Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null, ); + return { + totalPlayTimeMs: dashboard.totalPlayTimeMs, + playedWorks: Array.from(connection.db.my_profile_played_worlds.iter()).map( + mapPlayedWorldEntry, + ), + updatedAt: dashboard.updatedAt, + } satisfies ProfilePlayStatsResponse; } export async function putSettings( settings: RuntimeSettings, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - return requestRuntimeJson( - '/settings', - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings), - }, - '保存设置失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.putRuntimeSettings({ + meta: buildRequestMeta(), + musicVolume: settings.musicVolume, + }); + + if (!result.ok) { + throw new Error(result.message || '保存设置失败'); + } + + const row = await waitForRuntimeSettings(); + return row ? mapRuntimeSettings(row) : { musicVolume: DEFAULT_MUSIC_VOLUME }; } export async function listCustomWorldLibrary( - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson< - CustomWorldLibraryResponse - >( - '/custom-world-library', - { method: 'GET' }, - '读取自定义世界库失败', - options, + const connection = await ensureSpacetimeConnection(); + return Array.from(connection.db.my_custom_world_profiles.iter()).map( + mapCustomWorldLibraryEntry, ); - - return Array.isArray(response?.entries) ? response.entries : []; } export async function listCustomWorldWorks( - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson( - '/custom-world/works', - { method: 'GET' }, - '读取创作作品列表失败', - options, - ); - - return Array.isArray(response?.items) ? response.items : []; + return { + items: [], + } satisfies ListCustomWorldWorksResponse; } export async function upsertCustomWorldProfile( profile: CustomWorldProfile, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson< - CustomWorldLibraryMutationResponse - >( - `/custom-world-library/${encodeURIComponent(profile.id)}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - profile, - }), - }, - '保存自定义世界失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.upsertCustomWorldProfile({ + meta: buildRequestMeta(), + profileId: profile.id, + payloadJson: JSON.stringify(profile), + authorDisplayName: '玩家', + }); + + if (!result.ok) { + throw new Error(result.message || '保存自定义世界失败'); + } + + const entries = await listCustomWorldLibrary(); + const entry = + entries.find((item) => item.profileId === profile.id) ?? + mapCustomWorldLibraryEntry({ + ownerUserId: '', + profileId: profile.id, + payloadJson: JSON.stringify(profile), + visibility: { tag: 'Draft' }, + publishedAtMs: null, + updatedAtMs: BigInt(Date.now()), + authorDisplayName: '玩家', + worldName: profile.name, + subtitle: profile.subtitle, + summaryText: profile.summary, + coverImageSrc: null, + themeMode: { tag: 'Mythic' }, + playableNpcCount: profile.playableNpcs.length, + landmarkCount: profile.landmarks.length, + }); return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + entry, + entries, + } satisfies CustomWorldLibraryMutationResponse; } export async function deleteCustomWorldProfile( profileId: string, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson< - CustomWorldLibraryResponse - >( - `/custom-world-library/${encodeURIComponent(profileId)}`, - { method: 'DELETE' }, - '删除自定义世界失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.deleteCustomWorldProfile({ + meta: buildRequestMeta(), + profileId, + }); - return Array.isArray(response?.entries) ? response.entries : []; + if (!result.ok) { + throw new Error(result.message || '删除自定义世界失败'); + } + + return listCustomWorldLibrary(); } export async function publishCustomWorldProfile( profileId: string, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson< - CustomWorldLibraryMutationResponse - >( - `/custom-world-library/${encodeURIComponent(profileId)}/publish`, - { method: 'POST' }, - '发布自定义世界失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.publishCustomWorldProfile({ + meta: buildRequestMeta(), + profileId, + authorDisplayName: '玩家', + }); + + if (!result.ok) { + throw new Error(result.message || '发布自定义世界失败'); + } + + const entries = await listCustomWorldLibrary(); + const entry = entries.find((item) => item.profileId === profileId); + if (!entry) { + throw new Error('发布后未找到自定义世界'); + } return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + entry, + entries, + } satisfies CustomWorldLibraryMutationResponse; } export async function unpublishCustomWorldProfile( profileId: string, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson< - CustomWorldLibraryMutationResponse - >( - `/custom-world-library/${encodeURIComponent(profileId)}/unpublish`, - { method: 'POST' }, - '下架自定义世界失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.unpublishCustomWorldProfile({ + meta: buildRequestMeta(), + profileId, + authorDisplayName: '玩家', + }); + + if (!result.ok) { + throw new Error(result.message || '下架自定义世界失败'); + } + + const entries = await listCustomWorldLibrary(); + const entry = entries.find((item) => item.profileId === profileId); + if (!entry) { + throw new Error('下架后未找到自定义世界'); + } return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + entry, + entries, + } satisfies CustomWorldLibraryMutationResponse; } export async function listCustomWorldGallery( - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson( - '/custom-world-gallery', - { method: 'GET' }, - '读取作品广场失败', - options, + const connection = await ensureSpacetimeConnection(); + return Array.from(connection.db.published_custom_world_gallery.iter()).map( + mapGalleryCard, ); - - return Array.isArray(response?.entries) ? response.entries : []; } export async function getCustomWorldGalleryDetail( ownerUserId: string, profileId: string, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson< - CustomWorldGalleryDetailResponse - >( - `/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`, - { method: 'GET' }, - '读取作品详情失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const entry = Array.from(connection.db.published_custom_world_profiles.iter()) + .map(mapPublishedProfile) + .find( + (row) => + row.ownerUserId === ownerUserId && row.profileId === profileId, + ); - return response.entry; + if (!entry) { + throw new Error('读取作品详情失败'); + } + + return entry satisfies CustomWorldGalleryDetailResponse['entry']; } export async function listProfileBrowseHistory( - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson( - '/profile/browse-history', - { method: 'GET' }, - '读取浏览历史失败', - options, + const connection = await ensureSpacetimeConnection(); + return Array.from(connection.db.my_browse_history.iter()).map( + mapBrowseHistoryEntry, ); - - return Array.isArray(response?.entries) ? response.entries : []; } export async function upsertProfileBrowseHistory( entry: PlatformBrowseHistoryWriteEntry, - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson( - '/profile/browse-history', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(entry), - }, - '写入浏览历史失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.upsertPlatformBrowseHistory({ + meta: buildRequestMeta(), + entries: [ + { + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + worldName: entry.worldName, + subtitle: entry.subtitle, + summaryText: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + themeMode: mapThemeModeInput(entry.themeMode), + authorDisplayName: entry.authorDisplayName, + visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n, + }, + ], + }); - return Array.isArray(response?.entries) ? response.entries : []; + if (!result.ok) { + throw new Error(result.message || '写入浏览历史失败'); + } + + return listProfileBrowseHistory(); } export async function syncProfileBrowseHistory( entries: PlatformBrowseHistoryWriteEntry[], - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson( - '/profile/browse-history', - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - entries, - } satisfies PlatformBrowseHistoryBatchSyncRequest), - }, - '同步浏览历史失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.upsertPlatformBrowseHistory({ + meta: buildRequestMeta(), + entries: entries.map((entry) => ({ + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + worldName: entry.worldName, + subtitle: entry.subtitle, + summaryText: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + themeMode: mapThemeModeInput(entry.themeMode), + authorDisplayName: entry.authorDisplayName, + visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n, + })), + }); - return Array.isArray(response?.entries) ? response.entries : []; + if (!result.ok) { + throw new Error(result.message || '同步浏览历史失败'); + } + + return listProfileBrowseHistory(); } export async function clearProfileBrowseHistory( - options: RuntimeRequestOptions = {}, + _options: RuntimeRequestOptions = {}, ) { - const response = await requestRuntimeJson( - '/profile/browse-history', - { method: 'DELETE' }, - '清空浏览历史失败', - options, - ); + const connection = await ensureSpacetimeConnection(); + const result = await connection.procedures.clearPlatformBrowseHistory({ + meta: buildRequestMeta(), + }); - return Array.isArray(response?.entries) ? response.entries : []; + if (!result.ok) { + throw new Error(result.message || '清空浏览历史失败'); + } + + return [] satisfies PlatformBrowseHistoryEntry[]; +} + +async function listCustomWorldSessions() { + const connection = await ensureSpacetimeConnection(); + return Array.from(connection.db.my_custom_world_sessions.iter()).map( + mapCustomWorldSession, + ); } export const runtimeStorageClient = { diff --git a/src/spacetime/client.ts b/src/spacetime/client.ts new file mode 100644 index 00000000..2195c7e3 --- /dev/null +++ b/src/spacetime/client.ts @@ -0,0 +1,242 @@ +import type { Identity } from 'spacetimedb'; + +import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient'; +import { DbConnection } from './generated'; + +export const SPACETIME_VERIFICATION_REQUIRED_EVENT = + 'genarrative-spacetime-verification-required'; +export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick'; +export const SPACETIME_SESSION_REVOKED_EVENT = + 'genarrative-spacetime-session-revoked'; + +export type VerificationRequiredDetail = { + phoneNumberMasked: string | null; + title: string; + detail: string; +}; + +export type KickEventDetail = { + reasonCode: string; + message: string; +}; + +export type SessionRevokedDetail = { + targetSessionId: string; + reasonCode: string; + message: string; +}; + +let activeConnection: DbConnection | null = null; +let readyPromise: Promise | null = null; +let resolveReady: ((connection: DbConnection) => void) | null = null; +let rejectReady: ((error: Error) => void) | null = null; +let hasActiveSubscription = false; + +function emitWindowEvent(eventName: string, detail?: T) { + if (typeof window === 'undefined') { + return; + } + + if (typeof CustomEvent === 'function') { + window.dispatchEvent(new CustomEvent(eventName, { detail })); + return; + } + + window.dispatchEvent(new Event(eventName)); +} + +function emitAuthStateChange() { + emitWindowEvent(AUTH_STATE_EVENT); +} + +function normalizeSpacetimeUri(rawValue: string) { + const trimmed = rawValue.trim(); + if (!trimmed) { + return 'ws://127.0.0.1:3000'; + } + + if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) { + return trimmed; + } + + if (trimmed.startsWith('http://')) { + return `ws://${trimmed.slice('http://'.length)}`; + } + + if (trimmed.startsWith('https://')) { + return `wss://${trimmed.slice('https://'.length)}`; + } + + return trimmed; +} + +function resolveSpacetimeUri() { + return normalizeSpacetimeUri( + import.meta.env.VITE_SPACETIME_URI?.trim() || 'ws://127.0.0.1:3000', + ); +} + +function resolveDatabaseName() { + return ( + import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr' + ); +} + +function isTargetIdentity(connection: DbConnection, identity: Identity) { + return Boolean(connection.identity && connection.identity.isEqual(identity)); +} + +export function getCurrentSpacetimeSessionId(connection: DbConnection | null) { + if (!connection) { + return ''; + } + + const connectionIdHex = connection.connectionId?.toHexString()?.trim(); + if (connectionIdHex) { + return `usess_${connectionIdHex}`; + } + + const identityHex = connection.identity?.toHexString()?.trim(); + return identityHex ? `usess_${identityHex}` : ''; +} + +function installConnectionCallbacks(connection: DbConnection) { + connection.db.my_auth_state.onInsert(() => { + emitAuthStateChange(); + }); + connection.db.my_auth_state.onUpdate(() => { + emitAuthStateChange(); + }); + connection.db.my_auth_state.onDelete(() => { + emitAuthStateChange(); + }); + connection.db.verification_prompt_event.onInsert((_ctx, row) => { + if (!isTargetIdentity(connection, row.targetIdentity)) { + return; + } + emitWindowEvent( + SPACETIME_VERIFICATION_REQUIRED_EVENT, + { + phoneNumberMasked: row.phoneNumberMasked ?? null, + title: row.title, + detail: row.detail, + }, + ); + }); + connection.db.kick_event.onInsert((_ctx, row) => { + if (!isTargetIdentity(connection, row.targetIdentity)) { + return; + } + emitWindowEvent(SPACETIME_KICK_EVENT, { + reasonCode: row.reasonCode, + message: row.message, + }); + }); + connection.db.session_revocation_event.onInsert((_ctx, row) => { + const currentSessionId = getCurrentSpacetimeSessionId(connection); + if (!currentSessionId || row.targetSessionId !== currentSessionId) { + return; + } + emitWindowEvent(SPACETIME_SESSION_REVOKED_EVENT, { + targetSessionId: row.targetSessionId, + reasonCode: row.reasonCode, + message: row.message, + }); + connection.disconnect(); + }); +} + +function resetReadyState() { + readyPromise = null; + resolveReady = null; + rejectReady = null; + hasActiveSubscription = false; +} + +function rejectConnection(error: Error) { + rejectReady?.(error); + resetReadyState(); +} + +export function getSpacetimeConnection() { + return activeConnection; +} + +export function disconnectSpacetimeConnection(options: { clearToken?: boolean } = {}) { + const currentConnection = activeConnection; + activeConnection = null; + resetReadyState(); + currentConnection?.disconnect(); + if (options.clearToken) { + clearStoredAccessToken({ emit: false }); + } +} + +export function buildSpacetimeConnection() { + const connection = DbConnection.builder() + .withUri(resolveSpacetimeUri()) + .withDatabaseName(resolveDatabaseName()) + .withLightMode(true) + .withToken(getStoredAccessToken() || undefined) + .onConnect((nextConnection, _identity, token) => { + activeConnection = nextConnection; + setStoredAccessToken(token, { emit: false }); + installConnectionCallbacks(nextConnection); + if (hasActiveSubscription) { + resolveReady?.(nextConnection); + emitAuthStateChange(); + return; + } + + nextConnection + .subscriptionBuilder() + .onApplied(() => { + hasActiveSubscription = true; + resolveReady?.(nextConnection); + emitAuthStateChange(); + }) + .onError((_ctx) => { + rejectConnection(new Error('Spacetime 数据订阅失败')); + }) + .subscribeToAllTables(); + }) + .onConnectError((_ctx, error) => { + activeConnection = null; + rejectConnection(error); + }) + .onDisconnect((_ctx, error) => { + activeConnection = null; + if (!hasActiveSubscription) { + rejectConnection(error ?? new Error('Spacetime 连接已断开')); + return; + } + resetReadyState(); + emitAuthStateChange(); + }) + .build(); + + activeConnection = connection; + return connection; +} + +export function ensureSpacetimeConnection() { + if (activeConnection?.isActive && hasActiveSubscription && readyPromise) { + return readyPromise; + } + + if (activeConnection?.isActive && hasActiveSubscription) { + return Promise.resolve(activeConnection); + } + + if (readyPromise) { + return readyPromise; + } + + readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + buildSpacetimeConnection(); + return readyPromise; +} diff --git a/src/spacetime/generated/clear_platform_browse_history_procedure.ts b/src/spacetime/generated/clear_platform_browse_history_procedure.ts new file mode 100644 index 00000000..3ca77ba3 --- /dev/null +++ b/src/spacetime/generated/clear_platform_browse_history_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/client_app_config_table.ts b/src/spacetime/generated/client_app_config_table.ts new file mode 100644 index 00000000..96d17d60 --- /dev/null +++ b/src/spacetime/generated/client_app_config_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + guestLoginEnabled: __t.bool().name("guest_login_enabled"), + smsAuthEnabled: __t.bool().name("sms_auth_enabled"), + smsVerificationRequired: __t.bool().name("sms_verification_required"), + smsProvider: __t.string().name("sms_provider"), + smsCodeLength: __t.u16().name("sms_code_length"), + smsValidTimeSeconds: __t.u32().name("sms_valid_time_seconds"), + smsIntervalSeconds: __t.u32().name("sms_interval_seconds"), + defaultMusicVolume: __t.f32().name("default_music_volume"), + defaultGuestDisplayNamePrefix: __t.string().name("default_guest_display_name_prefix"), + wechatEnabled: __t.bool().name("wechat_enabled"), +}); diff --git a/src/spacetime/generated/delete_custom_world_profile_procedure.ts b/src/spacetime/generated/delete_custom_world_profile_procedure.ts new file mode 100644 index 00000000..ee2785cb --- /dev/null +++ b/src/spacetime/generated/delete_custom_world_profile_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + profileId: __t.string(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/delete_snapshot_procedure.ts b/src/spacetime/generated/delete_snapshot_procedure.ts new file mode 100644 index 00000000..3ca77ba3 --- /dev/null +++ b/src/spacetime/generated/delete_snapshot_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/index.ts b/src/spacetime/generated/index.ts new file mode 100644 index 00000000..9b50d5ec --- /dev/null +++ b/src/spacetime/generated/index.ts @@ -0,0 +1,282 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas + +// Import all procedure arg schemas +import * as ClearPlatformBrowseHistoryProcedure from "./clear_platform_browse_history_procedure"; +import * as DeleteCustomWorldProfileProcedure from "./delete_custom_world_profile_procedure"; +import * as DeleteSnapshotProcedure from "./delete_snapshot_procedure"; +import * as LiftMyRiskBlockProcedure from "./lift_my_risk_block_procedure"; +import * as LogoutAllUserSessionsProcedure from "./logout_all_user_sessions_procedure"; +import * as PublishCustomWorldProfileProcedure from "./publish_custom_world_profile_procedure"; +import * as PutRuntimeSettingsProcedure from "./put_runtime_settings_procedure"; +import * as RevokeUserSessionProcedure from "./revoke_user_session_procedure"; +import * as SaveSnapshotProcedure from "./save_snapshot_procedure"; +import * as SendSmsVerificationCodeProcedure from "./send_sms_verification_code_procedure"; +import * as UnpublishCustomWorldProfileProcedure from "./unpublish_custom_world_profile_procedure"; +import * as UpsertCustomWorldProfileProcedure from "./upsert_custom_world_profile_procedure"; +import * as UpsertCustomWorldSessionProcedure from "./upsert_custom_world_session_procedure"; +import * as UpsertPlatformBrowseHistoryProcedure from "./upsert_platform_browse_history_procedure"; +import * as VerifySmsCodeProcedure from "./verify_sms_code_procedure"; + +// Import all table schema definitions +import ClientAppConfigRow from "./client_app_config_table"; +import KickEventRow from "./kick_event_table"; +import MyAuthAuditLogsRow from "./my_auth_audit_logs_table"; +import MyAuthRiskBlocksRow from "./my_auth_risk_blocks_table"; +import MyAuthStateRow from "./my_auth_state_table"; +import MyBrowseHistoryRow from "./my_browse_history_table"; +import MyCustomWorldProfilesRow from "./my_custom_world_profiles_table"; +import MyCustomWorldSessionsRow from "./my_custom_world_sessions_table"; +import MyProfileDashboardRow from "./my_profile_dashboard_table"; +import MyProfilePlayedWorldsRow from "./my_profile_played_worlds_table"; +import MyProfileWalletLedgerRow from "./my_profile_wallet_ledger_table"; +import MyRuntimeSettingsRow from "./my_runtime_settings_table"; +import MySnapshotRow from "./my_snapshot_table"; +import MyUserSessionsRow from "./my_user_sessions_table"; +import PublishedCustomWorldGalleryRow from "./published_custom_world_gallery_table"; +import PublishedCustomWorldProfilesRow from "./published_custom_world_profiles_table"; +import SessionRevocationEventRow from "./session_revocation_event_table"; +import VerificationPromptEventRow from "./verification_prompt_event_table"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + kick_event: __table({ + name: 'kick_event', + indexes: [ + ], + constraints: [ + ], + event: true, + }, KickEventRow), + session_revocation_event: __table({ + name: 'session_revocation_event', + indexes: [ + ], + constraints: [ + ], + event: true, + }, SessionRevocationEventRow), + verification_prompt_event: __table({ + name: 'verification_prompt_event', + indexes: [ + ], + constraints: [ + ], + event: true, + }, VerificationPromptEventRow), + client_app_config: __table({ + name: 'client_app_config', + indexes: [ + ], + constraints: [ + ], + }, ClientAppConfigRow), + my_auth_audit_logs: __table({ + name: 'my_auth_audit_logs', + indexes: [ + ], + constraints: [ + ], + }, MyAuthAuditLogsRow), + my_auth_risk_blocks: __table({ + name: 'my_auth_risk_blocks', + indexes: [ + ], + constraints: [ + ], + }, MyAuthRiskBlocksRow), + my_auth_state: __table({ + name: 'my_auth_state', + indexes: [ + ], + constraints: [ + ], + }, MyAuthStateRow), + my_browse_history: __table({ + name: 'my_browse_history', + indexes: [ + ], + constraints: [ + ], + }, MyBrowseHistoryRow), + my_custom_world_profiles: __table({ + name: 'my_custom_world_profiles', + indexes: [ + ], + constraints: [ + ], + }, MyCustomWorldProfilesRow), + my_custom_world_sessions: __table({ + name: 'my_custom_world_sessions', + indexes: [ + ], + constraints: [ + ], + }, MyCustomWorldSessionsRow), + my_profile_dashboard: __table({ + name: 'my_profile_dashboard', + indexes: [ + ], + constraints: [ + ], + }, MyProfileDashboardRow), + my_profile_played_worlds: __table({ + name: 'my_profile_played_worlds', + indexes: [ + ], + constraints: [ + ], + }, MyProfilePlayedWorldsRow), + my_profile_wallet_ledger: __table({ + name: 'my_profile_wallet_ledger', + indexes: [ + ], + constraints: [ + ], + }, MyProfileWalletLedgerRow), + my_runtime_settings: __table({ + name: 'my_runtime_settings', + indexes: [ + ], + constraints: [ + ], + }, MyRuntimeSettingsRow), + my_snapshot: __table({ + name: 'my_snapshot', + indexes: [ + ], + constraints: [ + ], + }, MySnapshotRow), + my_user_sessions: __table({ + name: 'my_user_sessions', + indexes: [ + ], + constraints: [ + ], + }, MyUserSessionsRow), + published_custom_world_gallery: __table({ + name: 'published_custom_world_gallery', + indexes: [ + ], + constraints: [ + ], + }, PublishedCustomWorldGalleryRow), + published_custom_world_profiles: __table({ + name: 'published_custom_world_profiles', + indexes: [ + ], + constraints: [ + ], + }, PublishedCustomWorldProfilesRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("clear_platform_browse_history", ClearPlatformBrowseHistoryProcedure.params, ClearPlatformBrowseHistoryProcedure.returnType), + __procedureSchema("delete_custom_world_profile", DeleteCustomWorldProfileProcedure.params, DeleteCustomWorldProfileProcedure.returnType), + __procedureSchema("delete_snapshot", DeleteSnapshotProcedure.params, DeleteSnapshotProcedure.returnType), + __procedureSchema("lift_my_risk_block", LiftMyRiskBlockProcedure.params, LiftMyRiskBlockProcedure.returnType), + __procedureSchema("logout_all_user_sessions", LogoutAllUserSessionsProcedure.params, LogoutAllUserSessionsProcedure.returnType), + __procedureSchema("publish_custom_world_profile", PublishCustomWorldProfileProcedure.params, PublishCustomWorldProfileProcedure.returnType), + __procedureSchema("put_runtime_settings", PutRuntimeSettingsProcedure.params, PutRuntimeSettingsProcedure.returnType), + __procedureSchema("revoke_user_session", RevokeUserSessionProcedure.params, RevokeUserSessionProcedure.returnType), + __procedureSchema("save_snapshot", SaveSnapshotProcedure.params, SaveSnapshotProcedure.returnType), + __procedureSchema("send_sms_verification_code", SendSmsVerificationCodeProcedure.params, SendSmsVerificationCodeProcedure.returnType), + __procedureSchema("unpublish_custom_world_profile", UnpublishCustomWorldProfileProcedure.params, UnpublishCustomWorldProfileProcedure.returnType), + __procedureSchema("upsert_custom_world_profile", UpsertCustomWorldProfileProcedure.params, UpsertCustomWorldProfileProcedure.returnType), + __procedureSchema("upsert_custom_world_session", UpsertCustomWorldSessionProcedure.params, UpsertCustomWorldSessionProcedure.returnType), + __procedureSchema("upsert_platform_browse_history", UpsertPlatformBrowseHistoryProcedure.params, UpsertPlatformBrowseHistoryProcedure.returnType), + __procedureSchema("verify_sms_code", VerifySmsCodeProcedure.params, VerifySmsCodeProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.1.0" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/src/spacetime/generated/kick_event_table.ts b/src/spacetime/generated/kick_event_table.ts new file mode 100644 index 00000000..b8124393 --- /dev/null +++ b/src/spacetime/generated/kick_event_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + targetIdentity: __t.identity().name("target_identity"), + reasonCode: __t.string().name("reason_code"), + message: __t.string(), + issuedAtMs: __t.u64().name("issued_at_ms"), +}); diff --git a/src/spacetime/generated/lift_my_risk_block_procedure.ts b/src/spacetime/generated/lift_my_risk_block_procedure.ts new file mode 100644 index 00000000..f79c60e6 --- /dev/null +++ b/src/spacetime/generated/lift_my_risk_block_procedure.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RiskBlockScopeType, + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + get scopeType() { + return RiskBlockScopeType; + }, +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/logout_all_user_sessions_procedure.ts b/src/spacetime/generated/logout_all_user_sessions_procedure.ts new file mode 100644 index 00000000..3ca77ba3 --- /dev/null +++ b/src/spacetime/generated/logout_all_user_sessions_procedure.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/my_auth_audit_logs_table.ts b/src/spacetime/generated/my_auth_audit_logs_table.ts new file mode 100644 index 00000000..cee10688 --- /dev/null +++ b/src/spacetime/generated/my_auth_audit_logs_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64(), + eventType: __t.string().name("event_type"), + detail: __t.string(), + ip: __t.option(__t.string()), + userAgent: __t.option(__t.string()).name("user_agent"), + createdAtMs: __t.u64().name("created_at_ms"), +}); diff --git a/src/spacetime/generated/my_auth_risk_blocks_table.ts b/src/spacetime/generated/my_auth_risk_blocks_table.ts new file mode 100644 index 00000000..5bab6c7d --- /dev/null +++ b/src/spacetime/generated/my_auth_risk_blocks_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + RiskBlockScopeType, +} from "./types"; + + +export default __t.row({ + get scopeType() { + return RiskBlockScopeType.name("scope_type"); + }, + scopeKey: __t.string().name("scope_key"), + reason: __t.string(), + expiresAtMs: __t.u64().name("expires_at_ms"), +}); diff --git a/src/spacetime/generated/my_auth_state_table.ts b/src/spacetime/generated/my_auth_state_table.ts new file mode 100644 index 00000000..1c967ef5 --- /dev/null +++ b/src/spacetime/generated/my_auth_state_table.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + LoginProvider, + AccountStatus, +} from "./types"; + + +export default __t.row({ + userId: __t.string().name("user_id"), + identity: __t.identity(), + displayName: __t.string().name("display_name"), + phoneNumberMasked: __t.option(__t.string()).name("phone_number_masked"), + get loginProvider() { + return LoginProvider.name("login_provider"); + }, + get accountStatus() { + return AccountStatus.name("account_status"); + }, + smsVerificationRequired: __t.bool().name("sms_verification_required"), + smsVerified: __t.bool().name("sms_verified"), + jwtPresent: __t.bool().name("jwt_present"), +}); diff --git a/src/spacetime/generated/my_browse_history_table.ts b/src/spacetime/generated/my_browse_history_table.ts new file mode 100644 index 00000000..a45b91a9 --- /dev/null +++ b/src/spacetime/generated/my_browse_history_table.ts @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + CustomWorldThemeMode, +} from "./types"; + + +export default __t.row({ + ownerUserId: __t.string().name("owner_user_id"), + profileId: __t.string().name("profile_id"), + worldName: __t.string().name("world_name"), + subtitle: __t.string(), + summaryText: __t.string().name("summary_text"), + coverImageSrc: __t.option(__t.string()).name("cover_image_src"), + get themeMode() { + return CustomWorldThemeMode.name("theme_mode"); + }, + authorDisplayName: __t.string().name("author_display_name"), + visitedAtMs: __t.u64().name("visited_at_ms"), +}); diff --git a/src/spacetime/generated/my_custom_world_profiles_table.ts b/src/spacetime/generated/my_custom_world_profiles_table.ts new file mode 100644 index 00000000..c11b8e6a --- /dev/null +++ b/src/spacetime/generated/my_custom_world_profiles_table.ts @@ -0,0 +1,37 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + CustomWorldPublicationStatus, + CustomWorldThemeMode, +} from "./types"; + + +export default __t.row({ + ownerUserId: __t.string().name("owner_user_id"), + profileId: __t.string().name("profile_id"), + payloadJson: __t.string().name("payload_json"), + get visibility() { + return CustomWorldPublicationStatus; + }, + publishedAtMs: __t.option(__t.u64()).name("published_at_ms"), + updatedAtMs: __t.u64().name("updated_at_ms"), + authorDisplayName: __t.string().name("author_display_name"), + worldName: __t.string().name("world_name"), + subtitle: __t.string(), + summaryText: __t.string().name("summary_text"), + coverImageSrc: __t.option(__t.string()).name("cover_image_src"), + get themeMode() { + return CustomWorldThemeMode.name("theme_mode"); + }, + playableNpcCount: __t.u32().name("playable_npc_count"), + landmarkCount: __t.u32().name("landmark_count"), +}); diff --git a/src/spacetime/generated/my_custom_world_sessions_table.ts b/src/spacetime/generated/my_custom_world_sessions_table.ts new file mode 100644 index 00000000..23ebfba3 --- /dev/null +++ b/src/spacetime/generated/my_custom_world_sessions_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + sessionId: __t.string().name("session_id"), + payloadJson: __t.string().name("payload_json"), + createdAtMs: __t.u64().name("created_at_ms"), + updatedAtMs: __t.u64().name("updated_at_ms"), +}); diff --git a/src/spacetime/generated/my_profile_dashboard_table.ts b/src/spacetime/generated/my_profile_dashboard_table.ts new file mode 100644 index 00000000..40dc8af3 --- /dev/null +++ b/src/spacetime/generated/my_profile_dashboard_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + walletBalance: __t.i64().name("wallet_balance"), + totalPlayTimeMs: __t.u64().name("total_play_time_ms"), + playedWorldCount: __t.u32().name("played_world_count"), + updatedAtMs: __t.option(__t.u64()).name("updated_at_ms"), +}); diff --git a/src/spacetime/generated/my_profile_played_worlds_table.ts b/src/spacetime/generated/my_profile_played_worlds_table.ts new file mode 100644 index 00000000..4f9cd04a --- /dev/null +++ b/src/spacetime/generated/my_profile_played_worlds_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + worldKey: __t.string().name("world_key"), + ownerUserId: __t.option(__t.string()).name("owner_user_id"), + profileId: __t.option(__t.string()).name("profile_id"), + worldType: __t.option(__t.string()).name("world_type"), + worldTitle: __t.string().name("world_title"), + worldSubtitle: __t.string().name("world_subtitle"), + firstPlayedAtMs: __t.u64().name("first_played_at_ms"), + lastPlayedAtMs: __t.u64().name("last_played_at_ms"), + lastObservedPlayTimeMs: __t.u64().name("last_observed_play_time_ms"), +}); diff --git a/src/spacetime/generated/my_profile_wallet_ledger_table.ts b/src/spacetime/generated/my_profile_wallet_ledger_table.ts new file mode 100644 index 00000000..08df3c61 --- /dev/null +++ b/src/spacetime/generated/my_profile_wallet_ledger_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.string(), + amountDelta: __t.i64().name("amount_delta"), + balanceAfter: __t.i64().name("balance_after"), + sourceType: __t.string().name("source_type"), + createdAtMs: __t.u64().name("created_at_ms"), +}); diff --git a/src/spacetime/generated/my_runtime_settings_table.ts b/src/spacetime/generated/my_runtime_settings_table.ts new file mode 100644 index 00000000..4bc4120e --- /dev/null +++ b/src/spacetime/generated/my_runtime_settings_table.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + musicVolume: __t.f32().name("music_volume"), +}); diff --git a/src/spacetime/generated/my_snapshot_table.ts b/src/spacetime/generated/my_snapshot_table.ts new file mode 100644 index 00000000..08b2ffcd --- /dev/null +++ b/src/spacetime/generated/my_snapshot_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + version: __t.u32(), + savedAtMs: __t.u64().name("saved_at_ms"), + gameStateJson: __t.string().name("game_state_json"), + bottomTab: __t.string().name("bottom_tab"), + currentStoryJson: __t.option(__t.string()).name("current_story_json"), +}); diff --git a/src/spacetime/generated/my_user_sessions_table.ts b/src/spacetime/generated/my_user_sessions_table.ts new file mode 100644 index 00000000..71ef4bb4 --- /dev/null +++ b/src/spacetime/generated/my_user_sessions_table.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + sessionId: __t.string().name("session_id"), + clientType: __t.string().name("client_type"), + userAgent: __t.option(__t.string()).name("user_agent"), + ip: __t.option(__t.string()), + isCurrent: __t.bool().name("is_current"), + createdAtMs: __t.u64().name("created_at_ms"), + lastSeenAtMs: __t.u64().name("last_seen_at_ms"), + expiresAtMs: __t.option(__t.u64()).name("expires_at_ms"), +}); diff --git a/src/spacetime/generated/publish_custom_world_profile_procedure.ts b/src/spacetime/generated/publish_custom_world_profile_procedure.ts new file mode 100644 index 00000000..5f20b3f2 --- /dev/null +++ b/src/spacetime/generated/publish_custom_world_profile_procedure.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + profileId: __t.string(), + authorDisplayName: __t.string(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/published_custom_world_gallery_table.ts b/src/spacetime/generated/published_custom_world_gallery_table.ts new file mode 100644 index 00000000..85a59e7e --- /dev/null +++ b/src/spacetime/generated/published_custom_world_gallery_table.ts @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + CustomWorldPublicationStatus, + CustomWorldThemeMode, +} from "./types"; + + +export default __t.row({ + ownerUserId: __t.string().name("owner_user_id"), + profileId: __t.string().name("profile_id"), + get visibility() { + return CustomWorldPublicationStatus; + }, + publishedAtMs: __t.option(__t.u64()).name("published_at_ms"), + updatedAtMs: __t.u64().name("updated_at_ms"), + authorDisplayName: __t.string().name("author_display_name"), + worldName: __t.string().name("world_name"), + subtitle: __t.string(), + summaryText: __t.string().name("summary_text"), + coverImageSrc: __t.option(__t.string()).name("cover_image_src"), + get themeMode() { + return CustomWorldThemeMode.name("theme_mode"); + }, + playableNpcCount: __t.u32().name("playable_npc_count"), + landmarkCount: __t.u32().name("landmark_count"), +}); diff --git a/src/spacetime/generated/published_custom_world_profiles_table.ts b/src/spacetime/generated/published_custom_world_profiles_table.ts new file mode 100644 index 00000000..c11b8e6a --- /dev/null +++ b/src/spacetime/generated/published_custom_world_profiles_table.ts @@ -0,0 +1,37 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + CustomWorldPublicationStatus, + CustomWorldThemeMode, +} from "./types"; + + +export default __t.row({ + ownerUserId: __t.string().name("owner_user_id"), + profileId: __t.string().name("profile_id"), + payloadJson: __t.string().name("payload_json"), + get visibility() { + return CustomWorldPublicationStatus; + }, + publishedAtMs: __t.option(__t.u64()).name("published_at_ms"), + updatedAtMs: __t.u64().name("updated_at_ms"), + authorDisplayName: __t.string().name("author_display_name"), + worldName: __t.string().name("world_name"), + subtitle: __t.string(), + summaryText: __t.string().name("summary_text"), + coverImageSrc: __t.option(__t.string()).name("cover_image_src"), + get themeMode() { + return CustomWorldThemeMode.name("theme_mode"); + }, + playableNpcCount: __t.u32().name("playable_npc_count"), + landmarkCount: __t.u32().name("landmark_count"), +}); diff --git a/src/spacetime/generated/put_runtime_settings_procedure.ts b/src/spacetime/generated/put_runtime_settings_procedure.ts new file mode 100644 index 00000000..42bf9581 --- /dev/null +++ b/src/spacetime/generated/put_runtime_settings_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + musicVolume: __t.f32(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/revoke_user_session_procedure.ts b/src/spacetime/generated/revoke_user_session_procedure.ts new file mode 100644 index 00000000..bad6b87c --- /dev/null +++ b/src/spacetime/generated/revoke_user_session_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + sessionId: __t.string(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/save_snapshot_procedure.ts b/src/spacetime/generated/save_snapshot_procedure.ts new file mode 100644 index 00000000..21c4d7b8 --- /dev/null +++ b/src/spacetime/generated/save_snapshot_procedure.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + savedAtMs: __t.u64(), + gameStateJson: __t.string(), + bottomTab: __t.string(), + currentStoryJson: __t.option(__t.string()), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/send_sms_verification_code_procedure.ts b/src/spacetime/generated/send_sms_verification_code_procedure.ts new file mode 100644 index 00000000..8874536f --- /dev/null +++ b/src/spacetime/generated/send_sms_verification_code_procedure.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + SmsAuthScene, + SmsSendCodeResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + phoneNumber: __t.string(), + get scene() { + return SmsAuthScene; + }, +}; +export const returnType = SmsSendCodeResult \ No newline at end of file diff --git a/src/spacetime/generated/session_revocation_event_table.ts b/src/spacetime/generated/session_revocation_event_table.ts new file mode 100644 index 00000000..8039c713 --- /dev/null +++ b/src/spacetime/generated/session_revocation_event_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + targetSessionId: __t.string().name("target_session_id"), + reasonCode: __t.string().name("reason_code"), + message: __t.string(), + issuedAtMs: __t.u64().name("issued_at_ms"), +}); diff --git a/src/spacetime/generated/types.ts b/src/spacetime/generated/types.ts new file mode 100644 index 00000000..932456d7 --- /dev/null +++ b/src/spacetime/generated/types.ts @@ -0,0 +1,600 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +// The tagged union or sum type for the algebraic type `AccountStatus`. +export const AccountStatus = __t.enum("AccountStatus", { + Active: __t.unit(), + PendingSmsVerification: __t.unit(), + Disabled: __t.unit(), +}); +export type AccountStatus = __Infer; + +export const AppConfig = __t.object("AppConfig", { + id: __t.u8(), + guestLoginEnabled: __t.bool(), + smsAuthEnabled: __t.bool(), + smsVerificationRequired: __t.bool(), + smsProvider: __t.string(), + smsEndpoint: __t.string(), + smsAccessKeyId: __t.string(), + smsAccessKeySecret: __t.string(), + smsSignName: __t.string(), + smsTemplateCode: __t.string(), + smsTemplateParamKey: __t.string(), + smsCountryCode: __t.string(), + smsSchemeName: __t.string(), + smsCodeLength: __t.u16(), + smsCodeType: __t.u16(), + smsValidTimeSeconds: __t.u32(), + smsIntervalSeconds: __t.u32(), + smsDuplicatePolicy: __t.u16(), + smsCaseAuthPolicy: __t.u16(), + smsReturnVerifyCode: __t.bool(), + smsMockVerifyCode: __t.string(), + smsMaxSendPerPhonePerDay: __t.u16(), + smsMaxSendPerIpPerHour: __t.u16(), + smsMaxVerifyFailuresPerPhonePerHour: __t.u16(), + smsMaxVerifyFailuresPerIpPerHour: __t.u16(), + smsCaptchaTtlSeconds: __t.u32(), + smsCaptchaTriggerVerifyFailuresPerPhone: __t.u16(), + smsCaptchaTriggerVerifyFailuresPerIp: __t.u16(), + smsBlockPhoneFailureThreshold: __t.u16(), + smsBlockIpFailureThreshold: __t.u16(), + smsBlockPhoneDurationMinutes: __t.u16(), + smsBlockIpDurationMinutes: __t.u16(), + defaultMusicVolume: __t.f32(), + defaultGuestDisplayNamePrefix: __t.string(), + kickMessageUnverified: __t.string(), + wechatEnabled: __t.bool(), + wechatProvider: __t.string(), + wechatAppId: __t.string(), + wechatAppSecret: __t.string(), + wechatAuthorizeEndpoint: __t.string(), + wechatAccessTokenEndpoint: __t.string(), + wechatUserInfoEndpoint: __t.string(), + wechatCallbackPath: __t.string(), + wechatDefaultRedirectPath: __t.string(), + wechatMockUserId: __t.string(), + wechatMockUnionId: __t.string(), + wechatMockDisplayName: __t.string(), + wechatMockAvatarUrl: __t.string(), + updatedAtMs: __t.u64(), +}); +export type AppConfig = __Infer; + +export const AuthAuditLog = __t.object("AuthAuditLog", { + id: __t.u64(), + userId: __t.string(), + eventType: __t.string(), + detail: __t.string(), + ip: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + metaJson: __t.option(__t.string()), + createdAtMs: __t.u64(), +}); +export type AuthAuditLog = __Infer; + +export const AuthAuditLogView = __t.object("AuthAuditLogView", { + id: __t.u64(), + eventType: __t.string(), + detail: __t.string(), + ip: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAtMs: __t.u64(), +}); +export type AuthAuditLogView = __Infer; + +export const AuthIdentity = __t.object("AuthIdentity", { + id: __t.string(), + userId: __t.string(), + get provider() { + return AuthIdentityProvider; + }, + providerUid: __t.string(), + providerUnionId: __t.option(__t.string()), + displayName: __t.option(__t.string()), + avatarUrl: __t.option(__t.string()), + isVerified: __t.bool(), + metaJson: __t.option(__t.string()), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), +}); +export type AuthIdentity = __Infer; + +// The tagged union or sum type for the algebraic type `AuthIdentityProvider`. +export const AuthIdentityProvider = __t.enum("AuthIdentityProvider", { + Guest: __t.unit(), + Jwt: __t.unit(), + Phone: __t.unit(), + Wechat: __t.unit(), +}); +export type AuthIdentityProvider = __Infer; + +export const AuthRiskBlock = __t.object("AuthRiskBlock", { + id: __t.u64(), + get scopeType() { + return RiskBlockScopeType; + }, + scopeKey: __t.string(), + reason: __t.string(), + expiresAtMs: __t.u64(), + liftedAtMs: __t.option(__t.u64()), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), +}); +export type AuthRiskBlock = __Infer; + +export const AuthRiskBlockView = __t.object("AuthRiskBlockView", { + get scopeType() { + return RiskBlockScopeType; + }, + scopeKey: __t.string(), + reason: __t.string(), + expiresAtMs: __t.u64(), +}); +export type AuthRiskBlockView = __Infer; + +export const AuthSessionView = __t.object("AuthSessionView", { + sessionId: __t.string(), + clientType: __t.string(), + userAgent: __t.option(__t.string()), + ip: __t.option(__t.string()), + isCurrent: __t.bool(), + createdAtMs: __t.u64(), + lastSeenAtMs: __t.u64(), + expiresAtMs: __t.option(__t.u64()), +}); +export type AuthSessionView = __Infer; + +export const AuthStateView = __t.object("AuthStateView", { + userId: __t.string(), + identity: __t.identity(), + displayName: __t.string(), + phoneNumberMasked: __t.option(__t.string()), + get loginProvider() { + return LoginProvider; + }, + get accountStatus() { + return AccountStatus; + }, + smsVerificationRequired: __t.bool(), + smsVerified: __t.bool(), + jwtPresent: __t.bool(), +}); +export type AuthStateView = __Infer; + +export const ClientAppConfigView = __t.object("ClientAppConfigView", { + guestLoginEnabled: __t.bool(), + smsAuthEnabled: __t.bool(), + smsVerificationRequired: __t.bool(), + smsProvider: __t.string(), + smsCodeLength: __t.u16(), + smsValidTimeSeconds: __t.u32(), + smsIntervalSeconds: __t.u32(), + defaultMusicVolume: __t.f32(), + defaultGuestDisplayNamePrefix: __t.string(), + wechatEnabled: __t.bool(), +}); +export type ClientAppConfigView = __Infer; + +export const CustomWorldGalleryCardView = __t.object("CustomWorldGalleryCardView", { + ownerUserId: __t.string(), + profileId: __t.string(), + get visibility() { + return CustomWorldPublicationStatus; + }, + publishedAtMs: __t.option(__t.u64()), + updatedAtMs: __t.u64(), + authorDisplayName: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + playableNpcCount: __t.u32(), + landmarkCount: __t.u32(), +}); +export type CustomWorldGalleryCardView = __Infer; + +export const CustomWorldProfile = __t.object("CustomWorldProfile", { + id: __t.string(), + userId: __t.string(), + profileId: __t.string(), + get visibility() { + return CustomWorldPublicationStatus; + }, + payloadJson: __t.string(), + publishedAtMs: __t.option(__t.u64()), + updatedAtMs: __t.u64(), + authorDisplayName: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + playableNpcCount: __t.u32(), + landmarkCount: __t.u32(), + deletedAtMs: __t.option(__t.u64()), +}); +export type CustomWorldProfile = __Infer; + +export const CustomWorldProfileView = __t.object("CustomWorldProfileView", { + ownerUserId: __t.string(), + profileId: __t.string(), + payloadJson: __t.string(), + get visibility() { + return CustomWorldPublicationStatus; + }, + publishedAtMs: __t.option(__t.u64()), + updatedAtMs: __t.u64(), + authorDisplayName: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + playableNpcCount: __t.u32(), + landmarkCount: __t.u32(), +}); +export type CustomWorldProfileView = __Infer; + +// The tagged union or sum type for the algebraic type `CustomWorldPublicationStatus`. +export const CustomWorldPublicationStatus = __t.enum("CustomWorldPublicationStatus", { + Draft: __t.unit(), + Published: __t.unit(), +}); +export type CustomWorldPublicationStatus = __Infer; + +export const CustomWorldSession = __t.object("CustomWorldSession", { + id: __t.string(), + userId: __t.string(), + sessionId: __t.string(), + payloadJson: __t.string(), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), +}); +export type CustomWorldSession = __Infer; + +export const CustomWorldSessionView = __t.object("CustomWorldSessionView", { + sessionId: __t.string(), + payloadJson: __t.string(), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), +}); +export type CustomWorldSessionView = __Infer; + +// The tagged union or sum type for the algebraic type `CustomWorldThemeMode`. +export const CustomWorldThemeMode = __t.enum("CustomWorldThemeMode", { + Martial: __t.unit(), + Arcane: __t.unit(), + Machina: __t.unit(), + Tide: __t.unit(), + Rift: __t.unit(), + Mythic: __t.unit(), +}); +export type CustomWorldThemeMode = __Infer; + +export const KickEvent = __t.object("KickEvent", { + targetIdentity: __t.identity(), + reasonCode: __t.string(), + message: __t.string(), + issuedAtMs: __t.u64(), +}); +export type KickEvent = __Infer; + +// The tagged union or sum type for the algebraic type `LoginProvider`. +export const LoginProvider = __t.enum("LoginProvider", { + Guest: __t.unit(), + Jwt: __t.unit(), + Phone: __t.unit(), + Wechat: __t.unit(), +}); +export type LoginProvider = __Infer; + +export const MutationResult = __t.object("MutationResult", { + ok: __t.bool(), + kicked: __t.bool(), + code: __t.string(), + message: __t.string(), +}); +export type MutationResult = __Infer; + +export const PlatformBrowseHistoryView = __t.object("PlatformBrowseHistoryView", { + ownerUserId: __t.string(), + profileId: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + authorDisplayName: __t.string(), + visitedAtMs: __t.u64(), +}); +export type PlatformBrowseHistoryView = __Infer; + +export const PlatformBrowseHistoryWriteInput = __t.object("PlatformBrowseHistoryWriteInput", { + ownerUserId: __t.string(), + profileId: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + authorDisplayName: __t.string(), + visitedAtMs: __t.u64(), +}); +export type PlatformBrowseHistoryWriteInput = __Infer; + +export const ProfileDashboardState = __t.object("ProfileDashboardState", { + userId: __t.string(), + walletBalance: __t.i64(), + totalPlayTimeMs: __t.u64(), + updatedAtMs: __t.u64(), +}); +export type ProfileDashboardState = __Infer; + +export const ProfileDashboardView = __t.object("ProfileDashboardView", { + walletBalance: __t.i64(), + totalPlayTimeMs: __t.u64(), + playedWorldCount: __t.u32(), + updatedAtMs: __t.option(__t.u64()), +}); +export type ProfileDashboardView = __Infer; + +export const ProfilePlayedWorld = __t.object("ProfilePlayedWorld", { + id: __t.string(), + userId: __t.string(), + worldKey: __t.string(), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + worldType: __t.option(__t.string()), + worldTitle: __t.string(), + worldSubtitle: __t.string(), + firstPlayedAtMs: __t.u64(), + lastPlayedAtMs: __t.u64(), + lastObservedPlayTimeMs: __t.u64(), +}); +export type ProfilePlayedWorld = __Infer; + +export const ProfilePlayedWorldView = __t.object("ProfilePlayedWorldView", { + worldKey: __t.string(), + ownerUserId: __t.option(__t.string()), + profileId: __t.option(__t.string()), + worldType: __t.option(__t.string()), + worldTitle: __t.string(), + worldSubtitle: __t.string(), + firstPlayedAtMs: __t.u64(), + lastPlayedAtMs: __t.u64(), + lastObservedPlayTimeMs: __t.u64(), +}); +export type ProfilePlayedWorldView = __Infer; + +export const ProfileWalletLedger = __t.object("ProfileWalletLedger", { + id: __t.string(), + userId: __t.string(), + amountDelta: __t.i64(), + balanceAfter: __t.i64(), + sourceType: __t.string(), + sourceKey: __t.string(), + createdAtMs: __t.u64(), +}); +export type ProfileWalletLedger = __Infer; + +export const ProfileWalletLedgerView = __t.object("ProfileWalletLedgerView", { + id: __t.string(), + amountDelta: __t.i64(), + balanceAfter: __t.i64(), + sourceType: __t.string(), + createdAtMs: __t.u64(), +}); +export type ProfileWalletLedgerView = __Infer; + +export const PublishedCustomWorldProfileView = __t.object("PublishedCustomWorldProfileView", { + ownerUserId: __t.string(), + profileId: __t.string(), + payloadJson: __t.string(), + get visibility() { + return CustomWorldPublicationStatus; + }, + publishedAtMs: __t.option(__t.u64()), + updatedAtMs: __t.u64(), + authorDisplayName: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + playableNpcCount: __t.u32(), + landmarkCount: __t.u32(), +}); +export type PublishedCustomWorldProfileView = __Infer; + +export const RequestMeta = __t.object("RequestMeta", { + clientType: __t.string(), + userAgent: __t.option(__t.string()), + ip: __t.option(__t.string()), +}); +export type RequestMeta = __Infer; + +// The tagged union or sum type for the algebraic type `RiskBlockScopeType`. +export const RiskBlockScopeType = __t.enum("RiskBlockScopeType", { + Phone: __t.unit(), + Ip: __t.unit(), +}); +export type RiskBlockScopeType = __Infer; + +export const RuntimeSetting = __t.object("RuntimeSetting", { + userId: __t.string(), + musicVolume: __t.f32(), + updatedAtMs: __t.u64(), +}); +export type RuntimeSetting = __Infer; + +export const RuntimeSettingsView = __t.object("RuntimeSettingsView", { + musicVolume: __t.f32(), +}); +export type RuntimeSettingsView = __Infer; + +export const SaveSnapshot = __t.object("SaveSnapshot", { + userId: __t.string(), + version: __t.u32(), + savedAtMs: __t.u64(), + bottomTab: __t.string(), + gameStateJson: __t.string(), + currentStoryJson: __t.option(__t.string()), + updatedAtMs: __t.u64(), +}); +export type SaveSnapshot = __Infer; + +export const SessionRevocationEventRow = __t.object("SessionRevocationEventRow", { + targetSessionId: __t.string(), + reasonCode: __t.string(), + message: __t.string(), + issuedAtMs: __t.u64(), +}); +export type SessionRevocationEventRow = __Infer; + +// The tagged union or sum type for the algebraic type `SmsAuthAction`. +export const SmsAuthAction = __t.enum("SmsAuthAction", { + SendCode: __t.unit(), + VerifyCode: __t.unit(), +}); +export type SmsAuthAction = __Infer; + +export const SmsAuthEvent = __t.object("SmsAuthEvent", { + id: __t.u64(), + identity: __t.identity(), + phoneNumber: __t.string(), + get scene() { + return SmsAuthScene; + }, + get action() { + return SmsAuthAction; + }, + success: __t.bool(), + ip: __t.option(__t.string()), + ipKey: __t.string(), + userAgent: __t.option(__t.string()), + createdAtMs: __t.u64(), +}); +export type SmsAuthEvent = __Infer; + +// The tagged union or sum type for the algebraic type `SmsAuthScene`. +export const SmsAuthScene = __t.enum("SmsAuthScene", { + Login: __t.unit(), + BindPhone: __t.unit(), + ChangePhone: __t.unit(), +}); +export type SmsAuthScene = __Infer; + +export const SmsSendCodeResult = __t.object("SmsSendCodeResult", { + ok: __t.bool(), + kicked: __t.bool(), + code: __t.string(), + message: __t.string(), + cooldownSeconds: __t.u32(), + expiresInSeconds: __t.u32(), + providerRequestId: __t.option(__t.string()), +}); +export type SmsSendCodeResult = __Infer; + +export const SmsVerifyCodeResult = __t.object("SmsVerifyCodeResult", { + ok: __t.bool(), + kicked: __t.bool(), + code: __t.string(), + message: __t.string(), + verified: __t.bool(), +}); +export type SmsVerifyCodeResult = __Infer; + +export const SnapshotView = __t.object("SnapshotView", { + version: __t.u32(), + savedAtMs: __t.u64(), + gameStateJson: __t.string(), + bottomTab: __t.string(), + currentStoryJson: __t.option(__t.string()), +}); +export type SnapshotView = __Infer; + +export const User = __t.object("User", { + id: __t.string(), + identity: __t.identity(), + username: __t.option(__t.string()), + passwordHash: __t.option(__t.string()), + tokenVersion: __t.u32(), + displayName: __t.string(), + get loginProvider() { + return LoginProvider; + }, + get accountStatus() { + return AccountStatus; + }, + phoneNumber: __t.option(__t.string()), + phoneVerifiedAtMs: __t.option(__t.u64()), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), +}); +export type User = __Infer; + +export const UserBrowseHistory = __t.object("UserBrowseHistory", { + id: __t.string(), + userId: __t.string(), + ownerUserId: __t.string(), + profileId: __t.string(), + worldName: __t.string(), + subtitle: __t.string(), + summaryText: __t.string(), + coverImageSrc: __t.option(__t.string()), + get themeMode() { + return CustomWorldThemeMode; + }, + authorDisplayName: __t.string(), + visitedAtMs: __t.u64(), +}); +export type UserBrowseHistory = __Infer; + +export const UserSession = __t.object("UserSession", { + id: __t.string(), + userId: __t.string(), + refreshTokenHash: __t.string(), + clientType: __t.string(), + userAgent: __t.option(__t.string()), + ip: __t.option(__t.string()), + expiresAtMs: __t.option(__t.u64()), + revokedAtMs: __t.option(__t.u64()), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), + lastSeenAtMs: __t.u64(), +}); +export type UserSession = __Infer; + +export const VerificationPromptEvent = __t.object("VerificationPromptEvent", { + targetIdentity: __t.identity(), + phoneNumberMasked: __t.option(__t.string()), + title: __t.string(), + detail: __t.string(), + issuedAtMs: __t.u64(), +}); +export type VerificationPromptEvent = __Infer; + diff --git a/src/spacetime/generated/types/procedures.ts b/src/spacetime/generated/types/procedures.ts new file mode 100644 index 00000000..a89b1978 --- /dev/null +++ b/src/spacetime/generated/types/procedures.ts @@ -0,0 +1,55 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as ClearPlatformBrowseHistoryProcedure from "../clear_platform_browse_history_procedure"; +import * as DeleteCustomWorldProfileProcedure from "../delete_custom_world_profile_procedure"; +import * as DeleteSnapshotProcedure from "../delete_snapshot_procedure"; +import * as LiftMyRiskBlockProcedure from "../lift_my_risk_block_procedure"; +import * as LogoutAllUserSessionsProcedure from "../logout_all_user_sessions_procedure"; +import * as PublishCustomWorldProfileProcedure from "../publish_custom_world_profile_procedure"; +import * as PutRuntimeSettingsProcedure from "../put_runtime_settings_procedure"; +import * as RevokeUserSessionProcedure from "../revoke_user_session_procedure"; +import * as SaveSnapshotProcedure from "../save_snapshot_procedure"; +import * as SendSmsVerificationCodeProcedure from "../send_sms_verification_code_procedure"; +import * as UnpublishCustomWorldProfileProcedure from "../unpublish_custom_world_profile_procedure"; +import * as UpsertCustomWorldProfileProcedure from "../upsert_custom_world_profile_procedure"; +import * as UpsertCustomWorldSessionProcedure from "../upsert_custom_world_session_procedure"; +import * as UpsertPlatformBrowseHistoryProcedure from "../upsert_platform_browse_history_procedure"; +import * as VerifySmsCodeProcedure from "../verify_sms_code_procedure"; + +export type ClearPlatformBrowseHistoryArgs = __Infer; +export type ClearPlatformBrowseHistoryResult = __Infer; +export type DeleteCustomWorldProfileArgs = __Infer; +export type DeleteCustomWorldProfileResult = __Infer; +export type DeleteSnapshotArgs = __Infer; +export type DeleteSnapshotResult = __Infer; +export type LiftMyRiskBlockArgs = __Infer; +export type LiftMyRiskBlockResult = __Infer; +export type LogoutAllUserSessionsArgs = __Infer; +export type LogoutAllUserSessionsResult = __Infer; +export type PublishCustomWorldProfileArgs = __Infer; +export type PublishCustomWorldProfileResult = __Infer; +export type PutRuntimeSettingsArgs = __Infer; +export type PutRuntimeSettingsResult = __Infer; +export type RevokeUserSessionArgs = __Infer; +export type RevokeUserSessionResult = __Infer; +export type SaveSnapshotArgs = __Infer; +export type SaveSnapshotResult = __Infer; +export type SendSmsVerificationCodeArgs = __Infer; +export type SendSmsVerificationCodeResult = __Infer; +export type UnpublishCustomWorldProfileArgs = __Infer; +export type UnpublishCustomWorldProfileResult = __Infer; +export type UpsertCustomWorldProfileArgs = __Infer; +export type UpsertCustomWorldProfileResult = __Infer; +export type UpsertCustomWorldSessionArgs = __Infer; +export type UpsertCustomWorldSessionResult = __Infer; +export type UpsertPlatformBrowseHistoryArgs = __Infer; +export type UpsertPlatformBrowseHistoryResult = __Infer; +export type VerifySmsCodeArgs = __Infer; +export type VerifySmsCodeResult = __Infer; + diff --git a/src/spacetime/generated/types/reducers.ts b/src/spacetime/generated/types/reducers.ts new file mode 100644 index 00000000..c701027c --- /dev/null +++ b/src/spacetime/generated/types/reducers.ts @@ -0,0 +1,10 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas + + diff --git a/src/spacetime/generated/unpublish_custom_world_profile_procedure.ts b/src/spacetime/generated/unpublish_custom_world_profile_procedure.ts new file mode 100644 index 00000000..5f20b3f2 --- /dev/null +++ b/src/spacetime/generated/unpublish_custom_world_profile_procedure.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + profileId: __t.string(), + authorDisplayName: __t.string(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/upsert_custom_world_profile_procedure.ts b/src/spacetime/generated/upsert_custom_world_profile_procedure.ts new file mode 100644 index 00000000..9c88af09 --- /dev/null +++ b/src/spacetime/generated/upsert_custom_world_profile_procedure.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + profileId: __t.string(), + payloadJson: __t.string(), + authorDisplayName: __t.string(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/upsert_custom_world_session_procedure.ts b/src/spacetime/generated/upsert_custom_world_session_procedure.ts new file mode 100644 index 00000000..056505f9 --- /dev/null +++ b/src/spacetime/generated/upsert_custom_world_session_procedure.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + sessionId: __t.string(), + payloadJson: __t.string(), + createdAtMs: __t.u64(), + updatedAtMs: __t.u64(), +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/upsert_platform_browse_history_procedure.ts b/src/spacetime/generated/upsert_platform_browse_history_procedure.ts new file mode 100644 index 00000000..f55d193e --- /dev/null +++ b/src/spacetime/generated/upsert_platform_browse_history_procedure.ts @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + MutationResult, + PlatformBrowseHistoryWriteInput, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + get entries() { + return __t.array(PlatformBrowseHistoryWriteInput); + }, +}; +export const returnType = MutationResult \ No newline at end of file diff --git a/src/spacetime/generated/verification_prompt_event_table.ts b/src/spacetime/generated/verification_prompt_event_table.ts new file mode 100644 index 00000000..81e55acb --- /dev/null +++ b/src/spacetime/generated/verification_prompt_event_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + targetIdentity: __t.identity().name("target_identity"), + phoneNumberMasked: __t.option(__t.string()).name("phone_number_masked"), + title: __t.string(), + detail: __t.string(), + issuedAtMs: __t.u64().name("issued_at_ms"), +}); diff --git a/src/spacetime/generated/verify_sms_code_procedure.ts b/src/spacetime/generated/verify_sms_code_procedure.ts new file mode 100644 index 00000000..34abab9c --- /dev/null +++ b/src/spacetime/generated/verify_sms_code_procedure.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RequestMeta, + SmsVerifyCodeResult, +} from "./types"; + +export const params = { + get meta() { + return RequestMeta; + }, + phoneNumber: __t.string(), + code: __t.string(), +}; +export const returnType = SmsVerifyCodeResult \ No newline at end of file diff --git a/src/spacetime/mappers.ts b/src/spacetime/mappers.ts new file mode 100644 index 00000000..f64f8824 --- /dev/null +++ b/src/spacetime/mappers.ts @@ -0,0 +1,387 @@ +import type { + AuthAuditLogEntry, + AuthBindingStatus, + AuthLoginMethod, + AuthRiskBlockSummary, + AuthSessionSummary, + AuthUser, +} from '../../packages/shared/src/contracts/auth'; +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + PlatformBrowseHistoryEntry, + ProfileDashboardSummary, + ProfilePlayedWorkSummary, + ProfileWalletLedgerEntry, + RuntimeSettings, +} from '../../packages/shared/src/contracts/runtime'; +import type { SavedGameSnapshot } from '../persistence/gameSaveStorage'; +import type { CustomWorldProfile } from '../types'; +import type { + AuthAuditLogView, + AuthRiskBlockView, + AuthStateView, + AuthSessionView, + ClientAppConfigView, + CustomWorldGalleryCardView, + CustomWorldProfileView, + CustomWorldSessionView, + PlatformBrowseHistoryView, + ProfileDashboardView, + ProfilePlayedWorldView, + ProfileWalletLedgerView, + PublishedCustomWorldProfileView, + RuntimeSettingsView, + SnapshotView, +} from './generated/types'; + +function bigintToNumber(value: bigint | number | null | undefined) { + if (typeof value === 'bigint') { + return Number(value); + } + return typeof value === 'number' ? value : 0; +} + +function bigintToIso(value: bigint | number | null | undefined) { + if (value === null || value === undefined) { + return null; + } + + return new Date(bigintToNumber(value)).toISOString(); +} + +function enumTag(value: { tag: string } | null | undefined) { + return value?.tag ?? ''; +} + +function mapLoginMethod(tag: string): AuthLoginMethod { + switch (tag) { + case 'Phone': + return 'phone'; + case 'Wechat': + return 'wechat'; + case 'Jwt': + return 'jwt'; + case 'Guest': + return 'guest'; + default: + return 'guest'; + } +} + +function mapBindingStatus(row: AuthStateView): AuthBindingStatus { + return row.smsVerificationRequired && !row.smsVerified + ? 'pending_bind_phone' + : 'active'; +} + +export function mapAuthUser(row: AuthStateView): AuthUser { + return { + id: row.userId, + username: row.displayName, + displayName: row.displayName, + phoneNumberMasked: row.phoneNumberMasked ?? null, + loginMethod: mapLoginMethod(enumTag(row.loginProvider)), + bindingStatus: mapBindingStatus(row), + wechatBound: enumTag(row.loginProvider) === 'Wechat', + }; +} + +export function mapAvailableLoginMethods( + config: ClientAppConfigView | null, +): AuthLoginMethod[] { + const methods: AuthLoginMethod[] = []; + if (config?.smsAuthEnabled) { + methods.push('phone'); + } + if (config?.wechatEnabled) { + methods.push('wechat'); + } + return methods; +} + +function mapThemeMode(tag: string): CustomWorldLibraryEntry['themeMode'] { + switch (tag) { + case 'Martial': + return 'martial'; + case 'Arcane': + return 'arcane'; + case 'Machina': + return 'machina'; + case 'Tide': + return 'tide'; + case 'Rift': + return 'rift'; + default: + return 'mythic'; + } +} + +function parseJson(jsonText: string, fallback: T): T { + try { + return JSON.parse(jsonText) as T; + } catch { + return fallback; + } +} + +export function mapSnapshotRow(row: SnapshotView): SavedGameSnapshot { + return { + version: Number(row.version), + savedAt: bigintToIso(row.savedAtMs) ?? new Date(0).toISOString(), + gameState: parseJson(row.gameStateJson, {}), + bottomTab: row.bottomTab, + currentStory: row.currentStoryJson + ? parseJson(row.currentStoryJson, null) + : null, + } as SavedGameSnapshot; +} + +export function mapRuntimeSettings(row: RuntimeSettingsView | null): RuntimeSettings { + return { + musicVolume: row ? Number(row.musicVolume) : 0.42, + }; +} + +export function mapProfileDashboard( + row: ProfileDashboardView | null, +): ProfileDashboardSummary { + return { + walletBalance: row ? bigintToNumber(row.walletBalance) : 0, + totalPlayTimeMs: row ? bigintToNumber(row.totalPlayTimeMs) : 0, + playedWorldCount: row ? Number(row.playedWorldCount) : 0, + updatedAt: row ? bigintToIso(row.updatedAtMs) : null, + }; +} + +export function mapWalletLedgerEntry( + row: ProfileWalletLedgerView, +): ProfileWalletLedgerEntry { + return { + id: row.id, + amountDelta: bigintToNumber(row.amountDelta), + balanceAfter: bigintToNumber(row.balanceAfter), + sourceType: 'snapshot_sync', + createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(), + }; +} + +export function mapPlayedWorldEntry( + row: ProfilePlayedWorldView, +): ProfilePlayedWorkSummary { + return { + worldKey: row.worldKey, + ownerUserId: row.ownerUserId ?? null, + profileId: row.profileId ?? null, + worldType: row.worldType ?? null, + worldTitle: row.worldTitle, + worldSubtitle: row.worldSubtitle, + firstPlayedAt: bigintToIso(row.firstPlayedAtMs) ?? new Date(0).toISOString(), + lastPlayedAt: bigintToIso(row.lastPlayedAtMs) ?? new Date(0).toISOString(), + lastObservedPlayTimeMs: bigintToNumber(row.lastObservedPlayTimeMs), + }; +} + +export function mapBrowseHistoryEntry( + row: PlatformBrowseHistoryView, +): PlatformBrowseHistoryEntry { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + worldName: row.worldName, + subtitle: row.subtitle, + summaryText: row.summaryText, + coverImageSrc: row.coverImageSrc ?? null, + themeMode: mapThemeMode(enumTag(row.themeMode)), + authorDisplayName: row.authorDisplayName, + visitedAt: bigintToIso(row.visitedAtMs) ?? new Date(0).toISOString(), + }; +} + +export function mapCustomWorldLibraryEntry( + row: CustomWorldProfileView, +): CustomWorldLibraryEntry { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + profile: parseJson(row.payloadJson, { + id: row.profileId, + settingText: '', + name: row.worldName, + subtitle: row.subtitle, + summary: row.summaryText, + tone: '', + playerGoal: '', + templateWorldType: 'WUXIA', + majorFactions: [], + coreConflicts: [], + attributeSchema: { + dimensions: [], + coreStats: [], + derivedStats: [], + }, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + } as unknown as CustomWorldProfile), + visibility: enumTag(row.visibility) === 'Published' ? 'published' : 'draft', + publishedAt: bigintToIso(row.publishedAtMs), + updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(), + authorDisplayName: row.authorDisplayName, + worldName: row.worldName, + subtitle: row.subtitle, + summaryText: row.summaryText, + coverImageSrc: row.coverImageSrc ?? null, + themeMode: mapThemeMode(enumTag(row.themeMode)), + playableNpcCount: Number(row.playableNpcCount), + landmarkCount: Number(row.landmarkCount), + }; +} + +export function mapGalleryCard( + row: CustomWorldGalleryCardView, +): CustomWorldGalleryCard { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + visibility: enumTag(row.visibility) === 'Published' ? 'published' : 'draft', + publishedAt: bigintToIso(row.publishedAtMs), + updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(), + authorDisplayName: row.authorDisplayName, + worldName: row.worldName, + subtitle: row.subtitle, + summaryText: row.summaryText, + coverImageSrc: row.coverImageSrc ?? null, + themeMode: mapThemeMode(enumTag(row.themeMode)), + playableNpcCount: Number(row.playableNpcCount), + landmarkCount: Number(row.landmarkCount), + }; +} + +export function mapPublishedProfile( + row: PublishedCustomWorldProfileView, +): CustomWorldLibraryEntry { + return mapCustomWorldLibraryEntry({ + ownerUserId: row.ownerUserId, + profileId: row.profileId, + payloadJson: row.payloadJson, + visibility: row.visibility, + publishedAtMs: row.publishedAtMs, + updatedAtMs: row.updatedAtMs, + authorDisplayName: row.authorDisplayName, + worldName: row.worldName, + subtitle: row.subtitle, + summaryText: row.summaryText, + coverImageSrc: row.coverImageSrc, + themeMode: row.themeMode, + playableNpcCount: row.playableNpcCount, + landmarkCount: row.landmarkCount, + }); +} + +export function mapCustomWorldSession( + row: CustomWorldSessionView, +) { + return { + ...parseJson>(row.payloadJson, {}), + sessionId: row.sessionId, + createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(), + updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(), + }; +} + +export function mapAuditLogEntry(row: AuthAuditLogView): AuthAuditLogEntry { + const eventType = row.eventType as AuthAuditLogEntry['eventType']; + return { + id: String(bigintToNumber(row.id)), + eventType, + title: row.eventType, + detail: row.detail, + ipMasked: row.ip ?? null, + userAgent: row.userAgent ?? null, + createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(), + }; +} + +function maskIpAddress(ip: string | null | undefined) { + if (!ip) { + return null; + } + + if (ip.includes(':')) { + const parts = ip.split(':').filter(Boolean); + if (parts.length <= 2) { + return ip; + } + return `${parts.slice(0, 2).join(':')}::*`; + } + + const parts = ip.split('.'); + if (parts.length !== 4) { + return ip; + } + return `${parts[0]}.${parts[1]}.*.*`; +} + +function buildSessionClientLabel(session: { + clientType: string; + userAgent: string | null | undefined; +}) { + const userAgent = session.userAgent?.toLowerCase() || ''; + if ( + userAgent.includes('mobile') || + userAgent.includes('android') || + userAgent.includes('iphone') + ) { + return '移动端浏览器'; + } + if (session.clientType === 'web' || session.clientType === 'browser') { + return '网页端浏览器'; + } + return session.clientType || '未知设备'; +} + +export function mapAuthSession( + row: AuthSessionView, + options: { + currentSessionId?: string; + } = {}, +): AuthSessionSummary { + return { + sessionId: row.sessionId, + clientType: row.clientType, + clientLabel: buildSessionClientLabel({ + clientType: row.clientType, + userAgent: row.userAgent ?? null, + }), + userAgent: row.userAgent ?? null, + ipMasked: maskIpAddress(row.ip ?? null), + isCurrent: + options.currentSessionId?.trim() === row.sessionId || + Boolean(row.isCurrent), + createdAt: bigintToIso(row.createdAtMs) ?? '', + lastSeenAt: bigintToIso(row.lastSeenAtMs) ?? '', + expiresAt: bigintToIso(row.expiresAtMs) ?? '', + }; +} + +export function mapAuthRiskBlock(row: AuthRiskBlockView): AuthRiskBlockSummary { + const scopeType = enumTag(row.scopeType) === 'Phone' ? 'phone' : 'ip'; + const expiresAtMs = bigintToNumber(row.expiresAtMs); + const remainingSeconds = Math.max( + 0, + Math.floor((expiresAtMs - Date.now()) / 1000), + ); + + return { + scopeType, + title: scopeType === 'phone' ? '手机号保护中' : 'IP 保护中', + detail: + scopeType === 'phone' + ? `当前手机号因 ${row.reason} 被暂时保护` + : `当前连接因 ${row.reason} 被暂时保护`, + expiresAt: bigintToIso(row.expiresAtMs) ?? '', + remainingSeconds, + }; +}