Compare commits

...

10 Commits

74 changed files with 6609 additions and 439 deletions

View File

@@ -0,0 +1,227 @@
---
name: spacetimedb-cli
description: SpacetimeDB CLI reference for initializing projects, building modules, publishing databases, querying data, and managing servers
triggers:
- spacetime init
- spacetime build
- spacetime publish
- spacetime dev
- spacetime sql
- spacetime call
- spacetime logs
- spacetime server
- spacetime login
- spacetime generate
- how do I use the CLI
- CLI command
---
# SpacetimeDB CLI
Use this skill when the user needs help with the `spacetime` CLI tool - initializing projects, building modules, publishing databases, querying data, managing servers, or troubleshooting CLI issues.
## Quick Reference
### Project Initialization & Development
```bash
# Initialize new project
spacetime init my-project --lang rust|csharp|typescript|cpp
spacetime init my-project --template <template-id>
# Build module
spacetime build # release build
spacetime build --debug # faster iteration, slower runtime
# Dev mode (auto-rebuild, auto-publish, generates bindings)
spacetime dev
spacetime dev --client-lang typescript --module-bindings-path ./client/src/module_bindings
# Generate client bindings
spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings --module-path ./server
```
### Publishing & Deployment
```bash
# Publish to Maincloud (default)
spacetime publish my-database --yes
# Publish to local server
spacetime publish my-database --server local --yes
# Clear database and republish
spacetime publish my-database --clear-database --yes
```
### Database Interaction
```bash
# SQL queries
spacetime sql my-database "SELECT * FROM users"
spacetime sql my-database --interactive # REPL mode
# Call reducers
spacetime call my-database my_reducer '{"arg1": "value", "arg2": 123}'
# Subscribe to changes
spacetime subscribe my-database "SELECT * FROM users" --num-updates 10
# View logs
spacetime logs my-database -f # follow logs
spacetime logs my-database -n 100 # up to 100 log lines
# Describe schema
spacetime describe my-database --json
spacetime describe my-database table users --json
spacetime describe my-database reducer my_reducer --json
```
### Database Management
```bash
# List databases
spacetime list
# Delete database
spacetime delete my-database
# Rename database
spacetime rename <database-identity> --to new-name
```
### Server Management
```bash
# List configured servers
spacetime server list
# Add server
spacetime server add local --url http://localhost:3000 --default
spacetime server add myserver --url https://my-spacetime.example.com
# Set default server
spacetime server set-default local
# Test connectivity
spacetime server ping local
# Start local instance
spacetime start
# Clear local data
spacetime server clear
```
### Authentication
```bash
# Login (opens browser)
spacetime login
# Login with token
spacetime login --token <token>
# Show login status
spacetime login show
# Logout
spacetime logout
```
## Default Servers
| Name | URL | Description |
|------|-----|-------------|
| `maincloud` | `https://maincloud.spacetimedb.com` | Production cloud (default) |
| `local` | `http://127.0.0.1:3000` | Local development server |
## Common Workflows
### New Project Setup
```bash
# 1. Login
spacetime login
# 2. Create project
spacetime init my-game --lang rust
cd my-game
# 3. Start dev mode (auto-rebuilds and publishes)
spacetime dev
```
### Local Development
```bash
# Start local server (in separate terminal)
spacetime start
# Publish to local
spacetime publish my-db --server local --clear-database --yes
# Query local database
spacetime sql my-db --server local "SELECT * FROM players"
```
### Generate Client Bindings
```bash
# After building module
spacetime build
spacetime generate --lang typescript --out-dir ./client/src/bindings --module-path .
# Or use dev mode which auto-generates
spacetime dev --client-lang typescript --module-bindings-path ./client/src/bindings
```
## Common Flags
| Flag | Short | Description |
|------|-------|-------------|
| `--server` | `-s` | Target server (nickname, hostname, or URL) |
| `--yes` | `-y` | Non-interactive mode (skip confirmations) |
| `--anonymous` | | Use anonymous identity |
| `--module-path` | `-p` | Path to module project |
## Troubleshooting
### "Not logged in"
```bash
spacetime login
# Or use --anonymous for public operations
```
### "Server not responding"
```bash
spacetime server ping <server>
# For local: ensure spacetime start is running
```
### "Schema conflict"
```bash
# Clear data and republish
spacetime publish my-db --clear-database --yes
```
### "Build failed"
```bash
# Check Rust/C# toolchain
rustup show
# For Rust modules, ensure wasm32-unknown-unknown target
rustup target add wasm32-unknown-unknown
```
## Module Languages
**Server-side (modules):** Rust, C#, TypeScript, C++
**Client SDKs:** TypeScript, C#, Rust, Python, Unreal Engine
**CLI `generate` targets:** TypeScript, C#, Rust, Unreal C++
## Notes
- Many commands are marked UNSTABLE and may change
- Default server is `maincloud` unless configured otherwise
- Use `--yes` flag in scripts to avoid interactive prompts
- Dev mode watches files and auto-rebuilds on changes

View File

@@ -0,0 +1,345 @@
---
name: spacetimedb-concepts
description: Understand SpacetimeDB architecture and core concepts. Use when learning SpacetimeDB or making architectural decisions.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
---
# SpacetimeDB Core Concepts
SpacetimeDB is a relational database that is also a server. It lets you upload application logic directly into the database via WebAssembly modules, eliminating the traditional web/game server layer entirely.
---
## Critical Rules (Read First)
These five rules prevent the most common SpacetimeDB mistakes:
1. **Reducers are transactional** — they do not return data to callers. Use subscriptions to read data.
2. **Reducers must be deterministic** — no filesystem, network, timers, or random. All state must come from tables.
3. **Read data via tables/subscriptions** — not reducer return values. Clients get data through subscribed queries.
4. **Auto-increment IDs are not sequential** — gaps are normal, do not use for ordering. Use timestamps or explicit sequence columns.
5. **`ctx.sender()` is the authenticated principal** — never trust identity passed as arguments. Always use `ctx.sender()` for authorization.
---
## 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 — **do not skip 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.
---
## Debugging Checklist
When things are not working:
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 <db-name>`)
5. **Is the reducer actually being called from the client?**
---
## CLI Commands
```bash
spacetime start
spacetime publish <db-name> --module-path <module-path>
spacetime publish <db-name> --clear-database -y --module-path <module-path>
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
spacetime logs <db-name>
```
---
## What SpacetimeDB Is
SpacetimeDB combines a database and application server into a single deployable unit. Clients connect directly to the database and execute application logic inside it. The system is optimized for real-time applications requiring maximum speed and minimum latency.
Key characteristics:
- **In-memory execution**: Application state is served from memory for very low-latency access
- **Persistent storage**: Data is automatically persisted to a write-ahead log (WAL) for durability
- **Real-time synchronization**: Changes are automatically pushed to subscribed clients
- **Single deployment**: No separate servers, containers, or infrastructure to manage
## The Five Zen Principles
1. **Everything is a Table**: Your entire application state lives in tables. No separate cache layer, no Redis, no in-memory state to synchronize.
2. **Everything is Persistent**: SpacetimeDB persists state by default (for example via WAL-backed durability).
3. **Everything is Real-Time**: Clients are replicas of server state. Subscribe to data and it flows automatically.
4. **Everything is Transactional**: Every reducer runs atomically. Either all changes succeed or all roll back.
5. **Everything is Programmable**: Modules are real code (Rust, C#, TypeScript) running inside the database.
## Tables
Tables store all data in SpacetimeDB. They use the relational model and support SQL queries for subscriptions.
### Defining Tables
Tables are defined using language-specific attributes. In 2.0, use `accessor` (not `name`) for the API name:
**Rust:**
```rust
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u32,
#[index(btree)]
name: String,
#[unique]
email: String,
}
```
**C#:**
```csharp
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public uint Id;
[SpacetimeDB.Index.BTree]
public string Name;
[SpacetimeDB.Unique]
public string Email;
}
```
**TypeScript:**
```typescript
const players = table(
{ name: 'players', public: true },
{
id: t.u32().primaryKey().autoInc(),
name: t.string().index('btree'),
email: t.string().unique(),
}
);
```
### Table Visibility
- **Private tables** (default): Only accessible by reducers and the database owner
- **Public tables**: Exposed for client read access through subscriptions. Writes still require reducers.
### Table Design Principles
Organize data by access pattern, not by entity:
**Decomposed approach (recommended):**
```
Player PlayerState PlayerStats
id <-- player_id player_id
name position_x total_kills
position_y total_deaths
velocity_x play_time
```
Benefits: reduced bandwidth, cache efficiency, schema evolution, semantic clarity.
## Reducers
Reducers are transactional functions that modify database state. They are the primary client-invoked mutation path; procedures can also mutate tables by running explicit transactions.
### Key Properties
- **Transactional**: Run in isolated database transactions
- **Atomic**: Either all changes succeed or all roll back
- **Isolated**: Cannot interact with the outside world (no network, no filesystem)
- **Callable**: Clients invoke reducers as remote procedure calls
### Critical Reducer Rules
1. **No global state**: Relying on static variables is undefined behavior
2. **No side effects**: Reducers cannot make network requests or access files
3. **Store state in tables**: All persistent state must be in tables
4. **No return data**: Reducers do not return data to callers — use subscriptions
5. **Must be deterministic**: No random, no timers, no external I/O
### Defining Reducers
**Rust:**
```rust
#[spacetimedb::reducer]
pub fn create_user(ctx: &ReducerContext, name: String, email: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
ctx.db.user().insert(User { id: 0, name, email });
Ok(())
}
```
**C#:**
```csharp
[SpacetimeDB.Reducer]
public static void CreateUser(ReducerContext ctx, string name, string email)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name cannot be empty");
ctx.Db.User.Insert(new User { Id = 0, Name = name, Email = email });
}
```
### ReducerContext
Every reducer receives a `ReducerContext` providing:
- **Database**: `ctx.db` (Rust field, TS property) / `ctx.Db` (C# property)
- **Sender**: `ctx.sender()` (Rust method) / `ctx.Sender` (C# property) / `ctx.sender` (TS property)
- **Connection ID**: `ctx.connection_id()` (Rust method) / `ctx.ConnectionId` (C# property) / `ctx.connectionId` (TS property)
- **Timestamp**: `ctx.timestamp` (Rust field, TS property) / `ctx.Timestamp` (C# property)
## Event Tables (2.0)
Event tables are the preferred way to broadcast reducer-specific data to clients.
```rust
#[table(accessor = damage_event, public, event)]
pub struct DamageEvent {
pub target: Identity,
pub amount: u32,
}
#[reducer]
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
ctx.db.damage_event().insert(DamageEvent { target, amount });
}
```
Clients subscribe to event tables and use `on_insert` callbacks. Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
## Subscriptions
Subscriptions replicate database rows to clients in real-time.
### How Subscriptions Work
1. **Subscribe**: Register SQL queries describing needed data
2. **Receive initial data**: All matching rows are sent immediately
3. **Receive updates**: Real-time updates when subscribed rows change
4. **React to changes**: Use callbacks (`onInsert`, `onDelete`, `onUpdate`)
### Subscription Best Practices
1. **Group subscriptions by lifetime**: Keep always-needed data separate from temporary subscriptions
2. **Subscribe before unsubscribing**: When updating subscriptions, subscribe to new data first
3. **Avoid overlapping queries**: Distinct queries returning overlapping data cause redundant processing
4. **Use indexes**: Queries on indexed columns are efficient; full table scans are expensive
## Modules
Modules are WebAssembly bundles containing application logic that runs inside the database.
### Module Components
- **Tables**: Define the data schema
- **Reducers**: Define callable functions that modify state
- **Views**: Define read-only computed queries
- **Event Tables**: Broadcast reducer-specific data to clients (2.0)
- **Procedures**: (Beta) Functions that can have side effects (HTTP requests)
### Module Languages
Server-side modules can be written in: Rust, C#, TypeScript (beta)
### Module Lifecycle
1. **Write**: Define tables and reducers in your chosen language
2. **Compile**: Build to WebAssembly using the SpacetimeDB CLI
3. **Publish**: Upload to a SpacetimeDB host with `spacetime publish`
4. **Hot-swap**: Republish to update code without disconnecting clients
## Identity
Identity is SpacetimeDB's authentication system based on OpenID Connect (OIDC).
- **Identity**: A long-lived, globally unique identifier for a user.
- **ConnectionId**: Identifies a specific client connection.
```rust
#[spacetimedb::reducer]
pub fn do_something(ctx: &ReducerContext) {
let caller_identity = ctx.sender(); // Who is calling?
// NEVER trust identity passed as a reducer argument
}
```
### Authentication Providers
SpacetimeDB works with many OIDC providers, including SpacetimeAuth (built-in), Auth0, Clerk, Keycloak, Google, and GitHub.
## When to Use SpacetimeDB
### Ideal Use Cases
- **Real-time games**: MMOs, multiplayer games, turn-based games
- **Collaborative applications**: Document editing, whiteboards, design tools
- **Chat and messaging**: Real-time communication with presence
- **Live dashboards**: Streaming analytics and monitoring
### Key Decision Factors
Choose SpacetimeDB when you need:
- Sub-10ms latency for reads and writes
- Automatic real-time synchronization
- Transactional guarantees for all operations
- Simplified architecture (no separate cache, queue, or server)
### Less Suitable For
- **Batch analytics**: Optimized for OLTP, not OLAP
- **Large blob storage**: Better suited for structured relational data
- **Stateless APIs**: Traditional REST APIs do not need real-time sync
## Common Patterns
**Authentication check in reducer:**
```rust
#[spacetimedb::reducer]
fn admin_action(ctx: &ReducerContext) -> Result<(), String> {
let admin = ctx.db.admin().identity().find(&ctx.sender())
.ok_or("Not an admin")?;
Ok(())
}
```
**Scheduled reducer:**
```rust
#[spacetimedb::table(accessor = reminder, scheduled(send_reminder))]
pub struct Reminder {
#[primary_key]
#[auto_inc]
id: u64,
scheduled_at: ScheduleAt,
message: String,
}
#[spacetimedb::reducer]
fn send_reminder(ctx: &ReducerContext, reminder: Reminder) {
log::info!("Reminder: {}", reminder.message);
}
```
---
## Editing Behavior
When modifying SpacetimeDB code:
- 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

View File

@@ -0,0 +1,646 @@
---
name: spacetimedb-csharp
description: Build C# modules and clients for SpacetimeDB. Covers server-side module development and client SDK integration.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
tested_with: "SpacetimeDB 2.0, .NET 8 SDK"
---
# SpacetimeDB C# SDK
This skill provides guidance for building C# server-side modules and C# clients that connect to SpacetimeDB 2.0.
---
## HALLUCINATED APIs — DO NOT USE
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
```csharp
// WRONG — these table access patterns do not exist
ctx.db.tableName // Wrong casing — use ctx.Db
ctx.Db.tableName // Wrong casing — accessor must match exactly
ctx.Db.TableName.Get(id) // Use Find, not Get
ctx.Db.TableName.FindById(id) // Use index accessor: ctx.Db.TableName.Id.Find(id)
ctx.Db.table.field_name.Find(x) // Wrong! Use PascalCase: ctx.Db.Table.FieldName.Find(x)
Optional<string> field; // Use C# nullable: string? field
// WRONG — missing partial keyword
public struct MyTable { } // Must be "partial struct"
public class Module { } // Must be "static partial class"
// WRONG — non-partial types
[SpacetimeDB.Table(Accessor = "Player")]
public struct Player { } // WRONG — missing partial!
// WRONG — sum type syntax (VERY COMMON MISTAKE)
public partial struct Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: struct, missing names
public partial record Shape : TaggedEnum<(Circle, Rectangle)> { } // WRONG: missing variant names
public partial class Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { } // WRONG: class
// WRONG — Index attribute without full qualification
[Index.BTree(Accessor = "idx", Columns = new[] { "Col" })] // Ambiguous with System.Index!
[SpacetimeDB.Index.BTree(Accessor = "idx", Columns = ["Col"])] // Valid with modern C# collection expressions
// WRONG — old 1.0 patterns
[SpacetimeDB.Table(Name = "Player")] // Use Accessor, not Name (2.0)
<PackageReference Include="SpacetimeDB.ServerSdk" /> // Use SpacetimeDB.Runtime
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
// WRONG — lifecycle hooks starting with "On"
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void OnClientConnected(ReducerContext ctx) { } // STDB0010 error!
// WRONG — non-deterministic code in reducers
var random = new Random(); // Use ctx.Rng
var guid = Guid.NewGuid(); // Not allowed
var now = DateTime.Now; // Use ctx.Timestamp
// WRONG — collection parameters
int[] itemIds = { 1, 2, 3 };
_conn.Reducers.ProcessItems(itemIds); // Generated code expects List<T>!
```
### CORRECT PATTERNS
```csharp
using SpacetimeDB;
// CORRECT TABLE — must be partial struct, use Accessor
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Name;
}
// CORRECT MODULE — must be static partial class
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreatePlayer(ReducerContext ctx, string name)
{
ctx.Db.Player.Insert(new Player { Id = 0, OwnerId = ctx.Sender, Name = name });
}
}
// CORRECT DATABASE ACCESS — PascalCase, index-based lookups
var player = ctx.Db.Player.Id.Find(playerId); // Unique/PK: returns nullable
foreach (var p in ctx.Db.Player.OwnerId.Filter(ctx.Sender)) { } // BTree: returns IEnumerable
// CORRECT SUM TYPE — partial record with named tuple elements
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// CORRECT — collection parameters use List<T>
_conn.Reducers.ProcessItems(new List<int> { 1, 2, 3 });
```
---
## Common Mistakes Table
| Wrong | Right | Error |
|-------|-------|-------|
| Wrong .csproj name | `StdbModule.csproj` | Publish fails silently |
| .NET 9 SDK | .NET 8 SDK only | WASI compilation fails |
| Missing WASI workload | `dotnet workload install wasi-experimental` | Build fails |
| async/await in reducers | Synchronous only | Not supported |
| `table.Name.Update(...)` | `table.Id.Update(...)` | Update only via primary key (2.0) |
| Not calling `FrameTick()` | `conn.FrameTick()` in Update loop | No callbacks fire |
| Accessing `conn.Db` from background thread | Copy data in callback | Data races |
---
## Hard Requirements
1. **Tables and Module MUST be `partial`** — required for code generation
2. **Use `Accessor =` in table attributes**`Name =` is only for SQL compatibility (2.0)
3. **Project file MUST be named `StdbModule.csproj`** — CLI requirement
4. **Requires .NET 8 SDK** — .NET 9 and newer not yet supported
5. **Install WASI workload**`dotnet workload install wasi-experimental`
6. **Procedures are supported** — use `[SpacetimeDB.Procedure]` with `ProcedureContext` when needed
7. **Reducers must be deterministic** — no filesystem, network, timers, or `Random`
8. **Add `Public = true`** — if clients need to subscribe to a table
9. **Use `T?` for nullable fields** — not `Optional<T>`
10. **Pass `0` for auto-increment** — to trigger ID generation on insert
11. **Sum types must be `partial record`** — not struct or class
12. **Fully qualify Index attribute**`[SpacetimeDB.Index.BTree]` to avoid System.Index ambiguity
13. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
14. **Use `SpacetimeDB.Runtime` package** — not `ServerSdk` (2.0)
15. **Use `List<T>` for collection parameters** — not arrays
16. **`Identity` is in `SpacetimeDB` namespace** — not `SpacetimeDB.Types`
---
## Server-Side Module Development
### Table Definition
```csharp
using SpacetimeDB;
[SpacetimeDB.Table(Accessor = "Player", Public = true)]
public partial struct Player
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
[SpacetimeDB.Index.BTree]
public Identity OwnerId;
public string Name;
public Timestamp CreatedAt;
}
// Multi-column index (use fully-qualified attribute!)
[SpacetimeDB.Table(Accessor = "Score", Public = true)]
[SpacetimeDB.Index.BTree(Accessor = "by_player_game", Columns = new[] { "PlayerId", "GameId" })]
public partial struct Score
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public Identity PlayerId;
public string GameId;
public int Points;
}
```
### Field Attributes
```csharp
[SpacetimeDB.PrimaryKey] // Exactly one per table (required)
[SpacetimeDB.AutoInc] // Auto-increment (integer fields only)
[SpacetimeDB.Unique] // Unique constraint
[SpacetimeDB.Index.BTree] // Single-column B-tree index
[SpacetimeDB.Default(value)] // Default value for new columns
```
### SpacetimeDB Column Types
```csharp
Identity // User identity (SpacetimeDB namespace, not SpacetimeDB.Types)
Timestamp // Timestamp (use ctx.Timestamp server-side, never DateTime.Now)
ScheduleAt // For scheduled tables
T? // Nullable (e.g., string?)
List<T> // Collections (use List, not arrays)
```
Standard C# primitives (`bool`, `byte`..`ulong`, `float`, `double`, `string`) are all supported.
### Insert with Auto-Increment
```csharp
var player = ctx.Db.Player.Insert(new Player
{
Id = 0, // Pass 0 to trigger auto-increment
OwnerId = ctx.Sender,
Name = name,
CreatedAt = ctx.Timestamp
});
ulong newId = player.Id; // Insert returns the row with generated ID
```
### Module and Reducers
```csharp
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void CreateTask(ReducerContext ctx, string title)
{
if (string.IsNullOrEmpty(title))
throw new Exception("Title cannot be empty");
ctx.Db.Task.Insert(new Task
{
Id = 0,
OwnerId = ctx.Sender,
Title = title,
Completed = false
});
}
[SpacetimeDB.Reducer]
public static void CompleteTask(ReducerContext ctx, ulong taskId)
{
if (ctx.Db.Task.Id.Find(taskId) is not Task task)
throw new Exception("Task not found");
if (task.OwnerId != ctx.Sender)
throw new Exception("Not authorized");
ctx.Db.Task.Id.Update(task with { Completed = true });
}
[SpacetimeDB.Reducer]
public static void DeleteTask(ReducerContext ctx, ulong taskId)
{
ctx.Db.Task.Id.Delete(taskId);
}
}
```
### Lifecycle Reducers
```csharp
public static partial class Module
{
[SpacetimeDB.Reducer(ReducerKind.Init)]
public static void Init(ReducerContext ctx)
{
Log.Info("Module initialized");
}
// CRITICAL: no "On" prefix!
[SpacetimeDB.Reducer(ReducerKind.ClientConnected)]
public static void ClientConnected(ReducerContext ctx)
{
Log.Info($"Client connected: {ctx.Sender}");
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
{
ctx.Db.User.Identity.Update(user with { Online = true });
}
else
{
ctx.Db.User.Insert(new User { Identity = ctx.Sender, Online = true });
}
}
[SpacetimeDB.Reducer(ReducerKind.ClientDisconnected)]
public static void ClientDisconnected(ReducerContext ctx)
{
if (ctx.Db.User.Identity.Find(ctx.Sender) is User user)
{
ctx.Db.User.Identity.Update(user with { Online = false });
}
}
}
```
### Event Tables (2.0)
Reducer callbacks are removed in 2.0. Use event tables + `OnInsert` instead.
```csharp
[SpacetimeDB.Table(Accessor = "DamageEvent", Public = true, Event = true)]
public partial struct DamageEvent
{
public Identity Target;
public uint Amount;
}
[SpacetimeDB.Reducer]
public static void DealDamage(ReducerContext ctx, Identity target, uint amount)
{
ctx.Db.DamageEvent.Insert(new DamageEvent { Target = target, Amount = amount });
}
```
Client subscribes and uses `OnInsert`:
```csharp
conn.Db.DamageEvent.OnInsert += (ctx, evt) => {
PlayDamageAnimation(evt.Target, evt.Amount);
};
```
Event tables must be subscribed explicitly — they are excluded from `SubscribeToAllTables()`.
### Database Access
```csharp
// Find by primary key — returns nullable, use pattern matching
if (ctx.Db.Task.Id.Find(taskId) is Task task) { /* use task */ }
// Update by primary key (2.0: only primary key has .Update)
ctx.Db.Task.Id.Update(task with { Title = newTitle });
// Delete by primary key
ctx.Db.Task.Id.Delete(taskId);
// Find by unique index — returns nullable
if (ctx.Db.Player.Username.Find("alice") is Player player) { }
// Filter by B-tree index — returns iterator
foreach (var task in ctx.Db.Task.OwnerId.Filter(ctx.Sender)) { }
// Full table scan — avoid for large tables
foreach (var task in ctx.Db.Task.Iter()) { }
var count = ctx.Db.Task.Count;
```
### Custom Types and Sum Types
```csharp
[SpacetimeDB.Type]
public partial struct Position { public int X; public int Y; }
// Sum types MUST be partial record with named tuple
[SpacetimeDB.Type]
public partial struct Circle { public int Radius; }
[SpacetimeDB.Type]
public partial struct Rectangle { public int Width; public int Height; }
[SpacetimeDB.Type]
public partial record Shape : TaggedEnum<(Circle Circle, Rectangle Rectangle)> { }
// Creating sum type values
var circle = new Shape.Circle(new Circle { Radius = 10 });
```
### Scheduled Tables
```csharp
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = nameof(Module.SendReminder))]
public partial struct Reminder
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong Id;
public string Message;
public ScheduleAt ScheduledAt;
}
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void SendReminder(ReducerContext ctx, Reminder reminder)
{
Log.Info($"Reminder: {reminder.Message}");
}
[SpacetimeDB.Reducer]
public static void CreateReminder(ReducerContext ctx, string message, ulong delaySecs)
{
ctx.Db.Reminder.Insert(new Reminder
{
Id = 0,
Message = message,
ScheduledAt = new ScheduleAt.Time(ctx.Timestamp + TimeSpan.FromSeconds(delaySecs))
});
}
}
```
### Logging
```csharp
Log.Debug("Debug message");
Log.Info("Information");
Log.Warn("Warning");
Log.Error("Error occurred");
Log.Exception("Critical failure"); // Logs at error level
```
### ReducerContext API
```csharp
ctx.Sender // Identity of the caller
ctx.Timestamp // Current timestamp
ctx.Db // Database access
ctx.Identity // Module's own identity
ctx.ConnectionId // Connection ID (nullable)
ctx.SenderAuth // Authorization context (JWT claims, internal call detection)
ctx.Rng // Deterministic random number generator
```
### Error Handling
Throwing an exception in a reducer rolls back the entire transaction:
```csharp
[SpacetimeDB.Reducer]
public static void TransferCredits(ReducerContext ctx, Identity toUser, uint amount)
{
if (ctx.Db.User.Identity.Find(ctx.Sender) is not User sender)
throw new Exception("Sender not found");
if (sender.Credits < amount)
throw new Exception("Insufficient credits");
ctx.Db.User.Identity.Update(sender with { Credits = sender.Credits - amount });
if (ctx.Db.User.Identity.Find(toUser) is User receiver)
ctx.Db.User.Identity.Update(receiver with { Credits = receiver.Credits + amount });
}
```
---
## Project Setup
### Required .csproj (MUST be named `StdbModule.csproj`)
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>wasi-wasm</RuntimeIdentifier>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SpacetimeDB.Runtime" Version="1.*" />
</ItemGroup>
</Project>
```
### Prerequisites
```bash
# Install .NET 8 SDK (required, not .NET 9)
# Install WASI workload
dotnet workload install wasi-experimental
```
---
## Client SDK
### Installation
```bash
dotnet add package SpacetimeDB.ClientSDK
```
### Generate Module Bindings
```bash
spacetime generate --lang csharp --out-dir module_bindings --module-path PATH_TO_MODULE
```
This creates `SpacetimeDBClient.g.cs`, `Tables/*.g.cs`, `Reducers/*.g.cs`, and `Types/*.g.cs`.
### Connection Setup
```csharp
using SpacetimeDB;
using SpacetimeDB.Types;
var conn = DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-database")
.WithToken(savedToken)
.OnConnect(OnConnected)
.OnConnectError(err => Console.Error.WriteLine($"Failed: {err}"))
.OnDisconnect((conn, err) => { if (err != null) Console.Error.WriteLine(err); })
.Build();
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// Save authToken to persistent storage for reconnection
Console.WriteLine($"Connected: {identity}");
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
}
```
### Critical: FrameTick
**The SDK does NOT automatically process messages.** You must call `FrameTick()` regularly.
```csharp
// Console application
while (running) { conn.FrameTick(); Thread.Sleep(16); }
// Unity: call conn?.FrameTick() in Update()
```
**Warning**: Do NOT call `FrameTick()` from a background thread. It modifies `conn.Db` and can cause data races.
### Subscribing to Tables
```csharp
// SQL queries
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.OnError((ctx, err) => Console.Error.WriteLine($"Subscription failed: {err}"))
.Subscribe(new[] {
"SELECT * FROM player",
"SELECT * FROM message WHERE sender = :sender"
});
// Subscribe to all tables (development only)
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
// Subscription handle for later unsubscribe
SubscriptionHandle handle = conn.SubscriptionBuilder()
.OnApplied(ctx => Console.WriteLine("Applied"))
.Subscribe(new[] { "SELECT * FROM player" });
handle.UnsubscribeThen(ctx => Console.WriteLine("Unsubscribed"));
```
**Warning**: `SubscribeToAllTables()` cannot be mixed with `Subscribe()` on the same connection.
### Accessing the Client Cache
```csharp
// Iterate all rows
foreach (var player in ctx.Db.Player.Iter()) { Console.WriteLine(player.Name); }
// Count rows
int playerCount = ctx.Db.Player.Count;
// Find by unique/primary key — returns nullable
Player? player = ctx.Db.Player.Identity.Find(someIdentity);
if (player != null) { Console.WriteLine(player.Name); }
// Filter by BTree index — returns IEnumerable
foreach (var p in ctx.Db.Player.Level.Filter(1)) { }
```
### Row Event Callbacks
```csharp
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
Console.WriteLine($"Player joined: {player.Name}");
};
ctx.Db.Player.OnDelete += (EventContext ctx, Player player) => {
Console.WriteLine($"Player left: {player.Name}");
};
ctx.Db.Player.OnUpdate += (EventContext ctx, Player oldRow, Player newRow) => {
Console.WriteLine($"Player {oldRow.Name} renamed to {newRow.Name}");
};
// Checking event source
ctx.Db.Player.OnInsert += (EventContext ctx, Player player) => {
switch (ctx.Event)
{
case Event<Reducer>.SubscribeApplied:
break; // Initial subscription data
case Event<Reducer>.Reducer(var reducerEvent):
Console.WriteLine($"Reducer: {reducerEvent.Reducer}");
break;
}
};
```
### Calling Reducers
```csharp
ctx.Reducers.SendMessage("Hello, world!");
ctx.Reducers.CreatePlayer("NewPlayer");
// Reducer completion callbacks
conn.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
if (ctx.Event.Status is Status.Committed)
Console.WriteLine($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Console.Error.WriteLine($"Send failed: {reason}");
};
// Unhandled reducer errors
conn.OnUnhandledReducerError += (ReducerEventContext ctx, Exception ex) => {
Console.Error.WriteLine($"Reducer error: {ex.Message}");
};
```
### Identity and Authentication
```csharp
// In OnConnect callback — save token for reconnection
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// Save authToken to persistent storage (file, config, PlayerPrefs, etc.)
SaveToken(authToken);
}
// Reconnect with saved token
string savedToken = LoadToken();
DbConnection.Builder()
.WithUri("http://localhost:3000")
.WithDatabaseName("my-database")
.WithToken(savedToken)
.OnConnect(OnConnected)
.Build();
// Pass null or omit WithToken for anonymous connection
```
---
## Commands
```bash
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang csharp --out-dir <client>/SpacetimeDB --module-path <backend-dir>
spacetime logs <module-name>
```

View File

@@ -0,0 +1,556 @@
---
name: spacetimedb-rust
description: Develop SpacetimeDB server modules in Rust. Use when writing reducers, tables, or module logic.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
---
# SpacetimeDB Rust Module Development
SpacetimeDB modules are WebAssembly applications that run inside the database. They define tables to store data and reducers to modify data. Clients connect directly to the database and execute application logic inside it.
> **Tested with:** SpacetimeDB 2.0+ APIs
---
## HALLUCINATED APIs — DO NOT USE
**These APIs/patterns are incorrect. LLMs frequently hallucinate them.**
Both macro forms are valid in 2.0: `#[spacetimedb::table(...)]` / `#[table(...)]` and `#[spacetimedb::reducer]` / `#[reducer]`.
```rust
#[derive(Table)] // Tables use #[table] attribute, not derive
#[derive(Reducer)] // Reducers use #[reducer] attribute
// WRONG — SpacetimeType on tables
#[derive(SpacetimeType)] // DO NOT use on #[table] structs!
#[table(accessor = my_table)]
pub struct MyTable { ... }
// WRONG — mutable context
pub fn my_reducer(ctx: &mut ReducerContext, ...) { } // Should be &ReducerContext
// WRONG — table access without parentheses
ctx.db.player // Should be ctx.db.player()
ctx.db.player.find(id) // Should be ctx.db.player().id().find(&id)
// WRONG — old 1.0 patterns
ctx.sender // Use ctx.sender() — method, not field (2.0)
.with_module_name("db") // Use .with_database_name() (2.0)
ctx.db.user().name().update(..) // Update only via primary key (2.0)
```
### CORRECT PATTERNS:
```rust
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
use spacetimedb::SpacetimeType; // Only for custom types, NOT tables
// CORRECT TABLE — accessor, not name; no SpacetimeType derive!
#[table(accessor = player, public)]
pub struct Player {
#[primary_key]
pub id: u64,
pub name: String,
}
// CORRECT REDUCER — immutable context, sender() is a method
#[reducer]
pub fn create_player(ctx: &ReducerContext, name: String) {
ctx.db.player().insert(Player { id: 0, name });
}
// CORRECT TABLE ACCESS — methods with parentheses, sender() method
let player = ctx.db.player().id().find(&player_id);
let caller = ctx.sender();
```
### DO NOT:
- **Derive `SpacetimeType` on `#[table]` structs** — the macro handles this
- **Use mutable context** — `&ReducerContext`, not `&mut ReducerContext`
- **Forget `Table` trait import** — required for table operations
- **Use field access for tables** — `ctx.db.player()` not `ctx.db.player`
- **Use `ctx.sender`** — it's `ctx.sender()` (method) in 2.0
---
## Common Mistakes Table
| Wrong | Right | Error |
|-------|-------|-------|
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
---
## Hard Requirements
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
2. **Import `Table` trait** — required for all table operations
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
4. **Tables are methods**`ctx.db.table()` not `ctx.db.table`
5. **Use `ctx.sender()`** — method call, not field access (2.0)
6. **Use `accessor =` for API handles**`name = "..."` is optional canonical naming in table/index attributes
7. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
8. **Use `ctx.rng()`** — not `rand` crate for random numbers
9. **Add `public` flag** — if clients need to subscribe to a table
10. **Update only via primary key** — use delete+insert for non-PK changes (2.0)
---
## Project Setup
```toml
[package]
name = "my-module"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
spacetimedb = { workspace = true }
log = "0.4"
```
### Essential Imports
```rust
use spacetimedb::{ReducerContext, Table};
use spacetimedb::{Identity, Timestamp, ConnectionId, ScheduleAt};
```
## Table Definitions
```rust
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
#[auto_inc]
id: u64,
name: String,
score: u32,
}
```
### Table Attributes
| Attribute | Description |
|-----------|-------------|
| `accessor = identifier` | Required. The API name used in `ctx.db.{accessor}()` |
| `public` | Makes table visible to clients via subscriptions |
| `scheduled(function_name)` | Creates a schedule table that triggers the named reducer or procedure |
| `index(accessor = idx, btree(columns = [a, b]))` | Multi-column index |
### Column Attributes
| Attribute | Description |
|-----------|-------------|
| `#[primary_key]` | Unique identifier for the row (one per table max) |
| `#[unique]` | Enforces uniqueness, enables `find()` method |
| `#[auto_inc]` | Auto-generates unique integer values when inserting 0 |
| `#[index(btree)]` | Creates a B-tree index for efficient lookups |
### Supported Column Types
**Primitives**: `u8`-`u256`, `i8`-`i256`, `f32`, `f64`, `bool`, `String`
**SpacetimeDB Types**: `Identity`, `ConnectionId`, `Timestamp`, `Uuid`, `ScheduleAt`
**Collections**: `Vec<T>`, `Option<T>`, `Result<T, E>`
**Custom Types**: Any struct/enum with `#[derive(SpacetimeType)]`
---
## Reducers
```rust
#[spacetimedb::reducer]
pub fn create_player(ctx: &ReducerContext, name: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
ctx.db.player().insert(Player { id: 0, name, score: 0 });
Ok(())
}
```
### Reducer Rules
1. First parameter must be `&ReducerContext`
2. Return `()`, `Result<(), String>`, or `Result<(), E>` where `E: Display`
3. All changes roll back on panic or `Err` return
4. Must import `Table` trait: `use spacetimedb::Table;`
### ReducerContext
```rust
ctx.db // Database access
ctx.sender() // Identity of the caller (method, not field!)
ctx.connection_id() // Option<ConnectionId> (None for scheduled/system reducers)
ctx.timestamp // Invocation timestamp
ctx.identity() // Module's own identity
ctx.rng() // Deterministic RNG (method, not field!)
```
---
## Table Operations
### Insert
```rust
// Insert returns the row with auto_inc values populated
let player = ctx.db.player().insert(Player { id: 0, name: "Alice".into(), score: 100 });
log::info!("Created player with id: {}", player.id);
```
### Find and Filter
```rust
// Find by unique/primary key — returns Option
if let Some(player) = ctx.db.player().id().find(&123) {
log::info!("Found: {}", player.name);
}
// Optional clarity: typed literals can avoid inference ambiguity
if let Some(player) = ctx.db.player().id().find(&123u64) {
log::info!("Found: {}", player.name);
}
// Filter by indexed column — returns iterator
for player in ctx.db.player().name().filter(&"Alice".to_string()) {
log::info!("Player: {}", player.name);
}
// Full table scan
for player in ctx.db.player().iter() { }
let total = ctx.db.player().count();
```
### Update
```rust
// Update via primary key (2.0: only primary key has update)
if let Some(player) = ctx.db.player().id().find(&123) {
ctx.db.player().id().update(Player { score: player.score + 10, ..player });
}
// For non-PK changes: delete + insert
if let Some(old) = ctx.db.player().id().find(&id) {
ctx.db.player().id().delete(&id);
ctx.db.player().insert(Player { name: new_name, ..old });
}
```
### Delete
```rust
// Delete by primary key
ctx.db.player().id().delete(&123);
// Delete by indexed column (collect first to avoid iterator invalidation)
let to_remove: Vec<u64> = ctx.db.player().name().filter(&"Alice".to_string())
.map(|p| p.id)
.collect();
for id in to_remove {
ctx.db.player().id().delete(&id);
}
```
---
## Indexes
```rust
// Single-column index
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u64,
#[index(btree)]
level: u32,
name: String,
}
// Multi-column index
#[spacetimedb::table(
accessor = score, public,
index(accessor = by_player_level, btree(columns = [player_id, level]))
)]
pub struct Score {
player_id: u32,
level: u32,
points: i64,
}
// Multi-column index querying: prefix match (first column only)
for s in ctx.db.score().by_player_level().filter(&(42,)) {
log::info!("Player 42, any level: {} pts", s.points);
}
// Full match (both columns)
for s in ctx.db.score().by_player_level().filter(&(42, 5)) {
log::info!("Player 42, level 5: {} pts", s.points);
}
```
---
## Event Tables (2.0)
Reducer callbacks are removed in 2.0. Use event tables + `on_insert` instead.
```rust
#[table(accessor = damage_event, public, event)]
pub struct DamageEvent {
pub target: Identity,
pub amount: u32,
}
#[reducer]
fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
ctx.db.damage_event().insert(DamageEvent { target, amount });
}
```
Client subscribes and uses `on_insert`:
```rust
conn.db.damage_event().on_insert(|ctx, event| {
play_damage_animation(event.target, event.amount);
});
```
Event tables must be subscribed explicitly — they are excluded from `subscribe_to_all_tables()`.
---
## Lifecycle Reducers
```rust
#[spacetimedb::reducer(init)]
pub fn init(ctx: &ReducerContext) -> Result<(), String> {
log::info!("Database initializing...");
ctx.db.config().insert(Config {
id: 0,
max_players: 100,
game_mode: "default".to_string(),
});
Ok(())
}
#[spacetimedb::reducer(client_connected)]
pub fn on_connect(ctx: &ReducerContext) -> Result<(), String> {
let caller = ctx.sender();
log::info!("Client connected: {}", caller);
if let Some(user) = ctx.db.user().identity().find(&caller) {
ctx.db.user().identity().update(User { online: true, ..user });
} else {
ctx.db.user().insert(User {
identity: caller,
name: format!("User-{}", &caller.to_hex()[..8]),
online: true,
});
}
Ok(())
}
#[spacetimedb::reducer(client_disconnected)]
pub fn on_disconnect(ctx: &ReducerContext) -> Result<(), String> {
let caller = ctx.sender();
if let Some(user) = ctx.db.user().identity().find(&caller) {
ctx.db.user().identity().update(User { online: false, ..user });
}
Ok(())
}
```
---
## Scheduled Reducers
```rust
use spacetimedb::ScheduleAt;
use std::time::Duration;
#[spacetimedb::table(accessor = game_tick_schedule, scheduled(game_tick))]
pub struct GameTickSchedule {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
scheduled_at: ScheduleAt,
}
#[spacetimedb::reducer]
fn game_tick(ctx: &ReducerContext, schedule: GameTickSchedule) {
if !ctx.sender_auth().is_internal() { return; }
log::info!("Game tick at {:?}", ctx.timestamp);
}
// Schedule at interval (e.g., in init reducer)
ctx.db.game_tick_schedule().insert(GameTickSchedule {
scheduled_id: 0,
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
});
// Schedule at specific time
let run_at = ctx.timestamp + Duration::from_secs(delay_secs);
ctx.db.reminder_schedule().insert(ReminderSchedule {
scheduled_id: 0,
scheduled_at: ScheduleAt::Time(run_at),
});
```
---
## Identity and Authentication
```rust
#[spacetimedb::table(accessor = user, public)]
pub struct User {
#[primary_key]
identity: Identity,
name: String,
online: bool,
}
#[spacetimedb::reducer]
pub fn set_name(ctx: &ReducerContext, new_name: String) -> Result<(), String> {
let caller = ctx.sender();
let user = ctx.db.user().identity().find(&caller)
.ok_or("User not found — connect first")?;
ctx.db.user().identity().update(User { name: new_name, ..user });
Ok(())
}
```
### Owner-Only Reducer Pattern
```rust
fn require_owner(ctx: &ReducerContext, entity_owner: &Identity) -> Result<(), String> {
if ctx.sender() != *entity_owner {
Err("Not authorized: you don't own this entity".to_string())
} else {
Ok(())
}
}
#[spacetimedb::reducer]
pub fn rename_character(ctx: &ReducerContext, char_id: u64, new_name: String) -> Result<(), String> {
let character = ctx.db.character().id().find(&char_id)
.ok_or("Character not found")?;
require_owner(ctx, &character.owner)?;
ctx.db.character().id().update(Character { name: new_name, ..character });
Ok(())
}
```
---
## Error Handling
```rust
// Sender error — return Err (user sees message, transaction rolls back cleanly)
#[spacetimedb::reducer]
pub fn transfer(ctx: &ReducerContext, to: Identity, amount: u64) -> Result<(), String> {
let sender = ctx.db.wallet().identity().find(&ctx.sender())
.ok_or("Wallet not found")?;
if sender.balance < amount {
return Err("Insufficient balance".to_string());
}
// ... proceed with transfer
Ok(())
}
// Programmer error — panic (destroys the WASM instance, expensive!)
// Only use for truly impossible states
#[spacetimedb::reducer]
pub fn process(ctx: &ReducerContext, id: u64) {
let item = ctx.db.item().id().find(&id)
.expect("BUG: item should exist at this point");
// ...
}
```
Prefer `Result<(), String>` for all expected failure cases. Panics destroy and recreate the WASM instance.
---
## Procedures (Beta)
> Procedures are behind the `unstable` feature in `spacetimedb`.
> In `Cargo.toml`: `spacetimedb = { version = "...", features = ["unstable"] }`
```rust
use spacetimedb::{procedure, ProcedureContext};
#[procedure]
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
let data = fetch_from_url(&url)?;
ctx.try_with_tx(|tx| {
tx.db.external_data().insert(ExternalData { id: 0, content: data });
Ok(())
})?;
Ok(())
}
```
| 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 |
---
## Custom Types
```rust
use spacetimedb::SpacetimeType;
#[derive(SpacetimeType)]
pub enum PlayerStatus { Active, Idle, Away }
#[derive(SpacetimeType)]
pub struct Position { x: f32, y: f32, z: f32 }
// Use in table (DO NOT derive SpacetimeType on the table!)
#[spacetimedb::table(accessor = player, public)]
pub struct Player {
#[primary_key]
id: u64,
status: PlayerStatus,
position: Position,
}
```
---
## Commands
```bash
spacetime build
spacetime publish my_database --module-path .
spacetime publish my_database --clear-database --module-path .
spacetime logs my_database
spacetime call my_database create_player "Alice"
spacetime sql my_database "SELECT * FROM player"
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
```
## Important Constraints
1. **No Global State**: Static/global variables are undefined behavior across reducer calls
2. **No Side Effects**: Reducers cannot make network requests or file I/O
3. **Deterministic Execution**: Use `ctx.rng()` and `ctx.new_uuid_*()` for randomness
4. **Transactional**: All reducer changes roll back on failure
5. **Isolated**: Reducers don't see concurrent changes until commit

View File

@@ -0,0 +1,489 @@
---
name: spacetimedb-typescript
description: Build TypeScript clients for SpacetimeDB. Use when connecting to SpacetimeDB from web apps, Node.js, Deno, Bun, or other JavaScript runtimes.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
---
# SpacetimeDB TypeScript SDK
Build real-time TypeScript clients that connect directly to SpacetimeDB modules. The SDK provides type-safe database access, automatic synchronization, and reactive updates for web apps, Node.js, Deno, Bun, and other JavaScript runtimes.
---
## HALLUCINATED APIs — DO NOT USE
**These APIs DO NOT EXIST. LLMs frequently hallucinate them.**
```typescript
// WRONG PACKAGE — does not exist
import { SpacetimeDBClient } from "@clockworklabs/spacetimedb-sdk";
// WRONG — these methods don't exist
SpacetimeDBClient.connect(...);
SpacetimeDBClient.call("reducer_name", [...]);
connection.call("reducer_name", [arg1, arg2]);
// WRONG — positional reducer arguments
conn.reducers.doSomething("value"); // WRONG!
// WRONG — old 1.0 patterns
spacetimedb.reducer('reducer_name', params, fn); // Use export const name = spacetimedb.reducer(params, fn)
schema(myTable); // Use schema({ myTable })
schema(t1, t2, t3); // Use schema({ t1, t2, t3 })
scheduled: 'run_cleanup' // Use scheduled: () => run_cleanup
.withModuleName('db') // Use .withDatabaseName('db') (2.0)
setReducerFlags.x('NoSuccessNotify') // Removed in 2.0
```
### CORRECT PATTERNS:
```typescript
// CORRECT IMPORTS
import { DbConnection, tables } from './module_bindings'; // Generated!
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
import { Identity } from 'spacetimedb';
// CORRECT REDUCER CALLS — object syntax, not positional!
conn.reducers.doSomething({ value: 'test' });
conn.reducers.updateItem({ itemId: 1n, newValue: 42 });
// CORRECT DATA ACCESS — useTable returns [rows, isReady]
const [items, isReady] = useTable(tables.item);
```
### DO NOT:
- **Invent hooks** like `useItems()`, `useData()` — use `useTable(tables.tableName)`
- **Import from fake packages** — only `spacetimedb`, `spacetimedb/react`, `./module_bindings`
---
## Common Mistakes Table
### Server-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| Missing `package.json` | Create `package.json` | "could not detect language" |
| Missing `tsconfig.json` | Create `tsconfig.json` | "TsconfigNotFound" |
| Entrypoint not at `src/index.ts` | Use `src/index.ts` | Module won't bundle |
| `indexes` in COLUMNS (2nd arg) | `indexes` in OPTIONS (1st arg) of `table()` | "reading 'tag'" error |
| Index without `algorithm` | `algorithm: 'btree'` | "reading 'tag'" error |
| `filter({ ownerId })` | `filter(ownerId)` | "does not exist in type 'Range'" |
| `.filter()` on unique column | `.find()` on unique column | TypeError |
| `insert({ ...without id })` | `insert({ id: 0n, ... })` | "Property 'id' is missing" |
| `const id = table.insert(...)` | `const row = table.insert(...)` | `.insert()` returns ROW, not ID |
| `.unique()` + explicit index | Just use `.unique()` | "name is used for multiple entities" |
| Import spacetimedb from index.ts | Import from schema.ts | "Cannot access before initialization" |
| Incorrect multi-column `.filter()` range shape | Match index prefix/tuple shape | Empty results or range/type errors |
| `.iter()` in views | Use index lookups only | Views can't scan tables |
| `ctx.db` in procedures | `ctx.withTx(tx => tx.db...)` | Procedures need explicit transactions |
### Client-side errors
| Wrong | Right | Error |
|-------|-------|-------|
| Inline `connectionBuilder` | `useMemo(() => ..., [])` | Reconnects every render |
| `const rows = useTable(table)` | `const [rows, isReady] = useTable(table)` | Tuple destructuring |
| Optimistic UI updates | Let subscriptions drive state | Desync issues |
| `<SpacetimeDBProvider builder={...}>` | `connectionBuilder={...}` | Wrong prop name |
---
## Hard Requirements
1. **`schema({ table })`** — use a single tables object; optional module settings are allowed as a second argument
2. **Reducer/procedure names from exports**`export const name = spacetimedb.reducer(params, fn)`; never `reducer('name', ...)`
3. **Reducer calls use object syntax**`{ param: 'value' }` not positional args
4. **Import `DbConnection` from `./module_bindings`** — not from `spacetimedb`
5. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
6. **Indexes go in OPTIONS (1st arg)** — not in COLUMNS (2nd arg) of `table()`
7. **Use BigInt for u64/i64 fields**`0n`, `1n`, not `0`, `1`
8. **Reducers are transactional** — they do not return data
9. **Reducers must be deterministic** — no filesystem, network, timers, random
10. **Views should use index lookups**`.iter()` causes severe performance issues
11. **Procedures need `ctx.withTx()`**`ctx.db` doesn't exist in procedures
12. **Sum type values** — use `{ tag: 'variant', value: payload }` not `{ variant: payload }`
13. **Use `.withDatabaseName()`** — not `.withModuleName()` (2.0)
---
## Installation
```bash
npm install spacetimedb
```
For Node.js environments without native fetch/WebSocket support, install `undici`.
## Generating Type Bindings
```bash
spacetime generate --lang typescript --out-dir ./src/module_bindings --module-path ./server
```
## Client Connection
```typescript
import { DbConnection } from './module_bindings';
const connection = DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my_database')
.withToken(localStorage.getItem('spacetimedb_token') ?? undefined)
.onConnect((conn, identity, token) => {
// identity: your unique Identity for this database
console.log('Connected as:', identity.toHexString());
// Save token for reconnection (preserves identity across sessions)
localStorage.setItem('spacetimedb_token', token);
conn.subscriptionBuilder()
.onApplied(() => console.log('Cache ready'))
.subscribe('SELECT * FROM player');
})
.onDisconnect((ctx) => console.log('Disconnected'))
.onConnectError((ctx, error) => console.error('Connection failed:', error))
.build();
```
## Subscribing to Tables
```typescript
// Basic subscription
connection.subscriptionBuilder()
.onApplied((ctx) => console.log('Cache ready'))
.subscribe('SELECT * FROM player');
// Multiple queries
connection.subscriptionBuilder()
.subscribe(['SELECT * FROM player', 'SELECT * FROM game_state']);
// Subscribe to all tables (development only — cannot mix with Subscribe)
connection.subscriptionBuilder().subscribeToAllTables();
// Subscription handle for later unsubscribe
const handle = connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.subscribe('SELECT * FROM player');
handle.unsubscribeThen(() => console.log('Unsubscribed'));
```
## Accessing Table Data
```typescript
for (const player of connection.db.player.iter()) { console.log(player.name); }
const players = Array.from(connection.db.player.iter());
const count = connection.db.player.count();
const player = connection.db.player.id.find(42n);
```
## Table Event Callbacks
```typescript
connection.db.player.onInsert((ctx, player) => console.log('New:', player.name));
connection.db.player.onDelete((ctx, player) => console.log('Left:', player.name));
connection.db.player.onUpdate((ctx, old, new_) => console.log(`${old.score} -> ${new_.score}`));
```
## Calling Reducers
**CRITICAL: Use object syntax, not positional arguments.**
```typescript
connection.reducers.createPlayer({ name: 'Alice', location: { x: 0, y: 0 } });
```
### Snake_case to camelCase conversion
- Server: `export const do_something = spacetimedb.reducer(...)`
- Client: `conn.reducers.doSomething({ ... })`
---
## Identity and Authentication
- `identity` and `token` are provided in the `onConnect` callback (see Client Connection above)
- `identity.toHexString()` for display or logging
- Omit `.withToken()` for anonymous connection — server assigns a new identity
- Pass a stale/invalid token: server issues a new identity and token in `onConnect`
---
## Error Handling
Connection-level errors (`.onConnectError`, `.onDisconnect`) are shown in the Client Connection example above.
```typescript
// Subscription error
connection.subscriptionBuilder()
.onApplied(() => console.log('Subscribed'))
.onError((ctx) => console.error('Subscription error:', ctx.event))
.subscribe('SELECT * FROM player');
```
---
## Server-Side Module Development
### Table Definition
```typescript
import { schema, table, t } from 'spacetimedb/server';
export const Task = table({
name: 'task',
public: true,
indexes: [{ name: 'task_owner_id', algorithm: 'btree', columns: ['ownerId'] }]
}, {
id: t.u64().primaryKey().autoInc(),
ownerId: t.identity(),
title: t.string(),
createdAt: t.timestamp(),
});
```
### Column types
```typescript
t.identity() // User identity
t.u64() // Unsigned 64-bit integer (use for IDs)
t.string() // Text
t.bool() // Boolean
t.timestamp() // Timestamp
t.scheduleAt() // For scheduled tables only
t.object('Name', {}) // Product types (nested objects)
t.enum('Name', {}) // Sum types (tagged unions)
t.string().optional() // Nullable
```
> BigInt syntax: All `u64`/`i64` fields use `0n`, `1n`, not `0`, `1`.
### Schema export
```typescript
const spacetimedb = schema({ Task, Player });
export default spacetimedb;
```
### Reducer Definition (2.0)
**Name comes from the export — NOT from a string argument.**
```typescript
import spacetimedb from './schema';
import { t, SenderError } from 'spacetimedb/server';
export const create_task = spacetimedb.reducer(
{ title: t.string() },
(ctx, { title }) => {
if (!title) throw new SenderError('title required');
ctx.db.task.insert({ id: 0n, ownerId: ctx.sender, title, createdAt: ctx.timestamp });
}
);
```
### Update Pattern
```typescript
const existing = ctx.db.task.id.find(taskId);
if (!existing) throw new SenderError('Task not found');
ctx.db.task.id.update({ ...existing, title: newTitle, updatedAt: ctx.timestamp });
```
### Lifecycle Hooks
```typescript
spacetimedb.clientConnected((ctx) => { /* ctx.sender is the connecting identity */ });
spacetimedb.clientDisconnected((ctx) => { /* clean up */ });
```
---
## Event Tables (2.0)
Reducer callbacks are removed in 2.0. Use event tables + `onInsert` instead.
```typescript
export const DamageEvent = table(
{ name: 'damage_event', public: true, event: true },
{ target: t.identity(), amount: t.u32() }
);
export const deal_damage = spacetimedb.reducer(
{ target: t.identity(), amount: t.u32() },
(ctx, { target, amount }) => {
ctx.db.damageEvent.insert({ target, amount });
}
);
```
Client subscribes and uses `onInsert`:
```typescript
conn.db.damageEvent.onInsert((ctx, evt) => {
playDamageAnimation(evt.target, evt.amount);
});
```
Event tables must be subscribed explicitly — they are excluded from `subscribeToAllTables()`.
---
## Views
### ViewContext vs AnonymousViewContext
```typescript
// ViewContext — has ctx.sender, result varies per user
spacetimedb.view({ name: 'my_items', public: true }, t.array(Item.rowType), (ctx) => {
return [...ctx.db.item.by_owner.filter(ctx.sender)];
});
// AnonymousViewContext — no ctx.sender, same result for everyone (better perf)
spacetimedb.anonymousView({ name: 'leaderboard', public: true }, t.array(Player.rowType), (ctx) => {
return ctx.from.player.where(p => p.score.gt(1000));
});
```
Views can only use index lookups — `.iter()` is NOT allowed.
---
## Scheduled Tables
```typescript
export const CleanupJob = table({
name: 'cleanup_job',
scheduled: () => run_cleanup // function returning the exported reducer
}, {
scheduledId: t.u64().primaryKey().autoInc(),
scheduledAt: t.scheduleAt(),
targetId: t.u64(),
});
export const run_cleanup = spacetimedb.reducer(
{ arg: CleanupJob.rowType },
(ctx, { arg }) => { /* arg.scheduledId, arg.targetId available */ }
);
// Schedule a job
import { ScheduleAt } from 'spacetimedb';
ctx.db.cleanupJob.insert({
scheduledId: 0n,
scheduledAt: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + 60_000_000n),
targetId: someId
});
```
### ScheduleAt on Client
```typescript
// ScheduleAt is a tagged union on the client
// { tag: 'Time', value: Timestamp } or { tag: 'Interval', value: TimeDuration }
const schedule = row.scheduledAt;
if (schedule.tag === 'Time') {
const date = new Date(Number(schedule.value.microsSinceUnixEpoch / 1000n));
}
```
---
## Timestamps
### Server-side
```typescript
ctx.db.item.insert({ id: 0n, createdAt: ctx.timestamp });
const future = ctx.timestamp.microsSinceUnixEpoch + 300_000_000n;
```
### Client-side
```typescript
// Timestamps are objects with BigInt, not numbers
const date = new Date(Number(row.createdAt.microsSinceUnixEpoch / 1000n));
```
---
## Procedures (Beta)
```typescript
export const fetch_data = spacetimedb.procedure(
{ url: t.string() }, t.string(),
(ctx, { url }) => {
const response = ctx.http.fetch(url);
ctx.withTx(tx => { tx.db.myTable.insert({ id: 0n, content: response.text() }); });
return response.text();
}
);
```
Procedures don't have `ctx.db` — use `ctx.withTx(tx => tx.db...)`.
---
## React Integration
```tsx
import { useMemo } from 'react';
import { SpacetimeDBProvider, useTable } from 'spacetimedb/react';
import { DbConnection, tables } from './module_bindings';
function Root() {
const connectionBuilder = useMemo(() =>
DbConnection.builder()
.withUri('ws://localhost:3000')
.withDatabaseName('my_game')
.withToken(localStorage.getItem('auth_token') || undefined)
.onConnect((conn, identity, token) => {
localStorage.setItem('auth_token', token);
conn.subscriptionBuilder().subscribe(tables.player);
}),
[]
);
return (
<SpacetimeDBProvider connectionBuilder={connectionBuilder}>
<App />
</SpacetimeDBProvider>
);
}
function PlayerList() {
const [players, isReady] = useTable(tables.player);
if (!isReady) return <div>Loading...</div>;
return <ul>{players.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
```
---
## Project Structure
### Server (`backend/spacetimedb/`)
```
src/schema.ts -> Tables, export spacetimedb
src/index.ts -> Reducers, lifecycle, import schema
package.json -> { "type": "module", "dependencies": { "spacetimedb": "^2.0.0" } }
tsconfig.json -> Standard config
```
### Client (`client/`)
```
src/module_bindings/ -> Generated (spacetime generate)
src/main.tsx -> Provider, connection setup
src/App.tsx -> UI components
```
---
## Commands
```bash
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang typescript --out-dir <client>/src/module_bindings --module-path <backend-dir>
spacetime logs <module-name>
```

View File

@@ -0,0 +1,292 @@
---
name: spacetimedb-unity
description: Integrate SpacetimeDB with Unity game projects. Use when building Unity clients with MonoBehaviour lifecycle, FrameTick, and PlayerPrefs token persistence.
license: Apache-2.0
metadata:
author: clockworklabs
version: "2.0"
tested_with: "SpacetimeDB 2.0, Unity 2022.3+"
---
# SpacetimeDB Unity Integration
This skill covers Unity-specific patterns for connecting to SpacetimeDB. For server-side module development and general C# SDK usage, see the `spacetimedb-csharp` skill.
---
## HALLUCINATED APIs — DO NOT USE
```csharp
// WRONG — these do not exist in Unity SDK
SpacetimeDBClient.instance.Connect(...); // Use DbConnection.Builder()
SpacetimeDBClient.instance.Subscribe(...); // Use conn.SubscriptionBuilder()
NetworkManager.RegisterReducer(...); // SpacetimeDB is not a Unity networking plugin
// WRONG — old 1.0 patterns
.WithModuleName("my-db") // Use .WithDatabaseName() (2.0)
ScheduleAt.Time(futureTime) // Use new ScheduleAt.Time(futureTime)
```
---
## Common Mistakes
| Wrong | Right | Error |
|-------|-------|-------|
| Not calling `FrameTick()` | `conn?.FrameTick()` in `Update()` | No callbacks fire |
| Accessing `conn.Db` from background thread | Copy data in callback, use on main thread | Data races / crashes |
| Forgetting `DontDestroyOnLoad` | Add to manager `Awake()` | Connection lost on scene load |
| Connecting in `Update()` | Connect in `Start()` or on user action | Reconnects every frame |
| Not saving auth token | `PlayerPrefs.SetString(...)` in `OnConnect` | New identity every session |
| Missing generated bindings | Run `spacetime generate --lang csharp` | Compile errors |
---
## Installation
Add via Unity Package Manager using the git URL:
```
https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk.git
```
**Window > Package Manager > + > Add package from git URL**
---
## Generate Module Bindings
```bash
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path PATH_TO_MODULE
```
Place generated files in your Assets folder so Unity compiles them.
---
## SpacetimeManager Singleton
The core pattern for Unity integration. This MonoBehaviour manages the connection lifecycle.
```csharp
using UnityEngine;
using SpacetimeDB;
using SpacetimeDB.Types;
public class SpacetimeManager : MonoBehaviour
{
private const string TOKEN_KEY = "SpacetimeAuthToken";
private const string SERVER_URI = "http://localhost:3000";
private const string DATABASE_NAME = "my-game";
public static SpacetimeManager Instance { get; private set; }
public DbConnection Connection { get; private set; }
public Identity LocalIdentity { get; private set; }
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}
void Start()
{
string savedToken = PlayerPrefs.GetString(TOKEN_KEY, null);
Connection = DbConnection.Builder()
.WithUri(SERVER_URI)
.WithDatabaseName(DATABASE_NAME)
.WithToken(savedToken)
.OnConnect(OnConnected)
.OnConnectError(err => Debug.LogError($"Connection failed: {err}"))
.OnDisconnect((conn, err) => {
if (err != null) Debug.LogError($"Disconnected: {err}");
})
.Build();
}
void Update()
{
Connection?.FrameTick();
}
void OnDestroy()
{
Connection?.Disconnect();
}
private void OnConnected(DbConnection conn, Identity identity, string authToken)
{
LocalIdentity = identity;
PlayerPrefs.SetString(TOKEN_KEY, authToken);
PlayerPrefs.Save();
Debug.Log($"Connected as: {identity}");
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
}
private void OnSubscriptionApplied(SubscriptionEventContext ctx)
{
Debug.Log("Subscription applied — game state loaded");
}
}
```
---
## FrameTick — Critical
**`FrameTick()` must be called every frame in `Update()`.** The SDK queues all network messages and only processes them when you call `FrameTick()`. Without it:
- No callbacks fire (OnInsert, OnUpdate, OnDelete, reducer callbacks)
- The client appears frozen
```csharp
void Update()
{
Connection?.FrameTick();
}
```
**Thread safety**: `FrameTick()` processes messages on the calling thread (the main thread in Unity). Do NOT call it from a background thread. Do NOT access `conn.Db` from background threads.
---
## Subscribing to Tables
Subscribe in the `OnConnected` callback:
```csharp
private void OnConnected(DbConnection conn, Identity identity, string authToken)
{
// ...save token...
// Development: subscribe to all
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
// Production: subscribe to specific tables
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.Subscribe(new[] {
"SELECT * FROM player",
"SELECT * FROM game_state"
});
}
```
---
## Row Callbacks for Game State
Register callbacks to update Unity GameObjects when table data changes.
```csharp
void RegisterCallbacks()
{
Connection.Db.Player.OnInsert += (EventContext ctx, Player player) => {
SpawnPlayerObject(player);
};
Connection.Db.Player.OnDelete += (EventContext ctx, Player player) => {
DestroyPlayerObject(player.Id);
};
Connection.Db.Player.OnUpdate += (EventContext ctx, Player oldPlayer, Player newPlayer) => {
UpdatePlayerObject(newPlayer);
};
}
```
Register these in `OnSubscriptionApplied` (after initial data is loaded) or in `Start()` before connecting.
---
## Calling Reducers from UI
```csharp
public class GameUI : MonoBehaviour
{
public void OnMoveButtonClicked(Vector2 direction)
{
SpacetimeManager.Instance.Connection.Reducers.MovePlayer(direction.x, direction.y);
}
public void OnSendChat(string message)
{
SpacetimeManager.Instance.Connection.Reducers.SendMessage(message);
}
}
```
### Reducer Callbacks
```csharp
SpacetimeManager.Instance.Connection.Reducers.OnSendMessage += (ReducerEventContext ctx, string text) => {
if (ctx.Event.Status is Status.Committed)
Debug.Log($"Message sent: {text}");
else if (ctx.Event.Status is Status.Failed(var reason))
Debug.LogError($"Send failed: {reason}");
};
```
---
## Reading the Client Cache
```csharp
// Find by primary key
if (Connection.Db.Player.Id.Find(playerId) is Player player)
{
Debug.Log($"Player: {player.Name}");
}
// Iterate all
foreach (var p in Connection.Db.Player.Iter())
{
Debug.Log(p.Name);
}
// Filter by index
foreach (var p in Connection.Db.Player.Level.Filter(5))
{
Debug.Log($"Level 5: {p.Name}");
}
// Count
int total = Connection.Db.Player.Count;
```
---
## Unity-Specific Considerations
### Main Thread Only
All SpacetimeDB SDK calls (`FrameTick`, `conn.Db` access, reducer calls) must happen on the main thread. If you need to pass data to a background thread, copy it first in the callback.
### Scene Loading
Use `DontDestroyOnLoad(gameObject)` on the SpacetimeManager to prevent the connection from being destroyed during scene transitions. Without it, the connection drops every time you load a new scene.
### IL2CPP / AOT
The SpacetimeDB SDK uses code generation. If you encounter issues with IL2CPP builds:
- Ensure generated bindings are up to date
- Check that `link.xml` preserves SpacetimeDB types if you use assembly stripping
### Token Persistence
Token save/load via `PlayerPrefs` is demonstrated in the SpacetimeManager singleton above. If the token is stale or invalid, the server issues a new identity and token in the `OnConnect` callback.
---
## Commands
```bash
spacetime start
spacetime publish <module-name> --module-path <backend-dir>
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
spacetime generate --lang csharp --out-dir Assets/SpacetimeDB/module_bindings --module-path <backend-dir>
spacetime logs <module-name>
```

View File

@@ -18,6 +18,16 @@
- 禁止将功能说明描述类的文本默认写入UI界面中。
- prd文档中每个模块的描述要落地设计到可以精准编码到位不能出现需求落地漂移。
- 点击按钮弹出独立的面板的设计不要实现成在当前面板下面显示内容。
- 每个阶段任务完成后自动压缩上下文,确保后续阶段在清晰、低噪音的上下文基础上继续推进。
- 凡是涉及 SpacetimeDB 的设计、实现、脚本、调试、前端绑定接入,统一显式使用以下 skill 作为执行依据:
- [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
- [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
- [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
- [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
- 涉及 `spacetime` CLI、发布、绑定生成、本地联调时`spacetimedb-cli` 执行。
- 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust``spacetimedb-concepts` 执行。
- 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时`spacetimedb-typescript``spacetimedb-concepts` 执行。
- 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
## 文档图谱

View File

@@ -45,73 +45,75 @@
交付物:[../server-rs/README.md](../server-rs/README.md)
- [x] 创建 workspace `Cargo.toml`
交付物:[../server-rs/Cargo.toml](../server-rs/Cargo.toml)
- [x] 创建 `apps/api-server`
交付物:[../server-rs/apps/api-server/README.md](../server-rs/apps/api-server/README.md)
- [x] 创建 `apps/spacetime-module`
交付物:[../server-rs/apps/spacetime-module/README.md](../server-rs/apps/spacetime-module/README.md)
- [x] 创建 `packages/module-auth`
交付物:[../server-rs/packages/module-auth/README.md](../server-rs/packages/module-auth/README.md)
- [x] 创建 `packages/module-runtime`
交付物:[../server-rs/packages/module-runtime/README.md](../server-rs/packages/module-runtime/README.md)
- [x] 创建 `packages/module-story`
交付物:[../server-rs/packages/module-story/README.md](../server-rs/packages/module-story/README.md)
- [x] 创建 `packages/module-combat`
交付物:[../server-rs/packages/module-combat/README.md](../server-rs/packages/module-combat/README.md)
- [x] 创建 `packages/module-inventory`
交付物:[../server-rs/packages/module-inventory/README.md](../server-rs/packages/module-inventory/README.md)
- [x] 创建 `packages/module-npc`
交付物:[../server-rs/packages/module-npc/README.md](../server-rs/packages/module-npc/README.md)
- [x] 创建 `packages/module-progression`
交付物:[../server-rs/packages/module-progression/README.md](../server-rs/packages/module-progression/README.md)
- [x] 创建 `packages/module-quest`
交付物:[../server-rs/packages/module-quest/README.md](../server-rs/packages/module-quest/README.md)
- [x] 创建 `packages/module-runtime-item`
交付物:[../server-rs/packages/module-runtime-item/README.md](../server-rs/packages/module-runtime-item/README.md)
- [x] 创建 `packages/module-custom-world`
交付物:[../server-rs/packages/module-custom-world/README.md](../server-rs/packages/module-custom-world/README.md)
- [x] 创建 `packages/module-assets`
交付物:[../server-rs/packages/module-assets/README.md](../server-rs/packages/module-assets/README.md)
- [x] 创建 `packages/module-ai`
交付物:[../server-rs/packages/module-ai/README.md](../server-rs/packages/module-ai/README.md)
- [x] 创建 `packages/shared-contracts`
交付物:[../server-rs/packages/shared-contracts/README.md](../server-rs/packages/shared-contracts/README.md)
- [x] 创建 `packages/shared-kernel`
交付物:[../server-rs/packages/shared-kernel/README.md](../server-rs/packages/shared-kernel/README.md)
- [x] 创建 `packages/platform-auth`
交付物:[../server-rs/packages/platform-auth/README.md](../server-rs/packages/platform-auth/README.md)
- [x] 创建 `packages/platform-oss`
交付物:[../server-rs/packages/platform-oss/README.md](../server-rs/packages/platform-oss/README.md)
- [x] 创建 `packages/platform-llm`
交付物:[../server-rs/packages/platform-llm/README.md](../server-rs/packages/platform-llm/README.md)
- [x] 创建 `packages/spacetime-client`
交付物:[../server-rs/packages/spacetime-client/README.md](../server-rs/packages/spacetime-client/README.md)
- [x] 创建 `packages/tests-support`
交付物:[../server-rs/packages/tests-support/README.md](../server-rs/packages/tests-support/README.md)
- [x] 创建 `crates/api-server`
交付物:[../server-rs/crates/api-server/README.md](../server-rs/crates/api-server/README.md)
- [x] 创建 `crates/spacetime-module`
交付物:[../server-rs/crates/spacetime-module/README.md](../server-rs/crates/spacetime-module/README.md)
- [x] 创建 `crates/module-auth`
交付物:[../server-rs/crates/module-auth/README.md](../server-rs/crates/module-auth/README.md)
- [x] 创建 `crates/module-runtime`
交付物:[../server-rs/crates/module-runtime/README.md](../server-rs/crates/module-runtime/README.md)
- [x] 创建 `crates/module-story`
交付物:[../server-rs/crates/module-story/README.md](../server-rs/crates/module-story/README.md)
- [x] 创建 `crates/module-combat`
交付物:[../server-rs/crates/module-combat/README.md](../server-rs/crates/module-combat/README.md)
- [x] 创建 `crates/module-inventory`
交付物:[../server-rs/crates/module-inventory/README.md](../server-rs/crates/module-inventory/README.md)
- [x] 创建 `crates/module-npc`
交付物:[../server-rs/crates/module-npc/README.md](../server-rs/crates/module-npc/README.md)
- [x] 创建 `crates/module-progression`
交付物:[../server-rs/crates/module-progression/README.md](../server-rs/crates/module-progression/README.md)
- [x] 创建 `crates/module-quest`
交付物:[../server-rs/crates/module-quest/README.md](../server-rs/crates/module-quest/README.md)
- [x] 创建 `crates/module-runtime-item`
交付物:[../server-rs/crates/module-runtime-item/README.md](../server-rs/crates/module-runtime-item/README.md)
- [x] 创建 `crates/module-custom-world`
交付物:[../server-rs/crates/module-custom-world/README.md](../server-rs/crates/module-custom-world/README.md)
- [x] 创建 `crates/module-assets`
交付物:[../server-rs/crates/module-assets/README.md](../server-rs/crates/module-assets/README.md)
- [x] 创建 `crates/module-ai`
交付物:[../server-rs/crates/module-ai/README.md](../server-rs/crates/module-ai/README.md)
- [x] 创建 `crates/shared-contracts`
交付物:[../server-rs/crates/shared-contracts/README.md](../server-rs/crates/shared-contracts/README.md)
- [x] 创建 `crates/shared-kernel`
交付物:[../server-rs/crates/shared-kernel/README.md](../server-rs/crates/shared-kernel/README.md)
- [x] 创建 `crates/shared-logging`
交付物:[../server-rs/crates/shared-logging/README.md](../server-rs/crates/shared-logging/README.md)
- [x] 创建 `crates/platform-auth`
交付物:[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md)
- [x] 创建 `crates/platform-oss`
交付物:[../server-rs/crates/platform-oss/README.md](../server-rs/crates/platform-oss/README.md)
- [x] 创建 `crates/platform-llm`
交付物:[../server-rs/crates/platform-llm/README.md](../server-rs/crates/platform-llm/README.md)
- [x] 创建 `crates/spacetime-client`
交付物:[../server-rs/crates/spacetime-client/README.md](../server-rs/crates/spacetime-client/README.md)
- [x] 创建 `crates/tests-support`
交付物:[../server-rs/crates/tests-support/README.md](../server-rs/crates/tests-support/README.md)
### Axum 基础能力
- [x] 搭建 `main.rs` / `Router` / `with_state`
交付物:[../server-rs/apps/api-server/src/main.rs](../server-rs/apps/api-server/src/main.rs)
交付物:[../server-rs/crates/api-server/src/main.rs](../server-rs/crates/api-server/src/main.rs)
- [x] 接入统一配置加载
交付物:[../server-rs/apps/api-server/src/config.rs](../server-rs/apps/api-server/src/config.rs)
交付物:[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)
- [x] 接入统一日志与 tracing
交付物:[../server-rs/apps/api-server/src/logging.rs](../server-rs/apps/api-server/src/logging.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs)、[../server-rs/apps/api-server/src/main.rs](../server-rs/apps/api-server/src/main.rs)
交付物:[../docs/technical/RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](../docs/technical/RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md)、[../server-rs/crates/shared-logging/src/lib.rs](../server-rs/crates/shared-logging/src/lib.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)、[../server-rs/crates/api-server/src/main.rs](../server-rs/crates/api-server/src/main.rs)
- [x] 接入 `request_id` 中间件
交付物:[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs)
交付物:[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [x] 接入统一错误处理中间件
交付物:[../server-rs/apps/api-server/src/http_error.rs](../server-rs/apps/api-server/src/http_error.rs)、[../server-rs/apps/api-server/src/error_middleware.rs](../server-rs/apps/api-server/src/error_middleware.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs)
交付物:[../server-rs/crates/api-server/src/http_error.rs](../server-rs/crates/api-server/src/http_error.rs)、[../server-rs/crates/api-server/src/error_middleware.rs](../server-rs/crates/api-server/src/error_middleware.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [x] 接入当前项目兼容的 response envelope
交付物:[../server-rs/apps/api-server/src/api_response.rs](../server-rs/apps/api-server/src/api_response.rs)、[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs)、[../server-rs/apps/api-server/src/http_error.rs](../server-rs/apps/api-server/src/http_error.rs)
交付物:[../server-rs/crates/api-server/src/api_response.rs](../server-rs/crates/api-server/src/api_response.rs)、[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)、[../server-rs/crates/api-server/src/http_error.rs](../server-rs/crates/api-server/src/http_error.rs)
- [x] 接入 `x-request-id`
交付物:[../server-rs/apps/api-server/src/response_headers.rs](../server-rs/apps/api-server/src/response_headers.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs)
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [x] 接入 `x-api-version`
交付物:[../server-rs/apps/api-server/src/response_headers.rs](../server-rs/apps/api-server/src/response_headers.rs)
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)
- [x] 接入 `x-route-version`
交付物:[../server-rs/apps/api-server/src/response_headers.rs](../server-rs/apps/api-server/src/response_headers.rs)
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)
- [x] 接入 `x-response-time-ms`
交付物:[../server-rs/apps/api-server/src/response_headers.rs](../server-rs/apps/api-server/src/response_headers.rs)、[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs)
交付物:[../server-rs/crates/api-server/src/response_headers.rs](../server-rs/crates/api-server/src/response_headers.rs)、[../server-rs/crates/api-server/src/request_context.rs](../server-rs/crates/api-server/src/request_context.rs)
- [x] 实现 `/healthz`
交付物:[../server-rs/apps/api-server/src/health.rs](../server-rs/apps/api-server/src/health.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs)
交付物:[../server-rs/crates/api-server/src/health.rs](../server-rs/crates/api-server/src/health.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
### 基础工程脚本
@@ -144,17 +146,23 @@
交付物:[../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
- [x] 设计 `auth_identity`
交付物:[../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
- [ ] 设计 `refresh_session`
- [ ] 设计 `auth_audit_log`
- [ ] 设计 `auth_risk_block`
- [ ] 设计 `sms_auth_event`
- [ ] 设计 `wechat_auth_state`
- [x] 设计 `refresh_session`
交付物:[../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)
- [x] 设计 `auth_audit_log`
交付物:[../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md)
- [x] 设计 `auth_risk_block`
交付物:[../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)
- [x] 设计 `sms_auth_event`
交付物:[../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
- [x] 设计 `wechat_auth_state`
交付物:[../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
### Axum 鉴权服务
- [ ] 实现密码登录
- [ ] 实现账号自动创建 / 幂等登录兼容策略
- [ ] 实现 Bearer JWT 校验
- [x] 实现 Bearer JWT 校验
交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [ ] 实现 refresh cookie 读取
- [ ] 实现 refresh token 轮换
- [ ] 实现会话吊销
@@ -184,9 +192,12 @@
### OIDC 与 SpacetimeDB 身份透传
- [ ] 设计 JWT claims
- [ ] 确认 `iss/sub/sid/provider/roles` 字段
- [ ] 让 Axum 自身可校验 JWT
- [x] 设计 JWT claims
交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
- [x] 确认 `iss/sub/sid/provider/roles` 字段
交付物:[../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
- [x] 让 Axum 自身可校验 JWT
交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/README.md](../server-rs/crates/platform-auth/README.md)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)
- [ ] 让 SpacetimeDB 可识别 Axum 签发的身份令牌
- [ ] 验证 reducer / view 可读取用户身份上下文

View File

@@ -16,7 +16,7 @@
这里的“迁移归属”不是简单把旧目录名照搬到 Rust而是要求后续重写必须先明确
1. 每个旧模块在新架构中的主归属 package。
1. 每个旧模块在新架构中的主归属 crate。
2. 每个旧模块是否需要拆成“SpacetimeDB 状态层 + Axum/application 编排层”。
3. 每个旧模块的状态真相应该进入 `SpacetimeDB``OSS` 还是开发态本地文件适配。
4. 每个旧模块优先落在哪个迁移阶段,避免后续任务拆分时反复改口径。
@@ -24,7 +24,7 @@
命名补充说明:
1. 本文中仍出现的 `application::...``auth-service``oss-service``llm-service` 等名称,统一表示逻辑职责,不再要求它们必须继续作为顶层独立 crate 存在。
2. 在新的多 package 结构下,这些逻辑职责默认落到对应 `packages/module-*` 的内部子层次,或落到 `packages/platform-*``packages/shared-*` 等共享 package 中。
2. 在新的多 crate 结构下,这些逻辑职责默认落到对应 `crates/module-*` 的内部子层次,或落到 `crates/platform-*``crates/shared-*` 等共享 crate 中。
补充边界:
@@ -162,7 +162,7 @@
重写后的冻结要求:
1. 该模块在 `server-node/` 中的存在事实继续保留,用于历史基线与后续清理对照。
2.`2026-04-21` 起,不再为 `server-rs/` 创建 `module-editor` package也不再把它纳入 `M1 ~ M6` 主线迁移。
2.`2026-04-21` 起,不再为 `server-rs/` 创建 `module-editor` crate也不再把它纳入 `M1 ~ M6` 主线迁移。
3. 若未来仍需清理或替代 editor必须在遗留链路依赖确认后单独立项不能夹带进当前 Rust 重写主链。
4. 不允许为了简化本轮任务而篡改其历史存在事实。
@@ -281,11 +281,11 @@
- 重写后次归属
- 目标迁移阶段
3. 本轮 active rewrite modules 为 `11` 个,且 `editor` 的遗留/不迁移口径已经冻结。
4. 后续拆 `server-rs/`package、建 SpacetimeDB bounded context、排 M3~M6 任务时,可以直接引用本文,不再靠口头记忆。
4. 后续拆 `server-rs/`crate、建 SpacetimeDB bounded context、排 M3~M6 任务时,可以直接引用本文,不再靠口头记忆。
## 8. 后续直接依赖这份基线的任务
1. 设计 `server-rs/` workspace 与 package 边界
1. 设计 `server-rs/` workspace 与 crate 边界
2. 设计 SpacetimeDB `runtime / gameplay / custom_world / asset_metadata` 表分层
3. 设计 story action reducer 的跨模块协作边界
4. 设计 custom world / assets 的 Axum facade

View File

@@ -39,7 +39,7 @@
| 阶段 | 入口条件 | 核心交付 | 退出条件 | 回归焦点 |
| --- | --- | --- | --- | --- |
| `M0` 冻结能力与边界 | 已完成当前 Node 后端摸底;已明确目标架构为 `SpacetimeDB + Axum + 阿里云 OSS` | 冻结能力基线、路由矩阵、模块归属、SSE、静态资源前缀、前端响应契约、仓库边界决议、阶段验收矩阵 | `6` 个挂载面、`96` 条路由、`12` 个模块、`6` 条 SSE、`6` 个静态资源前缀全部形成书面基线;`server-rs/``server-node/`、Axum 边界、副作用收口原则全部冻结 | 文档口径一致性;前端 contract 依赖项是否被遗漏;迁移阶段是否还存在多套边界说法 |
| `M1` Rust 工作区与 Axum 基础设施 | `M0` 全部退出条件满足 | `server-rs/` workspace、`apps/api-server``apps/spacetime-module`、独立模块 packages、统一配置、日志、request id、中间件、response envelope、`/healthz`、开发脚本 | Axum 可独立启动;`/healthz` 与当前工程兼容;`x-request-id``x-api-version``x-route-version``x-response-time-ms` 行为稳定workspace 完整编译通过;主工程与模块 package 引用边界稳定 | 基础头部兼容;健康检查兼容;目录结构与 package 归属是否偏离 `M0` 决议 |
| `M1` Rust 工作区与 Axum 基础设施 | `M0` 全部退出条件满足 | `server-rs/` workspace、`crates/api-server``crates/spacetime-module`、独立模块 crates、统一配置、日志、request id、中间件、response envelope、`/healthz`、开发脚本 | Axum 可独立启动;`/healthz` 与当前工程兼容;`x-request-id``x-api-version``x-route-version``x-response-time-ms` 行为稳定workspace 完整编译通过;主工程与模块 crate 引用边界稳定 | 基础头部兼容;健康检查兼容;目录结构与 crate 归属是否偏离 `M0` 决议 |
| `M2` 鉴权、会话、JWT 与 refresh cookie | `M1` 已可稳定启动Axum 中间件与配置链可用 | 身份表、会话表、JWT claims、refresh cookie、密码登录、手机验证码登录、微信登录、OIDC 透传、旧鉴权接口兼容 | 密码登录、refresh cookie、手机验证码、微信登录主链可用旧鉴权接口 contract 回归通过SpacetimeDB 可识别 Axum 签发身份 | Cookie 与 JWT 兼容;`CAPTCHA_REQUIRED``details.captchaChallenge` 是否保持;登录态吊销与刷新是否稳定 |
| `M3` runtime snapshot / settings / profile | `M2` 鉴权稳定;用户身份可透传到 SpacetimeDB | `runtime_snapshot``runtime_setting`、profile 相关主表与 facade存档、设置、浏览历史、save archive 兼容接口 | 登录用户可正常保存、读取、删除存档profile dashboard / browse history / save archive 行为一致;前端恢复流程可直接跑通 | 快照恢复准确性;兼容路径与主路径是否返回一致;历史记录排序与去重逻辑 |
| `M4` story action 与 gameplay reducer | `M3` 快照与用户状态主链稳定 | story / combat / inventory / npc / quest / progression / runtime-item 表与 reducerstory 兼容接口与 view model | 前端 story 主循环可用;`story state` 恢复链可用NPC / quest / treasure / combat 主循环行为不回退;旧 Node story route 回归平移完成 | `RuntimeStoryActionResponse` 结构;战斗与奖励联动;状态投影是否与旧前端恢复逻辑一致 |
@@ -71,7 +71,7 @@
- 迁移期保留 `server-node/`
- 前端 `M0 ~ M6` 期间只访问 Axum
- 外部副作用统一收口在 Axum
- `server-rs/` 内部采用 `apps/* + packages/*`package 组织
- `server-rs/` 内部采用 `crates/*`crate 组织
- `editor` 已于 `2026-04-21` 退出本轮 Rust 重写范围
4. `M1` 以后任何任务引用路由、模块、SSE、静态资源与响应契约时都必须能追溯到本阶段产出的冻结文档。

View File

@@ -22,7 +22,7 @@
| 旧 `server-node/` 在迁移期继续保留,不提前删除 | 已确认 | `server-node/``M0 ~ M6` 期间持续保留,直到 `M7` 切流与回退验证完成后再评估清理。 |
| 前端第一阶段仍然只访问 Axum不直连 SpacetimeDB | 已确认 | `M0 ~ M6` 前端统一只访问 Axum 暴露的 `/api/*``/healthz`、SSE 与静态资源兼容层,不新增直连 SpacetimeDB 原生协议路径。 |
| 外部副作用统一收口在 Axum不放进 SpacetimeDB 模块 | 已确认 | OSS、LLM、短信、微信 OAuth、本地文件系统等外部副作用统一落在 Axum/application/infra不进入 SpacetimeDB reducer/module。 |
| `server-rs/` 内部采用多 package 组织,由主工程统一引用模块包 | 已确认 | `server-rs/` 采用 `apps/* + packages/*` 工作区结构,`apps/api-server``apps/spacetime-module` 作为主工程,独立模块以 `packages/module-*` 形式被主工程引用。 |
| `server-rs/` 内部采用多 crate 组织,由主工程 crate 统一引用模块 crate | 已确认 | `server-rs/` 采用 `crates/*` 工作区结构,`crates/api-server``crates/spacetime-module` 作为主工程 crate,独立模块以 `crates/module-*` 形式被主工程 crate 引用。 |
| `editor` 为遗留无用模块,不纳入 `server-rs` 本轮重写范围 | 已确认 | `server-node/src/modules/editor``/api/editor/*` 仅作为历史基线保留对照;自 `2026-04-21` 起退出本轮 Rust 后端重写范围。 |
## 3. 已确认决议一:`server-rs/` 固定落在仓库根目录
@@ -61,19 +61,19 @@ Genarrative/
### 3.3 这样落位的原因
1. 与当前重写设计文档、任务清单、后续 `M1`package 规划保持一致。
1. 与当前重写设计文档、任务清单、后续 `M1`crate 规划保持一致。
2. 允许 `server-node/``server-rs/` 在迁移期并行存在,便于逐阶段切流。
3. 让 Rust 工作区边界清晰,不污染现有前端 `src/``packages/`、Vite 工具链。
4. 后续新增 `server-rs/scripts/*``Cargo.toml``apps/*``packages/*` 时路径最直接,不需要额外中间层。
4. 后续新增 `server-rs/scripts/*``Cargo.toml``crates/*` 时路径最直接,不需要额外中间层。
### 3.4 对后续任务的直接约束
从这一条决议开始,后续任务必须统一按以下路径落位:
1. `M1` 的工作区初始化在 `server-rs/`
2. Axum 主工程在 `server-rs/apps/api-server`
3. SpacetimeDB 主工程在 `server-rs/apps/spacetime-module`
4. 独立模块`server-rs/packages/module-*`
2. Axum 主工程 crate `server-rs/crates/api-server`
3. SpacetimeDB 主工程 crate `server-rs/crates/spacetime-module`
4. 独立模块 crate `server-rs/crates/module-*`
5. 相关脚本在 `server-rs/scripts/`
## 4. 本条任务完成定义
@@ -197,63 +197,62 @@ Genarrative/
从这一条决议开始,后续任务必须遵守:
1. `M1` package 设计时,`platform-oss``platform-llm``platform-auth` 固定属于 Axum / 模块应用层一侧。
1. `M1` crate 设计时,`platform-oss``platform-llm``platform-auth` 固定属于 Axum / 模块应用层一侧。
2. `M2 ~ M6` 设计 reducer 时,只写状态变更,不直接发外部请求。
3. 若确实需要异步副作用,也必须由 Axum worker 或应用层作业执行,再把结果回写 SpacetimeDB。
## 8. 已确认决议五:`server-rs/` 内部采用多 package 组织
## 8. 已确认决议五:`server-rs/` 内部采用多 crate 组织
### 8.1 决议内容
从当前版本开始,`server-rs/` 内部结构固定采用:
1. `apps/*`主工程 package
2. `packages/*`:独立模块 package
3. `scripts/*`:开发、发布、回归脚本
1. `crates/*`统一收口主工程 crate、独立模块 crate 与共享 crate
2. `scripts/*`:开发、发布、回归脚本
主工程固定包含:
主工程 crate 固定包含:
1. `apps/api-server`
2. `apps/spacetime-module`
1. `crates/api-server`
2. `crates/spacetime-module`
独立模块 package 固定按“每个独立模块一个 package”推进至少覆盖
独立模块 crate 固定按“每个独立模块一个 crate”推进至少覆盖
1. `packages/module-auth`
2. `packages/module-runtime`
3. `packages/module-story`
4. `packages/module-combat`
5. `packages/module-inventory`
6. `packages/module-npc`
7. `packages/module-progression`
8. `packages/module-quest`
9. `packages/module-runtime-item`
10. `packages/module-custom-world`
11. `packages/module-assets`
12. `packages/module-ai`
1. `crates/module-auth`
2. `crates/module-runtime`
3. `crates/module-story`
4. `crates/module-combat`
5. `crates/module-inventory`
6. `crates/module-npc`
7. `crates/module-progression`
8. `crates/module-quest`
9. `crates/module-runtime-item`
10. `crates/module-custom-world`
11. `crates/module-assets`
12. `crates/module-ai`
跨模块共享 package 固定包含:
跨模块共享 crate 固定包含:
1. `packages/shared-contracts`
2. `packages/shared-kernel`
3. `packages/platform-auth`
4. `packages/platform-oss`
5. `packages/platform-llm`
6. `packages/spacetime-client`
7. `packages/tests-support`
1. `crates/shared-contracts`
2. `crates/shared-kernel`
3. `crates/platform-auth`
4. `crates/platform-oss`
5. `crates/platform-llm`
6. `crates/spacetime-client`
7. `crates/tests-support`
### 8.2 这样决议的原因
1. 用户已经明确要求后端采用多 package 模式,独立模块不能继续堆回单个技术层大包。
2. 当前后端已有 `12` 个内部模块边界,多 package 方案更容易保持一一映射与独立演进。
3. `apps/api-server``apps/spacetime-module` 只做组合与发布,更符合“主工程引用模块包”的组织方式。
1. 用户已经明确要求后端采用 Rust workspace 下的多 crate 模式,独立模块不能继续堆回单个技术层大包。
2. 当前后端已有 `12` 个内部模块边界,多 crate 方案更容易保持一一映射与独立演进。
3. `crates/api-server``crates/spacetime-module` 只做组合与发布,更符合“主工程 crate 引用模块 crate”的组织方式。
### 8.3 对后续任务的直接约束
从这一条决议开始,后续任务必须遵守:
1. `M1` 后续目录创建任务统一按 `apps/* + packages/*` 执行,不再新增 `crates/*` 目录规划。
2. 每个业务模块默认先有自己的 workspace package再由主工程引用。
3. 只有共享 contract、共享领域内核、平台适配、SpacetimeDB client 这类跨模块能力,才允许使用共享 package而不是业务模块混装。
1. `M1` 后续目录任务统一按 `crates/*` 执行,不再保留 `apps/*``packages/*` 并行规划。
2. 每个业务模块默认先有自己的 workspace crate再由主工程 crate 引用。
3. 只有共享 contract、共享领域内核、平台适配、SpacetimeDB client 这类跨模块能力,才允许使用共享 crate而不是业务模块混装。
## 9. 已确认决议六:`editor` 退出本轮 Rust 重写范围
@@ -264,19 +263,19 @@ Genarrative/
当前固定口径为:
1. 历史基线继续保留 `server-node/src/modules/editor``/api/editor/*` 的存在事实。
2. `server-rs/` 不再保留 `packages/module-editor`
3. `M1 ~ M6` 的主线任务、阶段验收与 package 规划,不再把 `editor` 计入 active rewrite scope。
2. `server-rs/` 不再保留 `crates/module-editor`
3. `M1 ~ M6` 的主线任务、阶段验收与 crate 规划,不再把 `editor` 计入 active rewrite scope。
### 9.2 这样决议的原因
1. 用户已明确确认 `editor` 为遗留无用模块,应从本轮重写目标中剔除。
2. 保留历史事实有助于后续对照清理,不会把“旧系统曾存在该模块”的信息抹掉。
3. 从当前阶段开始继续为 `editor` 预留 Rust package只会增加主线迁移噪音与工程负担。
3. 从当前阶段开始继续为 `editor` 预留 Rust crate只会增加主线迁移噪音与工程负担。
### 9.3 对后续任务的直接约束
从这一条决议开始,后续任务必须遵守:
1. 不再为 `editor` 创建或维护 `server-rs` 下的新 package、Axum 路由树与迁移验收项。
1. 不再为 `editor` 创建或维护 `server-rs` 下的新 crate、Axum 路由树与迁移验收项。
2. 所有涉及挂载面、模块、路由总量的文档,都要区分“历史基线”与“本轮 active rewrite target”。
3. 若未来仍要清理 `editor`,应在 `server-node/` 遗留链路依赖核对完成后单独立项。

View File

@@ -16,7 +16,7 @@
- [07_CROSS_CUTTING_AND_ACCEPTANCE.md](./07_CROSS_CUTTING_AND_ACCEPTANCE.md):横向专项、执行顺序与最终验收清单。
- [M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md](./M0_CAPABILITY_SURFACE_BASELINE_2026-04-20.md):当前 Node 后端 `6` 个挂载面的冻结基线,用于后续接口映射、模块迁移与验收对照。
- [M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md](./M0_ROUTE_MIGRATION_MATRIX_2026-04-20.md):当前 `96` 条后端路由的“旧接口 -> 新实现”迁移矩阵,用于 Axum 路由树和 application service 落位。
- [M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md):当前 `12` 个内部模块的迁移归属基线,用于锁定 Rust package、SpacetimeDB bounded context 与 Axum/application 分工。
- [M0_MODULE_MIGRATION_BASELINE_2026-04-20.md](./M0_MODULE_MIGRATION_BASELINE_2026-04-20.md):当前 `12` 个内部模块的迁移归属基线,用于锁定 Rust crate、SpacetimeDB bounded context 与 Axum/application 分工。
- [M0_SSE_INTERFACE_BASELINE_2026-04-20.md](./M0_SSE_INTERFACE_BASELINE_2026-04-20.md):当前 `6` 条 SSE 接口及其事件格式冻结基线,用于 Axum SSE 兼容和前端 contract 回归。
- [M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md](./M0_GENERATED_STATIC_PREFIX_BASELINE_2026-04-20.md):当前正式 `/generated-*` 静态资源前缀冻结基线,用于 Axum 静态资源兼容层与 OSS 对象键规划。
- [M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md](./M0_FRONTEND_RESPONSE_CONTRACT_BASELINE_2026-04-20.md)当前前端直接依赖的响应头、envelope 与错误格式冻结基线,用于 Axum 中间件与错误响应兼容。

View File

@@ -0,0 +1,399 @@
# OIDC JWT Claims 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于完成 `M2` 中的两条任务:
1. `设计 JWT claims`
2. `确认 iss/sub/sid/provider/roles 字段`
目标是把当前 Node 后端只包含 `sub + ver` 的轻量 JWT升级为一份既兼容 Axum Bearer 鉴权、又可用于 `SpacetimeDB` 身份透传的 OIDC 风格 claims 设计,并固定:
1. 必须出现的标准字段与扩展字段
2. 哪些字段属于 access token哪些不应该塞进 JWT
3. Axum、`platform-auth``module-auth``SpacetimeDB` 各自如何使用这些 claims
4. 与当前 Node token 口径的映射方式
## 2. 当前基线
当前 Node token 实现位于:
1. `server-node/src/auth/token.ts`
2. `server-node/src/middleware/auth.ts`
3. `server-node/src/types/express.d.ts`
当前 Node access token 口径:
1. Header
- `alg = HS256`
- `typ = JWT`
2. 标准字段:
- `sub = userId`
- `iss = config.jwtIssuer`
- `iat`
- `exp`
3. 自定义字段:
- `ver = tokenVersion`
当前主要问题:
1. claims 信息过薄,不足以直接支撑 `SpacetimeDB` 侧的身份上下文判断
2. `sid/provider/roles` 等字段尚未冻结,后续 `platform-auth``module-auth` 实现没有稳定目标
3. 当前 Express `request.auth` 里也只有 `userId``tokenVersion`
## 3. 设计目标
新的 access token 必须同时满足:
1. Axum 可直接做 Bearer 校验与最小授权判断
2. `SpacetimeDB` 可识别发行者与稳定主体身份
3. 后续 `module-auth` 可基于 `sid/provider/roles` 做会话与能力判定
4. 前端无需解析大量敏感信息
5. claims 尺寸保持克制,不把会话明细或风控状态塞进 JWT
## 4. token 分层
当前阶段固定只设计两类 token
### 4.1 Access Token
用途:
1. `Authorization: Bearer <token>`
2. Axum 路由鉴权
3. `SpacetimeDB` 身份透传
特点:
1. 短期有效
2. 带完整最小 claims
3. 可在服务间透传
### 4.2 Refresh Session Cookie
用途:
1. 浏览器长期登录续期
2. 轮换 access token
特点:
1. 不走 JWT
2. 对应 `refresh_session`
3. 不通过 `SpacetimeDB` 透传
结论:
1. 只有 access token 需要本设计文档中的 claims
2. refresh cookie 不追加 JWT claims 设计复杂度
## 5. Claims 总体设计
当前阶段固定采用:
### 5.1 标准字段
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `iss` | 是 | 发行者,固定为 Axum 鉴权发行者。 |
| `sub` | 是 | 稳定用户 ID对应 `user_account.user_id`。 |
| `aud` | 否 | 当前阶段可暂不强制;若启用,固定为 `genarrative-api`。 |
| `iat` | 是 | 签发时间。 |
| `exp` | 是 | 过期时间。 |
| `nbf` | 否 | 当前阶段不强制。 |
| `jti` | 否 | 当前阶段不强制;若后续需要细粒度吊销,再单独扩展。 |
### 5.2 扩展字段
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `sid` | 是 | 会话 ID对应 `refresh_session.session_id` 或当前 access token 所属会话。 |
| `provider` | 是 | 登录来源,固定为 `password``phone``wechat`。 |
| `roles` | 是 | 角色列表;当前默认至少包含 `user`。 |
| `ver` | 是 | 用户 token 版本,对应 `user_account.token_version`。 |
| `phone_verified` | 是 | 是否已完成手机号验证。 |
| `binding_status` | 是 | 账号绑定状态,固定为 `active``pending_bind_phone`。 |
| `display_name` | 否 | 当前展示名快照,用于少量上游日志/观测;不是授权依据。 |
## 6. 关键字段定义
### 6.1 `iss`
固定要求:
1. 必填
2. 必须可稳定配置
3. 必须作为 Axum 与 `SpacetimeDB` 共同信任的发行者标识
当前阶段建议:
1. 本地开发默认:`https://auth.genarrative.local`
2. 测试/生产环境由 `GENARRATIVE_JWT_ISSUER` 或等价配置显式提供
说明:
1. 不继续沿用 Node 当前的 `genarrative-server-node` 这种非 URL 风格字符串。
2. 既然目标是 OIDC 风格 claims`iss` 应该升级为稳定 issuer 标识。
### 6.2 `sub`
固定要求:
1. 必填
2. 值为稳定用户 ID
3. 不能使用手机号、微信 openid 或用户名作为 `sub`
来源:
1. `user_account.user_id`
### 6.3 `sid`
固定要求:
1. 必填
2. 表示当前 access token 所属的会话 ID
3. 用于会话吊销、全端登出和会话列表关联
来源:
1. `refresh_session.session_id`
说明:
1. 即便当前 access token 是由 refresh cookie 刷新得到,`sid` 仍固定指向同一会话 ledger。
2. `sid` 是会话真相的索引,不是一次 access token 的唯一 ID。
### 6.4 `provider`
固定枚举:
1. `password`
2. `phone`
3. `wechat`
来源:
1. 优先来源于当前会话完成登录时的主 provider
2.`auth_identity.provider``user_account.login_provider` 保持兼容
### 6.5 `roles`
固定要求:
1. 必填
2. 类型为字符串数组
3. 当前阶段至少包含 `user`
当前阶段角色基线:
1. `user`
说明:
1. 当前阶段不预支 `admin/moderator/devops` 等角色体系
2. 但字段必须现在就冻结,避免后续 breaking change
### 6.6 `ver`
固定要求:
1. 必填
2. 表示当前用户 token 版本
3. 用于全局登录失效控制
来源:
1. `user_account.token_version`
兼容说明:
1. 继续兼容当前 Node `ver` 设计
2. Axum 校验时必须比对数据库中的最新 `token_version`
### 6.7 `phone_verified`
固定要求:
1. 必填
2. `true` 表示账号已具备已验证手机号
3. `false` 表示例如微信待绑手机号场景
来源:
1. `user_account.phone_verified_at != null`
### 6.8 `binding_status`
固定枚举:
1. `active`
2. `pending_bind_phone`
来源:
1. `user_account.account_status` 的鉴权视图
说明:
1. 它不是完整账号状态枚举,只是鉴权流程需要的最小绑定状态快照
2. 禁止把大量内部状态枚举直接透传成 JWT claim
### 6.9 `display_name`
固定规则:
1. 可选
2. 仅作为展示快照
3. 不能用于授权或数据归属判断
说明:
1. 即使写入,也必须把它视为弱一致字段
2. 后续若变更显示名,不要求立即使所有 JWT 失效
## 7. 不进入 JWT 的字段
以下内容当前阶段禁止进入 access token
1. 原始手机号
2. 手机号脱敏值
3. 微信 openid / unionid
4. refresh token hash
5. 风控状态、captcha 状态、封禁剩余时间
6. 完整用户资料对象
7. 审计日志、设备列表、IP、UA
原因:
1. 这些字段要么敏感,要么高频变动,要么不适合做 claims
2. 统一由数据库真相或接口读取承担
## 8. 推荐 payload 形态
```json
{
"iss": "https://auth.genarrative.local",
"sub": "usr_123",
"sid": "sess_456",
"provider": "wechat",
"roles": ["user"],
"ver": 3,
"phone_verified": false,
"binding_status": "pending_bind_phone",
"display_name": "微信旅人",
"iat": 1713657600,
"exp": 1713664800
}
```
说明:
1. 示例只表达字段形态,不锁死具体编码库细节
2. `iat/exp` 由签发库按标准时间戳表达
## 9. Axum / platform-auth / module-auth / SpacetimeDB 的使用边界
### 9.1 `platform-auth`
负责:
1. 签发 access token
2. 校验 `iss/exp/sub/sid/provider/roles/ver`
3. 解析 claims 为统一 Rust 结构
### 9.2 `module-auth`
负责:
1. 提供 claims 所依赖的用户、会话、绑定状态真相
2. 定义 `provider``binding_status``token_version` 的领域语义
### 9.3 `api-server`
负责:
1. 从 Bearer token 中提取 claims
2. 做路由级鉴权与用户状态校验
3. 把最小身份上下文透传给 `SpacetimeDB client`
### 9.4 `SpacetimeDB`
负责:
1. 基于受信任 issuer 识别主体身份
2. 在 reducer / view 上下文中读取稳定主体身份
当前阶段约束:
1. `SpacetimeDB` 身份判定以 `iss + sub` 为核心
2. `sid/provider/roles` 主要服务于应用层与后续模块授权,不要求第一版在 reducer 中过度使用
## 10. 与当前 Node token 的映射关系
| 当前 Node | 新 claims | 迁移规则 |
| --- | --- | --- |
| `sub = userId` | `sub` | 原样保留,但语义冻结为稳定用户 ID。 |
| `iss = jwtIssuer` | `iss` | 继续保留,但升级为 OIDC 风格 issuer 标识。 |
| `ver = tokenVersion` | `ver` | 原样保留。 |
| 无 | `sid` | 新增,绑定会话主键。 |
| 无 | `provider` | 新增,绑定本次登录 provider。 |
| 无 | `roles` | 新增,当前至少固定为 `["user"]`。 |
| 无 | `phone_verified` | 新增,表达手机号验证状态。 |
| 无 | `binding_status` | 新增,表达待绑手机/已激活状态。 |
| 无 | `display_name` | 可选新增。 |
## 11. Express / Axum 请求上下文映射
当前 Node `Express.Request.auth` 只有:
1. `userId`
2. `tokenVersion`
Rust 侧建议升级为统一 claims 结构,例如:
1. `user_id`
2. `session_id`
3. `provider`
4. `roles`
5. `token_version`
6. `phone_verified`
7. `binding_status`
说明:
1. `api-server` 不再只传 `userId`
2. 但 handler 仍应优先依赖最小必要字段,避免所有路由都耦合完整 claims
## 12. 不允许的设计漂移
后续实现时禁止出现以下情况:
1. 继续只保留 `sub + ver`,却声称已完成 `SpacetimeDB` 身份透传设计
2. 把手机号、openid、unionid 等敏感信息直接塞进 JWT
3.`sid` 设计成一次 access token 的随机 ID而不是会话 ID
4.`roles` 省略,等未来再补,导致后续 claims 结构 breaking change
5.`SpacetimeDB` 场景里信任客户端传入的 user id而不是受信任 JWT 的 `sub`
## 13. 本任务完成定义
当以下条件满足时JWT claims 设计任务视为完成:
1. `iss/sub/sid/provider/roles` 已明确冻结
2. access token 与 refresh session 的职责边界已切开
3. Axum、`platform-auth``module-auth``SpacetimeDB` 的使用边界已明确
4. 后续可以直接按这份文档实现签发、校验与身份透传
## 14. 依据文件
1. `server-node/src/auth/token.ts`
2. `server-node/src/middleware/auth.ts`
3. `server-node/src/auth/refreshSessionCookie.ts`
4. `server-node/src/config.ts`
5. `server-node/src/types/express.d.ts`
6. `packages/shared/src/contracts/auth.ts`
7. `docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md`
8. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`
9. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md`

View File

@@ -0,0 +1,217 @@
# platform-auth JWT 适配设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `platform-auth` 首个真实能力落地,目标是完成:
1. `platform-auth` crate 的 JWT claims 结构。
2. access token 的签发与校验适配。
3. `api-server` 的最小 Bearer JWT 校验入口。
这一步只解决“Axum 自身能稳定签发并校验 JWT”的基础问题不提前把 refresh cookie、短信和微信 OAuth 一起耦合进来。
## 2. 当前落地范围
本阶段只包含以下实现:
1. `JwtConfig`
2. `AccessTokenClaimsInput`
3. `AccessTokenClaims`
4. `sign_access_token(...)`
5. `verify_access_token(...)`
6. `api-server` 的 Bearer 鉴权中间件
7. `/_internal/auth/claims` 内部调试路由
本阶段明确不包含:
1. refresh cookie 的生成、解析、轮换和吊销。
2.`module-auth` / `SpacetimeDB` 真相表读取 `token_version` 并做在线比对。
3. 短信 provider 与微信 OAuth 平台适配。
4. SpacetimeDB 模块对 Axum 签发 JWT 的消费代码。
## 3. 设计输入
本实现直接受以下文档约束:
1. [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
关键冻结点:
1. `iss/sub/sid/provider/roles/ver/phone_verified/binding_status` 是当前 access token 的固定字段。
2. `sub` 是稳定 `user_id`
3. `sid` 是会话 ID不是单次 token ID。
4. `roles` 当前至少包含 `user`
5. 不允许把手机号、openid、风控状态、refresh token hash 放进 JWT。
## 4. crate 边界
### 4.1 `platform-auth`
负责:
1. 组织 JWT 结构。
2. 执行签名与验签。
3. 执行基础 claims 完整性校验。
不负责:
1. 用户是否存在。
2. `token_version` 是否仍是数据库最新值。
3. refresh session 是否已被吊销。
4. provider 之外的业务规则判断。
### 4.2 `api-server`
负责:
1.`Authorization` 头提取 Bearer token。
2. 调用 `platform-auth` 校验。
3. 把已校验 claims 写入请求上下文。
4. 在本阶段提供最小内部验收入口 `/_internal/auth/claims`
不负责:
1. 自己再实现一套 JWT 编解码逻辑。
2. 把 claims 结构拆散成多个重复 helper。
## 5. 配置口径
当前阶段 `api-server` 读取并传入 `platform-auth` 的配置如下:
| 配置项 | 环境变量 | 默认值 | 说明 |
| --- | --- | --- | --- |
| issuer | `GENARRATIVE_JWT_ISSUER` | `https://auth.genarrative.local` | OIDC 风格发行者标识。 |
| secret | `GENARRATIVE_JWT_SECRET` | `genarrative-dev-secret` | 当前阶段沿用对称签名密钥。 |
| access token TTL | `GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS` | `7200` | access token 有效期,单位秒。 |
兼容回退:
1. `issuer` 可回退读取 `JWT_ISSUER`
2. `secret` 可回退读取 `JWT_SECRET`
3. TTL 可回退读取 `JWT_EXPIRES_IN`,支持:
- 纯秒值,例如 `900`
- `s/m/h/d` 后缀,例如 `30m``2h`
## 6. 算法选择
当前阶段固定采用:
1. `alg = HS256`
理由:
1. 与当前 Node 基线兼容,迁移阻力最低。
2. 先把 claims、配置口径和 Bearer 主链稳定下来。
3. 若未来升级到非对称签名,应作为独立任务处理,而不是夹带进当前重写链路。
## 7. Rust 结构设计
### 7.1 `AccessTokenClaimsInput`
用途:
1. 作为业务层输入。
2. 不承载 `iat/exp/iss` 这种平台计算字段。
字段:
1. `user_id`
2. `session_id`
3. `provider`
4. `roles`
5. `token_version`
6. `phone_verified`
7. `binding_status`
8. `display_name`
### 7.2 `AccessTokenClaims`
用途:
1. 对应最终 JWT payload。
2. 直接用于签名与验签结果输出。
字段:
1. `iss`
2. `sub`
3. `sid`
4. `provider`
5. `roles`
6. `ver`
7. `phone_verified`
8. `binding_status`
9. `display_name`
10. `iat`
11. `exp`
## 8. 校验规则
### 8.1 签发前校验
1. `issuer``secret` 不能为空。
2. TTL 必须大于 `0`
3. `sub` 不能为空。
4. `sid` 不能为空。
5. `roles` 不能为空数组。
6. `exp` 必须晚于 `iat`
### 8.2 验签时校验
1. 算法必须是 `HS256`
2. 签名必须正确。
3. `iss` 必须匹配当前配置。
4. `exp/iat/iss/sub` 必须存在。
5. 反序列化后的 `sid/provider/roles/ver/phone_verified/binding_status` 必须完整。
当前阶段不做的校验:
1. `ver` 与数据库最新 token version 比对。
2. `sid``refresh_session` 活跃状态比对。
3. `roles` 的细粒度授权判断。
## 9. api-server 最小接线
本阶段 `api-server` 接线规则如下:
1. `AppConfig` 增加 JWT 相关配置。
2. `AppState` 在启动时构造唯一一份 `JwtConfig`
3. `require_bearer_auth` 中间件从请求头读取 Bearer token。
4. 验签成功后把 claims 以 `AuthenticatedAccessToken` 写入 request extensions。
5. 内部路由 `/_internal/auth/claims` 用于返回当前已校验 claims作为阶段验收与调试入口。
说明:
1. 这个内部路由不是最终对外 contract。
2. 它的存在是为了在 `module-auth` 与正式 `/api/auth/me` 落地前,先把 Bearer 主链单独跑通。
## 10. 测试策略
当前阶段要求至少覆盖:
1. `platform-auth` 的 JWT 签发与验签回环。
2. issuer 不匹配时拒绝。
3. 空角色拒绝。
4. `api-server` 在无 Bearer token 时返回 `401`
5. `api-server` 在合法 Bearer token 下返回 claims。
## 11. 完成定义
当以下条件满足时,本任务视为完成:
1. Rust workspace 中存在真实可编译的 `platform-auth` crate。
2. `api-server` 已能使用 `platform-auth` 校验 Bearer JWT。
3. 工作区测试与编译可通过。
4. 任务清单已同步更新。
## 12. 后续衔接
下一阶段继续衔接:
1. refresh cookie 读取与轮换。
2. `module-auth` 会话真相与 `token_version` 在线校验。
3. `/api/auth/me``/api/auth/refresh` 等正式接口。
4. SpacetimeDB 对 Axum JWT 的身份透传验证。

View File

@@ -4,6 +4,14 @@
## 文档列表
- [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。
- [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth``SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。
- [RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](./RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md)Rust 工作区统一日志模块 `shared-logging` 的职责边界、API、输出风格与 `api-server` 迁移规则。
- [SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)`M2` 第七张微信 OAuth 状态表 `wechat_auth_state` 的字段、过期/消费语义、`wechat/start``wechat/callback` 的单次消费规则,以及多实例下的清理策略。
- [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)`M2` 第六张短信鉴权统计表 `sms_auth_event` 的事件范围、统计口径、索引与和风控/审计表的协作边界。
- [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)`M2` 第五张风控状态表 `auth_risk_block` 的作用域、活跃态、刷新/解除规则与读取派生约束。
- [SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md)`M2` 第四张鉴权审计表 `auth_audit_log` 的事件范围、追加写规则、索引与对外 DTO 派生约束。
- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)`M2` 第三张会话表 `refresh_session` 的 cookie/hash 边界、轮换与吊销语义、索引与迁移规则。
- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)`M2` 第二张身份表 `auth_identity` 的 provider 范围、唯一约束、手机号/微信身份写入规则与迁移策略。
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。

View File

@@ -0,0 +1,155 @@
# Rust `shared-logging` crate 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于明确 `server-rs/` 下统一日志模块的落地方式。
目标是把当前仅存在于 `crates/api-server/src/logging.rs` 的日志初始化逻辑,上提为一个可被多 crate 复用的独立 `shared-logging` crate并固定
1. 统一日志模块的职责边界
2. `api-server`、后续 `spacetime-module`、测试支撑与脚本入口的复用方式
3. 日志过滤、subscriber 初始化、输出风格的统一口径
4. 不允许继续把日志初始化散落到各主工程 crate 中
## 2. 当前基线
当前 Rust 工作区里的日志实现现状:
1. `api-server` 内部已有 `src/logging.rs`
2. 该文件负责创建 `EnvFilter` 并初始化 `tracing subscriber`
3. `main.rs` 直接调用 `init_tracing(&config)`
4. 其它 crate 尚未复用同一套日志初始化逻辑
当前问题:
1. 日志初始化逻辑被绑定在 `api-server` 内部,无法作为工作区级共享能力复用
2. 后续 `spacetime-module`、测试支撑、独立 worker、脚本入口若需要日志会继续复制粘贴
3. 当前没有独立 crate 作为“统一日志模块”承接输出风格、默认过滤器与 subscriber 初始化边界
## 3. crate 职责边界
### 3.1 `shared-logging` 负责
1. 提供工作区统一的 `tracing subscriber` 初始化入口
2. 提供统一的日志过滤器解析逻辑
3. 固定当前阶段的输出风格,例如 `compact``with_target(true)`
4.`api-server`、后续 `spacetime-module`、测试支撑与独立入口提供可复用初始化函数
### 3.2 它不负责
1. 保存业务日志事件结构体
2. 直接耦合 `Axum` 中间件或 `TraceLayer`
3. 处理业务 request id、response headers、error body
4. 承担供应商日志上报、链路追踪采样、远端日志聚合
### 3.3 与其他 crate 的边界
1. `shared-logging` 只提供“日志基础设施初始化”
2. `api-server` 继续负责 HTTP 访问日志的 `TraceLayer` 挂载
3. `request_context`、错误中间件、响应头模块继续负责 request id 与响应元信息串联
4. 若未来需要结构化埋点或审计事件,应进入对应业务 crate不进入 `shared-logging`
## 4. 目录与命名
统一落位:
1. `server-rs/crates/shared-logging`
当前阶段建议最小文件结构:
```text
server-rs/crates/shared-logging/
├─ Cargo.toml
├─ README.md
└─ src/
└─ lib.rs
```
说明:
1. 当前阶段只做库 crate不做独立二进制入口
2. `api-server``src/logging.rs` 迁出后,直接依赖 `shared-logging`
## 5. API 设计
当前阶段建议提供以下最小 API
1. `init_tracing(log_filter: &str) -> Result<(), io::Error>`
2. `resolve_env_filter(default_filter: &str) -> EnvFilter`
设计原因:
1. `api-server` 当前只需要一个初始化入口
2. 后续如 `spacetime-module` 需要不同默认过滤器,也可以复用 `resolve_env_filter(...)`
3. 避免在第一版就过度设计成复杂 builder
## 6. 配置约定
当前阶段仍保留现有配置来源:
1. `api-server` 默认读取 `GENARRATIVE_API_LOG`
2. 若环境变量未提供,则回落到配置对象中的 `log_filter`
3. `shared-logging` 不直接读取业务配置结构,只消费最终传入的默认 filter 字符串
这样做的原因:
1. 保持 `shared-logging` 对上层业务配置解耦
2. 避免把 `AppConfig` 类型反向沉到共享基础设施 crate
## 7. 输出风格约定
当前阶段固定沿用现有输出口径:
1. `compact`
2. `with_target(true)`
3. 基于 `EnvFilter` 做过滤
说明:
1. 先保证工作区统一
2. 后续若要引入 JSON 输出、环境区分或远端采集,再在 `shared-logging` 中集中演进
## 8. 落地规则
### 8.1 `api-server`
必须调整为:
1. 删除本地 `src/logging.rs`
2.`Cargo.toml` 中引用 `shared-logging`
3. `main.rs` 改为调用 `shared_logging::init_tracing(...)`
### 8.2 其它 crate
后续若需要日志初始化,统一按以下规则:
1. 优先依赖 `shared-logging`
2. 不再在各自 crate 内重复实现一份 `init_tracing`
## 9. 不允许的设计漂移
后续实现时禁止出现以下情况:
1. 再为 `api-server``spacetime-module` 各自复制一份 subscriber 初始化逻辑
2.`Axum``TraceLayer` 直接塞进 `shared-logging`
3. 把 request id、响应头、错误 envelope 这些 HTTP 语义放进 `shared-logging`
4. 为了抽象而把业务配置类型强耦合进共享日志 crate
## 10. 本任务完成定义
当以下条件满足时,统一日志模块任务视为完成:
1. `shared-logging` crate 已创建并加入 workspace
2. `api-server` 已改为依赖 `shared-logging`
3.`api-server/src/logging.rs` 已被移除
4. 工作区文档与任务清单已同步到“统一日志模块”口径
## 11. 依据文件
1. `server-rs/crates/api-server/src/logging.rs`
2. `server-rs/crates/api-server/src/main.rs`
3. `server-rs/crates/api-server/src/config.rs`
4. `server-rs/crates/api-server/README.md`
5. `backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md`

View File

@@ -0,0 +1,339 @@
# `auth_audit_log` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于完成 `M2` 的第四条任务:`设计 auth_audit_log`
目标是把鉴权审计表固定成一张“只追加的事实表”,并明确:
1. 哪些鉴权动作必须写审计
2. 审计表与风控表、会话表、账号表的边界
3. 哪些字段应该入库,哪些字段应该在读取时派生
4. `/api/auth/audit-logs` 要依赖什么读模型
## 2. 当前基线
当前 Node 后端已经存在 `auth_audit_logs` 表,并有完整写入链路:
1. `authService.ts` 通过 `writeAuthAuditLog(...)` 统一写入
2. `GET /api/auth/audit-logs` 会按用户倒序返回最近 20 条
3. 前端拿到的是已经过展示加工的 DTO
- `title`
- `detail`
- `ipMasked`
- `userAgent`
- `createdAt`
当前 Node `auth_audit_logs` 表字段基线:
1. `id`
2. `user_id`
3. `event_type`
4. `detail`
5. `ip`
6. `user_agent`
7. `meta_json`
8. `created_at`
当前已使用的事件类型基线:
1. `password_login`
2. `phone_login`
3. `wechat_login`
4. `wechat_bind_phone`
5. `change_phone`
6. `captcha_required`
7. `logout`
8. `logout_all`
9. `revoke_session`
10. `risk_block_phone`
11. `risk_block_ip`
12. `risk_unblock_phone`
13. `risk_unblock_ip`
## 3. 表职责边界
### 3.1 `auth_audit_log` 负责
1. 记录“账号已经发生过什么安全相关动作”
2. 记录动作发生时的操作者账号
3. 记录动作发生时的 IP、UA 与必要扩展上下文
4. 作为 `/api/auth/audit-logs` 的唯一事实来源
### 3.2 它不负责
1. 风控封禁当前是否生效
2. refresh session 当前是否活跃
3. 短信验证码发送频控
4. 账号当前状态
5. 展示标题的最终本地化文案
### 3.3 与其他表的边界
1. `auth_risk_block` 负责“当前拦截状态”
2. `auth_audit_log` 负责“发生过封禁或解除动作的历史事实”
3. `refresh_session` 负责“当前设备会话”
4. `auth_audit_log` 负责“谁在什么时候移除了哪台设备”
## 4. 访问级别
`auth_audit_log` 固定为 `private table`
原因:
1. 含用户行为安全记录
2. 含原始 IP 与原始 UA
3. 含 provider 或设备相关元信息
对外读取固定通过:
1. Axum 鉴权后按当前 `user_id` 查询
2. 再由 view / service 转成脱敏 DTO
## 5. 字段设计
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `audit_log_id` | `String` | 是 | 主键,建议继续沿用 `audit_*` 前缀。 |
| `user_id` | `String` | 是 | 归属账号 ID外键指向 `user_account.user_id`。 |
| `event_type` | `String` | 是 | 事件类型枚举。 |
| `detail` | `String` | 是 | 当前事件的人类可读详情,继续保留中文。 |
| `ip` | `Option<String>` | 否 | 事件发生时采集到的原始 IP。 |
| `user_agent` | `Option<String>` | 否 | 事件发生时采集到的原始 UA。 |
| `meta_json` | `Option<String>` | 否 | 扩展上下文 JSON 字符串,例如 `sessionId``targetIp``expiresAt`。 |
| `created_at` | `String` | 是 | 事件发生时间UTC RFC3339。 |
补充约束:
1. 当前阶段不增加 `updated_at`,因为审计事件一旦写入就不允许回写。
2. 当前阶段不单独存 `title`,继续由 `event_type` 在读取时派生。
3. `detail` 继续保留中文事实描述,避免后续只剩事件码却缺少可读上下文。
## 6. 事件类型设计
当前阶段 `event_type` 固定只支持以下值:
1. `password_login`
2. `phone_login`
3. `wechat_login`
4. `wechat_bind_phone`
5. `change_phone`
6. `captcha_required`
7. `logout`
8. `logout_all`
9. `revoke_session`
10. `risk_block_phone`
11. `risk_block_ip`
12. `risk_unblock_phone`
13. `risk_unblock_ip`
当前不新增更细子类型,原因:
1. 现有前端与 Node 展示已经围绕这 13 类建立
2. M2 重点是兼容迁移,不是先重做一套新的安全事件 taxonomy
后续若要扩展,只允许追加,不重命名已有事件码。
## 7. 展示派生规则
### 7.1 `title` 不入库
`/api/auth/audit-logs` 返回的 `title` 固定按 `event_type` 派生。
当前派生规则与现有 Node 对齐:
1. `password_login` -> `账号密码登录`
2. `phone_login` -> `手机号登录`
3. `wechat_login` -> `微信登录`
4. `wechat_bind_phone` -> `绑定手机号`
5. `change_phone` -> `更换手机号`
6. `captcha_required` -> `需要图形验证码`
7. `logout` -> `退出当前设备`
8. `logout_all` -> `退出全部设备`
9. `revoke_session` -> `移除登录设备`
10. `risk_block_phone` -> `手机号临时保护`
11. `risk_block_ip` -> `网络临时保护`
12. `risk_unblock_phone` -> `解除手机号保护`
13. `risk_unblock_ip` -> `解除网络保护`
### 7.2 `ipMasked` 不入库
原始 IP 继续入库,但对外返回时必须脱敏:
1. IPv4 -> `a.b.*.*`
2. IPv6 -> 保留前两段,其余隐藏
## 8. 唯一约束与索引
### 8.1 必须具备的唯一约束
1. `audit_log_id` 主键唯一
### 8.2 必须具备的查询索引
1. `(user_id, created_at DESC)`
作用:支撑 `/api/auth/audit-logs`
2. `(user_id, event_type, created_at DESC)`
作用:后续按事件分类筛选或管理后台排查
3. `(created_at DESC)`
作用:后续归档与清理窗口扫描
## 9. 写入规则
### 9.1 登录相关
必须写入:
1. 密码登录成功 -> `password_login`
2. 手机号登录成功 -> `phone_login`
3. 微信登录成功 -> `wechat_login`
4. 微信绑定手机号成功 -> `wechat_bind_phone`
### 9.2 手机号变更相关
必须写入:
1. 更换手机号成功 -> `change_phone`
2. 图形验证码被触发或校验失败 -> `captcha_required`
说明:
1. `captcha_required` 当前不是“成功动作”,而是一个安全门槛触发事件。
2. 继续沿用现有 Node 语义,不再拆成 `captcha_challenge_issued``captcha_verify_failed` 两类。
### 9.3 会话相关
必须写入:
1. 当前设备退出 -> `logout`
2. 退出全部设备 -> `logout_all`
3. 移除指定远端设备 -> `revoke_session`
### 9.4 风控相关
必须写入:
1. 手机号被封禁 -> `risk_block_phone`
2. IP 被封禁 -> `risk_block_ip`
3. 手机号封禁被解除 -> `risk_unblock_phone`
4. IP 封禁被解除 -> `risk_unblock_ip`
## 10. `meta_json` 约定
`meta_json` 当前只放扩展上下文,不放主字段副本。
### 10.1 推荐写入内容
1. `revoke_session`
- `sessionId`
- `targetIp`
- `targetUserAgent`
2. `risk_block_phone` / `risk_block_ip`
- `scopeKey`
- `expiresAt`
3. `captcha_required`
- `scene`
- `phoneNumberMasked`(如需要)
### 10.2 禁止写入内容
1. 原始 refresh token
2. 密码明文
3. 短信验证码明文
4. 微信 access token
## 11. 读取规则
### 11.1 `/api/auth/audit-logs`
当前阶段固定返回:
1. 最近 20 条
2.`created_at DESC`
3. 只返回当前用户自己的审计记录
### 11.2 DTO 转换规则
对外 DTO `AuthAuditLogEntry` 固定为:
1. `id`
2. `eventType`
3. `title`
4. `detail`
5. `ipMasked`
6. `userAgent`
7. `createdAt`
其中:
1. `id <- audit_log_id`
2. `title <- event_type` 派生
3. `ipMasked <- ip` 脱敏后派生
## 12. 与当前 Node `auth_audit_logs` 的映射关系
| Node 列 | 新字段 | 迁移规则 |
| --- | --- | --- |
| `id` | `audit_log_id` | 原样迁移。 |
| `user_id` | `user_id` | 原样迁移。 |
| `event_type` | `event_type` | 原样迁移。 |
| `detail` | `detail` | 原样迁移。 |
| `ip` | `ip` | 原样迁移。 |
| `user_agent` | `user_agent` | 原样迁移。 |
| `meta_json` | `meta_json` | 原样迁移。 |
| `created_at` | `created_at` | 原样迁移。 |
## 13. reducer / service 落地约束
### 13.1 `module-auth` reducer 层
必须至少具备:
1. `append_auth_audit_log`
说明:
1. 审计表是典型追加型事实表,不需要复杂更新 reducer。
2. 后续若做归档清理,再单独新增 maintenance reducer 或离线清理任务。
### 13.2 Axum 应用层
固定负责:
1. 决定在哪个业务动作成功或触发门槛时写审计
2. 组织 `detail`
3. 组织 `meta_json`
4. 读取时把 `event_type` 转为 `title`
## 14. 不允许的设计漂移
后续实现时禁止出现以下情况:
1.`title` 直接入库,导致显示文案和事件真相耦合
2. 为了省事复用 `auth_audit_log` 做当前风险态判断
3. 为了省事复用 `auth_audit_log` 做短信频控统计
4. 在审计表中记录原始 refresh token、验证码或密码明文
5. 更新已有审计记录,而不是追加新事实
## 15. 本任务完成定义
当以下条件满足时,`设计 auth_audit_log` 视为完成:
1. 审计表的职责边界已和会话表、风控表切开。
2. 事件类型、字段、索引与读取 DTO 派生规则已明确。
3. `/api/auth/audit-logs` 所需的查询语义已经固定。
4. 后续可以直接据此编码 reducer、view 与 Axum 读取逻辑。
## 16. 依据文件
1. `server-node/src/auth/authService.ts`
2. `server-node/src/repositories/authAuditLogRepository.ts`
3. `server-node/src/routes/authRoutes.ts`
4. `server-node/src/db/migrations.ts`
5. `packages/shared/src/contracts/auth.ts`
6. `docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md`
7. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md`
8. `docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md`
9. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`

View File

@@ -0,0 +1,309 @@
# `auth_risk_block` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于完成 `M2` 的第五条任务:`设计 auth_risk_block`
目标是把鉴权风控保护表明确为“当前生效态真相表”,并固定:
1. 什么叫活跃封禁
2. 什么叫解除封禁
3. 它与 `auth_audit_log` 的边界
4. `/api/auth/risk-blocks``/api/auth/risk-blocks/:scopeType/lift` 的读写语义
## 2. 当前基线
当前 Node 后端已经存在 `auth_risk_blocks` 表,并具备以下能力:
1. 当手机号验证码失败次数达到阈值时,创建或刷新手机号保护
2. 当 IP 验证失败次数达到阈值时,创建或刷新网络保护
3. `GET /api/auth/risk-blocks` 返回当前用户手机号与当前请求 IP 命中的保护
4. `POST /api/auth/risk-blocks/:scopeType/lift` 支持用户主动解除当前手机号或当前网络保护
当前 Node `auth_risk_blocks` 字段基线:
1. `id`
2. `scope_type`
3. `scope_key`
4. `reason`
5. `expires_at`
6. `lifted_at`
7. `created_at`
8. `updated_at`
当前已落地的 scope 基线:
1. `phone`
2. `ip`
当前已落地的 reason 基线:
1. `sms_verify_failures`
## 3. 表职责边界
### 3.1 `auth_risk_block` 负责
1. 记录某个手机号或某个 IP 当前是否处于保护状态
2. 记录这次保护何时过期
3. 记录这次保护是否已被手动解除
4. 作为 `/api/auth/risk-blocks` 的唯一事实来源
### 3.2 它不负责
1. 记录所有历史触发次数
2. 记录所有历史解除动作
3. 生成用户可读标题和说明文案
4. 短信频控与失败次数统计
### 3.3 与其他表的边界
1. `sms_auth_event` 负责失败次数统计源数据
2. `auth_risk_block` 负责统计之后得到的“当前保护状态”
3. `auth_audit_log` 负责记录“何时触发保护 / 何时解除保护”的历史事实
## 4. 访问级别
`auth_risk_block` 固定为 `private table`
原因:
1. 原始手机号与原始 IP 都属于敏感安全数据
2. 前端只应该通过已鉴权接口读取当前命中的保护摘要
3. 不能让客户端直接订阅或查询全表
## 5. 字段设计
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `risk_block_id` | `String` | 是 | 主键,建议继续沿用 `risk_*` 前缀。 |
| `scope_type` | `String` | 是 | 保护作用域,枚举固定为 `phone``ip`。 |
| `scope_key` | `String` | 是 | 作用域主体键;`phone` 时为 `E.164` 手机号,`ip` 时为原始 IP。 |
| `reason_code` | `String` | 是 | 触发原因码,当前固定为 `sms_verify_failures`。 |
| `expires_at` | `String` | 是 | 当前保护的失效时间UTC RFC3339。 |
| `lifted_at` | `Option<String>` | 否 | 手动解除时间;为 `null` 表示未手动解除。 |
| `created_at` | `String` | 是 | 首次创建该保护记录的时间。 |
| `updated_at` | `String` | 是 | 最近一次刷新保护或解除保护的时间。 |
补充约束:
1. 当前阶段不额外存 `user_id`,因为 IP 保护天然不是账号主键作用域。
2. 当前阶段不额外存 `remaining_seconds`,读取时按 `expires_at` 动态计算。
3. 当前阶段不把标题或 detail 文案入库,继续由 Axum 读取时派生。
## 6. 作用域设计
### 6.1 `phone`
固定规则:
1. `scope_key` 必须是标准化后的 `E.164` 手机号
2. 主要用于手机号验证码登录、绑定手机号、换绑手机号
3. 查询时通过当前账号的主手机号命中
### 6.2 `ip`
固定规则:
1. `scope_key` 为请求来源 IP 原文
2. 主要用于防御同一网络环境的异常尝试
3. 查询时通过当前请求上下文中的 IP 命中
## 7. 活跃态定义
一条 `auth_risk_block` 只有同时满足以下条件,才视为活跃:
1. `lifted_at = null`
2. `expires_at > now`
说明:
1. 超时失效不需要额外更新行状态。
2. 手动解除必须显式写 `lifted_at`
## 8. 写入规则
### 8.1 创建或刷新保护
触发点:
1. 手机号验证码失败次数达到手机号阈值
2. IP 验证失败次数达到 IP 阈值
写入规则:
1. 先按 `(scope_type, scope_key)` 查当前活跃保护
2. 若已有活跃保护,则更新:
- `reason_code`
- `expires_at`
- `updated_at`
3. 若不存在活跃保护,则新建一条记录
关键约束:
1. 风控表是“当前态表”,因此允许刷新同一条活跃记录
2. 触发保护的历史事实不在这里重复保留,由 `auth_audit_log` 承担
### 8.2 手动解除保护
触发点:
1. `POST /api/auth/risk-blocks/phone/lift`
2. `POST /api/auth/risk-blocks/ip/lift`
写入规则:
1. 只更新当前活跃保护
2. 写入:
- `lifted_at = now`
- `updated_at = now`
3. 不删除行
### 8.3 自动超时
触发点:
1. 当前时间超过 `expires_at`
写入规则:
1. 不做同步更新
2. 查询活跃状态时自然排除
## 9. 唯一约束与索引
### 9.1 必须具备的唯一约束
1. `risk_block_id` 主键唯一
### 9.2 必须具备的查询索引
1. `(scope_type, scope_key, expires_at DESC)`
作用:查当前活跃保护
2. `(scope_type, scope_key, lifted_at, expires_at DESC)`
作用:提升当前活跃保护查找效率
3. `(expires_at, lifted_at)`
作用:后续定时清理或后台巡检
说明:
1. 当前阶段不强行加“同 scope 只有一条记录”的数据库唯一约束,因为历史已失效/已解除记录允许共存。
2. 活跃唯一性由“查询时只取未解除且未过期的最新一条”保证。
## 10. 对外读取规则
### 10.1 `/api/auth/risk-blocks`
固定读取:
1. 当前账号主手机号命中的活跃 `phone` 保护
2. 当前请求 IP 命中的活跃 `ip` 保护
固定返回:
1. `scopeType`
2. `title`
3. `detail`
4. `expiresAt`
5. `remainingSeconds`
其中:
1. `title` 读取时按 `scope_type` 派生
2. `detail` 读取时按 `scope_type + expires_at` 派生
3. `remainingSeconds` 读取时按 `expires_at - now` 计算
### 10.2 标题派生规则
1. `phone` -> `手机号保护中`
2. `ip` -> `当前网络保护中`
### 10.3 detail 派生规则
1. `phone` -> `该手机号因异常尝试已被临时保护,请约 N 分钟后再试`
2. `ip` -> `当前网络因异常尝试已被临时保护,请约 N 分钟后再试`
## 11. 与当前 Node `auth_risk_blocks` 的映射关系
| Node 列 | 新字段 | 迁移规则 |
| --- | --- | --- |
| `id` | `risk_block_id` | 原样迁移。 |
| `scope_type` | `scope_type` | 原样迁移。 |
| `scope_key` | `scope_key` | 原样迁移。 |
| `reason` | `reason_code` | 重命名迁移,值当前原样保留。 |
| `expires_at` | `expires_at` | 原样迁移。 |
| `lifted_at` | `lifted_at` | 原样迁移。 |
| `created_at` | `created_at` | 原样迁移。 |
| `updated_at` | `updated_at` | 原样迁移。 |
## 12. reducer / service 落地约束
### 12.1 `module-auth` reducer 层
必须至少具备:
1. `upsert_auth_risk_block`
2. `lift_auth_risk_block`
### 12.2 Axum 应用层
固定负责:
1. 根据 `sms_auth_event` 统计结果决定是否触发保护
2. 计算新的 `expires_at`
3. 读取保护时派生标题、detail 与剩余秒数
4. 解除保护成功后同步写 `auth_audit_log`
## 13. 与 `auth_audit_log` 的协作规则
### 13.1 触发保护时
必须:
1. 先写或刷新 `auth_risk_block`
2. 再写 `auth_audit_log`
### 13.2 解除保护时
必须:
1. 先写 `lifted_at`
2. 再写 `risk_unblock_phone``risk_unblock_ip` 审计
### 13.3 禁止的反向依赖
禁止:
1.`auth_audit_log` 回推当前是否仍在保护中
## 14. 不允许的设计漂移
后续实现时禁止出现以下情况:
1. 为了保存历史,把每次刷新保护都新建一条新活跃记录而不复用现有活跃记录
2. 手动解除时直接删行,导致无法保留解除痕迹
3.`remainingSeconds` 这类瞬时值入库
4. 把风控表当作审计表使用
5.`scope_key` 脱敏后再入库,导致无法稳定命中当前作用域
## 15. 本任务完成定义
当以下条件满足时,`设计 auth_risk_block` 视为完成:
1. 当前态与历史态边界已经和 `auth_audit_log` 切开。
2. scope、活跃态、刷新态、解除态的规则已明确。
3. `/api/auth/risk-blocks``/lift` 的读取和写入语义已固定。
4. 后续可以直接按这份文档编码 reducer、view 与 Axum 接口。
## 16. 依据文件
1. `server-node/src/repositories/authRiskBlockRepository.ts`
2. `server-node/src/auth/authService.ts`
3. `server-node/src/routes/authRoutes.ts`
4. `server-node/src/db/migrations.ts`
5. `packages/shared/src/contracts/auth.ts`
6. `docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md`
7. `docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md`
8. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`

View File

@@ -59,7 +59,7 @@
1. 上述 `12` 个模块是历史基线总量。
2. 本轮 active rewrite modules 固定为 `11` 个。
3. `editor` 仅保留历史事实,不进入 `server-rs` 主线 package 与阶段验收。
3. `editor` 仅保留历史事实,不进入 `server-rs` 主线 crate 与阶段验收。
## 3. 技术选型后的硬边界
@@ -200,16 +200,39 @@ SpacetimeDB 官方文档对自动迁移的限制很强:
2. 高频变化数据优先事件表化
3. 聚合结果优先投影表 / view而不是频繁重塑旧表结构
### 5.5 主工程必须按多 package 方式组织模块
### 5.5 主工程必须按多 crate 方式组织模块
从当前版本开始Rust 后端固定采用“主工程 + 独立模块 package”的方式组织
从当前版本开始Rust 后端固定采用“主工程 crate + 独立模块 crate”的方式组织
1. `apps/api-server` 作为 Axum 主工程,只负责协议装配与模块组合。
2. `apps/spacetime-module` 作为 SpacetimeDB 主工程,只负责聚合各模块 package 的表、reducer、view。
3. 每个独立业务模块必须优先拥有自己的 workspace package再由主工程引用。
4. 只有共享 contract、共享领域内核、平台适配、SpacetimeDB client 这类跨模块能力,才允许使用共享 package。
这里再明确一层:
这样做的目的,是避免把当前 `12` 个既有模块边界重新压缩回单个“大 application package”或“大 domain package”中确保后续重写能继续按模块独立演进
1. `crates/` 只是工作区下统一承载 Rust crate 的目录名
2. 目录里的每个独立单元在 Rust 语义上都按 workspace crate 对待。
组织规则固定为:
1. `crates/api-server` 作为 Axum 主工程 crate只负责协议装配与模块组合。
2. `crates/spacetime-module` 作为 SpacetimeDB 主工程 crate只负责聚合各模块 crate 的表、reducer、view。
3. 每个独立业务模块必须优先拥有自己的 workspace crate再由主工程 crate 引用。
4. 只有共享 contract、共享领域内核、平台适配、SpacetimeDB client 这类跨模块能力,才允许使用共享 crate。
这样做的目的,是避免把当前 `12` 个既有模块边界重新压缩回单个“大 application crate”或“大 domain crate”中确保后续重写能继续按模块独立演进。
### 5.6 SpacetimeDB 相关修改的执行约束
从当前版本开始,凡是涉及 `SpacetimeDB` 的设计、实现、脚本、调试与前端接入,统一要求显式使用以下 skill 作为执行依据:
1. [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
2. [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
3. [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
4. [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
执行要求:
1. 涉及 `spacetime` CLI、发布、绑定生成、本地联调时优先按 `spacetimedb-cli` 约束执行。
2. 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,优先按 `spacetimedb-rust``spacetimedb-concepts` 约束执行。
3. 涉及前端或 Node 侧的 SpacetimeDB 绑定、订阅、TypeScript SDK 接入时,优先按 `spacetimedb-typescript``spacetimedb-concepts` 约束执行。
4. 若 skill 约束与仓库内已有旧实现存在冲突,必须先以 skill 约束校正设计文档与实现方案,再继续编码,避免沿用已过时或幻觉式 API。
## 6. 推荐工程结构
@@ -218,24 +241,24 @@ SpacetimeDB 官方文档对自动迁移的限制很强:
```text
server-rs/
├─ Cargo.toml
├─ apps/
│ ├─ api-server/ # Axum 主工程负责装配路由、中间件、SSE 与模块引用
─ spacetime-module/ # SpacetimeDB 主工程负责聚合表、reducer、view 并发布 wasm
├─ packages/
│ ├─ module-auth/ # 鉴权与会话模块 package
│ ├─ module-runtime/ # runtime snapshot / settings / profile 模块 package
│ ├─ module-story/ # story 主循环模块 package
│ ├─ module-combat/ # 战斗规则模块 package
│ ├─ module-inventory/ # 背包与奖励模块 package
│ ├─ module-npc/ # NPC 状态与对话模块 package
│ ├─ module-progression/ # 成长与章节推进模块 package
│ ├─ module-quest/ # 任务运行时模块 package
│ ├─ module-runtime-item/ # 运行时物品模块 package
│ ├─ module-custom-world/ # 自定义世界与 agent 模块 package
│ ├─ module-assets/ # 资产任务与对象绑定模块 package
│ ├─ module-ai/ # AI 编排模块 package
├─ crates/
│ ├─ api-server/ # Axum 主工程 crate负责装配路由、中间件、SSE 与模块引用
─ spacetime-module/ # SpacetimeDB 主工程 crate负责聚合表、reducer、view 并发布 wasm
│ ├─ module-auth/ # 鉴权与会话模块 crate
│ ├─ module-runtime/ # runtime snapshot / settings / profile 模块 crate
│ ├─ module-story/ # story 主循环模块 crate
│ ├─ module-combat/ # 战斗规则模块 crate
│ ├─ module-inventory/ # 背包与奖励模块 crate
│ ├─ module-npc/ # NPC 状态与对话模块 crate
│ ├─ module-progression/ # 成长与章节推进模块 crate
│ ├─ module-quest/ # 任务运行时模块 crate
│ ├─ module-runtime-item/ # 运行时物品模块 crate
│ ├─ module-custom-world/ # 自定义世界与 agent 模块 crate
│ ├─ module-assets/ # 资产任务与对象绑定模块 crate
│ ├─ module-ai/ # AI 编排模块 crate
│ ├─ shared-contracts/ # HTTP DTO / SSE event / 前后端兼容 contract
│ ├─ shared-kernel/ # 跨模块共享领域类型、ID、枚举、值对象
│ ├─ shared-logging/ # 工作区统一日志初始化与 tracing subscriber 基础设施
│ ├─ platform-auth/ # JWT、cookie、provider adapter
│ ├─ platform-oss/ # OSS 直传、签名、对象管理
│ ├─ platform-llm/ # DashScope / Ark / 其他模型适配
@@ -250,17 +273,18 @@ server-rs/
目录职责约束:
1. `apps/api-server/` 只做协议装配、鉴权、中间件、handler 与模块组合,不把业务模块重新堆回单包。
2. `apps/spacetime-module/` 只负责聚合各模块 package 的状态模型,不直接承接外部副作用。
3. `packages/module-*` 保持与当前业务模块边界一一对应,已明确退出本轮的 `editor` 遗留模块除外;必要时可在 package 内部再拆 `application``domain``spacetime` 子层次。
4. `packages/shared-contracts/` 负责与当前前端兼容的 JSON / SSE 协议。
5. `packages/shared-kernel/` 只放跨模块复用的数据结构和规则,不碰框架。
6. `packages/platform-*` 统一承接三方供应商与平台适配
1. `crates/api-server/` 只做协议装配、鉴权、中间件、handler 与模块组合,不把业务模块重新堆回单包。
2. `crates/spacetime-module/` 只负责聚合各模块 crate 的状态模型,不直接承接外部副作用。
3. `crates/module-*` 保持与当前业务模块边界一一对应,已明确退出本轮的 `editor` 遗留模块除外;必要时可在 crate 内部再拆 `application``domain``spacetime` 子层次。
4. `crates/shared-contracts/` 负责与当前前端兼容的 JSON / SSE 协议。
5. `crates/shared-kernel/` 只放跨模块复用的数据结构和规则,不碰框架。
6. `crates/shared-logging/` 负责统一日志初始化、过滤器解析与 subscriber 基础设施,不承接 HTTP 业务语义
7. `crates/platform-*` 统一承接三方供应商与平台适配。
命名补充说明:
1. 本文后续若出现 `auth-service``oss-service``llm-service``application::...` 等历史逻辑名,统一视为职责标签,而不是强制要求继续存在同名顶层目录。
2. 在新的多 package 版本中,这些职责会落到 `packages/module-*` 内部子层次,或落到 `packages/platform-*``packages/shared-*` 等共享 package 中。
2. 在新的多 crate 版本中,这些职责会落到 `crates/module-*` 内部子层次,或落到 `crates/platform-*``crates/shared-*` 等共享 crate 中。
## 7. 目标模块映射
@@ -314,6 +338,26 @@ server-rs/
- [SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
`refresh_session` 的 cookie/hash 边界、轮换与吊销语义,见:
- [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)
`auth_audit_log` 的事件范围、追加写规则与 DTO 派生约束,见:
- [SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md)
`auth_risk_block` 的作用域、活跃态与解除规则,见:
- [SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)
`sms_auth_event` 的事件范围、发送/校验写入规则、统计口径与和风控/审计表的边界,见:
- [SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
`wechat_auth_state` 的字段、过期时间、授权场景、callback 单次消费与清理策略,见:
- [SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
### B. 运行时主状态表
- `runtime_snapshot`
@@ -442,6 +486,10 @@ server-rs/
- `phone_verified`
- `display_name`
`iss/sub/sid/provider/roles/ver/phone_verified/binding_status` 的字段定义、哪些字段禁止进入 JWT、以及 Axum 与 `SpacetimeDB` 的使用边界,见:
- [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
## 9.3 Refresh Session
建议保留当前模式:

View File

@@ -0,0 +1,388 @@
# `refresh_session` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于完成 `M2` 的第三条任务:`设计 refresh_session`
目标是把以下几件事固定到可编码级别:
1. refresh cookie 与服务端 session 表的边界
2. `refresh``logout``logout-all``sessions/:sessionId/revoke` 的失效语义
3. `refresh_session``user_account.token_version` 的职责切分
4. 会话列表、当前设备识别、轮换与吊销的数据结构
## 2. 当前基线
当前 Node 后端已经存在一张 `user_sessions` 表,并且 refresh cookie 主链已经完整可用:
1. 登录成功后创建随机 refresh token并只把原始 token 放入 HttpOnly cookie
2. 服务端只存 `sha256(refresh_token)` 结果
3. `/api/auth/refresh` 会轮换 refresh token同时更新过期时间与 `last_seen_at`
4. `/api/auth/logout` 会吊销当前 refresh session并提升 `token_version`
5. `/api/auth/logout-all` 会吊销当前账号全部 refresh session并提升 `token_version`
6. `/api/auth/sessions` 依赖会话表列出当前设备与远端设备
7. `/api/auth/sessions/:sessionId/revoke` 只吊销目标设备,不影响当前设备
当前 Node `user_sessions` 字段基线:
1. `id`
2. `user_id`
3. `refresh_token_hash`
4. `client_type`
5. `user_agent`
6. `ip`
7. `expires_at`
8. `revoked_at`
9. `created_at`
10. `updated_at`
11. `last_seen_at`
这说明:
1. refresh session 已经是现有系统的既有真相源。
2. Rust 重写时不需要重新发明另一套“session cache + cookie state”双轨模型。
3. 只需要把当前语义更明确地迁入 SpacetimeDB并把与 `user_account` 的职责切开。
## 3. 边界定义
### 3.1 `refresh_session` 负责
1. 设备级 refresh token hash 真相
2. 设备级过期时间
3. 设备级吊销状态
4. 设备级最后活跃时间
5. 会话列表所需的客户端信息
### 3.2 它不负责
1. access token 签发
2. access token 全局失效版本号
3. 用户主状态
4. provider 身份绑定
5. 短信验证码与微信 OAuth
### 3.3 与 `user_account` 的职责切分
固定规则:
1. `refresh_session` 负责“哪台设备还能继续 refresh”
2. `user_account.token_version` 负责“旧 access token 是否整体失效”
因此:
1. `logout` 必须同时改两层
2. `logout-all` 必须同时改两层
3. `sessions/:sessionId/revoke` 只改 `refresh_session`
4. `/refresh` 只改 `refresh_session`,不改 `token_version`
## 4. cookie 与表的边界
### 4.1 cookie 只存原始 token
浏览器侧固定继续存:
1. cookie 名:`genarrative_refresh_session`
2. 值:原始 refresh token
3. `HttpOnly`
4. `Path=/api/auth`
5. 默认 `SameSite=Lax`
6. 生产环境按配置决定 `Secure`
### 4.2 表里只存 hash
`refresh_session` 固定只存:
1. `sha256(refresh_token)`
禁止:
1. 把原始 refresh token 落库
2. 把原始 refresh token 写日志
3. 把 cookie 配置字段冗余进表结构
### 4.3 当前设备识别方式
`/api/auth/sessions``isCurrent` 固定按以下规则判断:
1. 从 cookie 读出原始 refresh token
2. 计算 hash
3.`refresh_session.refresh_token_hash` 比较
## 5. 表访问级别
`refresh_session` 固定为 `private table`
原因:
1. 包含 refresh token hash
2. 包含客户端 UA 与 IP
3. 包含设备级会话状态
前端不直接查询该表,只能通过 Axum / view 聚合后的 DTO 读取。
## 6. 字段设计
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `session_id` | `String` | 是 | 主键,建议继续沿用 `usess_*` 前缀。 |
| `user_id` | `String` | 是 | 归属账号 ID外键指向 `user_account.user_id`。 |
| `refresh_token_hash` | `String` | 是 | 当前生效 refresh token 的哈希值。 |
| `client_type` | `String` | 是 | 当前设备类型,当前阶段默认 `browser`。 |
| `user_agent` | `Option<String>` | 否 | 请求头中的 `User-Agent` 原文。 |
| `ip` | `Option<String>` | 否 | 会话创建时采集的客户端 IP。 |
| `issued_by_provider` | `String` | 是 | 该会话是由哪种登录链路创建,枚举固定为 `password``phone``wechat`。 |
| `expires_at` | `String` | 是 | 当前 refresh token 过期时间UTC RFC3339。 |
| `revoked_at` | `Option<String>` | 否 | 会话被吊销的时间。 |
| `revoked_reason_code` | `Option<String>` | 否 | 吊销原因码,例如 `logout``logout_all``session_revoke``account_disabled`。 |
| `created_at` | `String` | 是 | 会话首次创建时间。 |
| `updated_at` | `String` | 是 | 最近一次会话状态变更时间。 |
| `last_seen_at` | `String` | 是 | 最近一次 refresh 成功或创建时的活跃时间。 |
补充说明:
1. 当前阶段时间字段统一继续使用 UTC RFC3339 字符串。
2. `session_id` 在 refresh 轮换时保持不变,不创建新会话行。
3. `issued_by_provider` 不是为了做 provider 身份表,而是为了后续账号安全页和审计展示保留稳定字段。
## 7. 唯一约束与索引
### 7.1 必须具备的唯一约束
1. `session_id` 主键唯一
2. `refresh_token_hash` 全局唯一
### 7.2 必须具备的查询索引
1. `(user_id, revoked_at, expires_at, last_seen_at DESC)`
作用:列当前账号活跃会话
2. `(user_id, session_id)`
作用:按用户吊销指定会话
3. `(expires_at, revoked_at)`
作用:后续清理过期/已吊销会话
4. `refresh_token_hash`
作用refresh、logout、current session 判断
## 8. 生命周期设计
### 8.1 创建
触发点:
1. 密码登录成功
2. 手机号登录成功
3. 微信登录成功
4. 微信绑定手机号成功后签发正式会话
写入规则:
1. 生成原始 refresh token
2. 计算 `refresh_token_hash`
3. 创建一条新 `refresh_session`
4. `last_seen_at = created_at`
### 8.2 刷新
触发点:
1. `POST /api/auth/refresh`
写入规则:
1. 先按 `refresh_token_hash` 找当前 session
2. 校验 `revoked_at == null`
3. 校验 `expires_at > now`
4. 生成新的 refresh token
5. 更新同一条 session 的 `refresh_token_hash`
6. 更新 `expires_at`
7. 更新 `last_seen_at`
8. 更新 `updated_at`
关键约束:
1. refresh 是“同一会话轮换”,不是“新建第二条会话”。
2. `session_id` 在轮换前后必须稳定,保证会话列表中的设备 ID 不跳变。
### 8.3 吊销当前会话
触发点:
1. `POST /api/auth/logout`
写入规则:
1. 按当前 cookie 找 session
2.`revoked_at = now`
3.`revoked_reason_code = logout`
4. 同时提升 `user_account.token_version`
### 8.4 吊销全部会话
触发点:
1. `POST /api/auth/logout-all`
写入规则:
1.`user_id` 批量吊销全部未吊销 session
2. `revoked_reason_code = logout_all`
3. 同时提升 `user_account.token_version`
### 8.5 吊销指定远端设备
触发点:
1. `POST /api/auth/sessions/:sessionId/revoke`
写入规则:
1. 只允许吊销同一 `user_id` 下的目标 session
2. 当前设备不允许通过该接口吊销自己
3. 只改目标 `refresh_session`
4. `revoked_reason_code = session_revoke`
5. 不提升 `token_version`
### 8.6 账号被禁用或并入
触发点:
1. `user_account.account_status = disabled`
写入规则:
1. 该账号下所有未吊销 session 都必须被批量吊销
2. `revoked_reason_code = account_disabled`
## 9. 活跃态判断规则
一条 `refresh_session` 只有同时满足以下条件,才视为活跃:
1. `revoked_at = null`
2. `expires_at > now`
3. 所属 `user_account.account_status = active` 或允许 refresh 的待绑定状态
补充约束:
1. 当前阶段 `pending_bind_phone` 的微信壳账号允许 refresh但只允许继续走绑定手机号相关接口。
2. `disabled` 账号无论 session 本身是否过期,都不能继续 refresh。
## 10. 与现有接口的映射
### 10.1 `POST /api/auth/refresh`
依赖:
1. `refresh_session.refresh_token_hash`
2. `refresh_session.expires_at`
3. `refresh_session.revoked_at`
4. `user_account.account_status`
5. `user_account.token_version`
### 10.2 `GET /api/auth/sessions`
直接读取:
1. `session_id`
2. `client_type`
3. `user_agent`
4. `ip`
5. `created_at`
6. `last_seen_at`
7. `expires_at`
前端 DTO `clientLabel``ipMasked``isCurrent` 继续在 Axum 侧派生。
### 10.3 `POST /api/auth/logout`
依赖:
1. 当前 cookie 命中的 `refresh_session`
2. `user_account.token_version`
### 10.4 `POST /api/auth/logout-all`
依赖:
1. 当前 `user_id` 下全部活跃 `refresh_session`
2. `user_account.token_version`
## 11. 与当前 Node `user_sessions` 的映射关系
| Node `user_sessions` 列 | 新 `refresh_session` 字段 | 迁移规则 |
| --- | --- | --- |
| `id` | `session_id` | 原样迁移。 |
| `user_id` | `user_id` | 原样迁移。 |
| `refresh_token_hash` | `refresh_token_hash` | 原样迁移。 |
| `client_type` | `client_type` | 原样迁移。 |
| `user_agent` | `user_agent` | 原样迁移。 |
| `ip` | `ip` | 原样迁移。 |
| `expires_at` | `expires_at` | 原样迁移。 |
| `revoked_at` | `revoked_at` | 原样迁移。 |
| `created_at` | `created_at` | 原样迁移。 |
| `updated_at` | `updated_at` | 原样迁移。 |
| `last_seen_at` | `last_seen_at` | 原样迁移。 |
新增字段回填规则:
1. `issued_by_provider`
初次迁移统一回填为 `password`
说明:这是保守回填值,后续只影响展示,不影响鉴权正确性
2. `revoked_reason_code`
初次迁移统一回填为 `null`
## 12. reducer / service 落地约束
### 12.1 `module-auth` reducer 层
必须至少具备这些命令入口:
1. `create_refresh_session`
2. `rotate_refresh_session`
3. `revoke_refresh_session`
4. `revoke_refresh_sessions_by_user`
5. `revoke_refresh_session_by_user_and_session`
6. `touch_refresh_session_last_seen`
### 12.2 Axum 应用层
固定负责:
1. 生成原始 refresh token
2. 计算 hash
3. 读写 HttpOnly cookie
4. 决定当前调用是创建、轮换还是吊销
5.`revoked_reason_code` 映射到对应业务语义
## 13. 不允许的设计漂移
后续实现时禁止出现以下情况:
1. refresh 轮换时新建第二条 session而不是更新原 session
2. `sessions/:sessionId/revoke` 顺手提升 `token_version`,导致当前 access token 一起失效
3. `logout-all` 只提升 `token_version`,却不吊销 refresh session
4. 原始 refresh token 直接入库
5. 会话表开始承担 `user_account` 状态职责
## 14. 本任务完成定义
当以下条件满足时,`设计 refresh_session` 视为完成:
1. refresh cookie 与服务端 session hash 的边界已经明确。
2. 轮换、当前设备吊销、全部设备吊销三种语义已经切开。
3. `refresh_session``user_account.token_version` 的职责已明确。
4. 字段、唯一约束、索引与迁移规则已具体到可直接编码。
## 15. 依据文件
1. `server-node/src/routes/authRoutes.ts`
2. `server-node/src/auth/authService.ts`
3. `server-node/src/auth/refreshSessionCookie.ts`
4. `server-node/src/repositories/userSessionRepository.ts`
5. `server-node/src/config.ts`
6. `server-node/src/db/migrations.ts`
7. `server-node/src/app.test.ts`
8. `docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md`
9. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md`
10. `docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md`
11. `docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md`

View File

@@ -0,0 +1,364 @@
# `sms_auth_event` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于完成 `M2` 的第六条任务:`设计 sms_auth_event`
目标是把短信鉴权事件表固定成一张“短信发送与验证码校验的统计源表”,并明确:
1. 哪些短信相关动作要写入事件
2. 哪些动作不应该写进这张表
3. 发送频控、失败次数限制、captcha 触发、风险保护分别依赖什么统计口径
4. 它与 `auth_risk_block``auth_audit_log` 的边界
## 2. 当前基线
当前 Node 后端已经存在 `sms_auth_events` 表,并有稳定的读写链路:
1. `POST /api/auth/phone/send-code` 在短信发送成功后写入一条 `send_code` 事件
2. `POST /api/auth/phone/login` 在验证码校验成功或失败后写入 `verify_code` 事件
3. `POST /api/auth/wechat/bind-phone` 在绑定手机号时复用同一套 `verify_code` 事件
4. `POST /api/auth/phone/change` 在换绑手机号时复用同一套 `verify_code` 事件
5. 发送频控、失败次数限制、captcha 触发与风险保护,全部依赖这张表统计
当前 Node `sms_auth_events` 字段基线:
1. `id`
2. `phone_number`
3. `scene`
4. `action`
5. `success`
6. `ip`
7. `user_agent`
8. `created_at`
当前已落地的 `scene` 基线:
1. `login`
2. `bind_phone`
3. `change_phone`
当前已落地的 `action` 基线:
1. `send_code`
2. `verify_code`
## 3. 表职责边界
### 3.1 `sms_auth_event` 负责
1. 记录短信验证码发送成功事件
2. 记录短信验证码校验成功或失败事件
3. 作为手机号维度与 IP 维度的短信鉴权统计源
4. 为发送频控、失败次数限制、captcha 触发、风险保护提供统一统计基础
### 3.2 它不负责
1. 保存验证码明文或验证码 hash
2. 保存阿里云短信 provider 的完整原始响应
3. 记录当前风险保护是否仍生效
4. 记录账号安全动作的长期审计历史
### 3.3 与其他表的边界
1. `sms_auth_event` 负责“发生过哪些短信发送/校验事件”
2. `auth_risk_block` 负责“经过统计后当前是否处于保护状态”
3. `auth_audit_log` 负责“账号维度发生过哪些安全动作”
## 4. 访问级别
`sms_auth_event` 固定为 `private table`
原因:
1. 原始手机号、原始 IP、原始 UA 都属于敏感安全数据
2. 这张表主要服务于后端风控与统计,不直接对前端暴露
3. 前端不应该直接查询或订阅短信验证码操作明细
## 5. 字段设计
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `sms_event_id` | `String` | 是 | 主键,建议继续沿用 `smsev_*` 前缀。 |
| `phone_e164` | `String` | 是 | 标准化后的 `E.164` 手机号。 |
| `scene` | `String` | 是 | 业务场景,枚举固定为 `login``bind_phone``change_phone`。 |
| `action` | `String` | 是 | 动作类型,枚举固定为 `send_code``verify_code`。 |
| `success` | `bool` | 是 | 当前动作是否成功。 |
| `ip` | `Option<String>` | 否 | 请求来源 IP缺失时为 `null`。 |
| `user_agent` | `Option<String>` | 否 | 请求来源 UA缺失时为 `null`。 |
| `created_at` | `String` | 是 | 事件发生时间UTC RFC3339。 |
补充约束:
1. 当前阶段不存 `provider_request_id`,因为这张表不是供应商排障日志表。
2. 当前阶段不存 `provider_code``provider_message`,避免把供应商响应日志职责压进统计源表。
3. 当前阶段不存 `user_id`,因为验证码发送和校验发生时,场景并不总是已经稳定归属到某个正式账号。
## 6. 枚举与语义设计
### 6.1 `scene`
当前阶段固定只支持:
1. `login`
2. `bind_phone`
3. `change_phone`
解释:
1. `login` 对应手机号验证码登录
2. `bind_phone` 对应微信待激活账号绑定手机号
3. `change_phone` 对应正式账号更换手机号
### 6.2 `action`
当前阶段固定只支持:
1. `send_code`
2. `verify_code`
解释:
1. `send_code` 表示验证码已成功发出
2. `verify_code` 表示一次验证码校验尝试已经结束
### 6.3 `success`
固定语义:
1. `send_code + success = true` 表示供应商确认发送成功
2. `verify_code + success = true` 表示验证码校验通过
3. `verify_code + success = false` 表示验证码校验失败或已失效
当前阶段特别约束:
1. `send_code + success = false` 暂不入表
2. 发送失败继续由应用日志、供应商日志与 tracing 承担排障,不混入当前统计口径
这样做的原因是:
1. 现有发送频控按 `action = send_code` 的总数统计
2. Node 当前只在发送成功后写事件
3. 若直接把发送失败也写入,会改变当前频控语义
## 7. 统计口径设计
### 7.1 发送频控
固定统计来源:
1. 按手机号统计 `action = send_code` 的事件数
2. 按 IP 统计 `action = send_code` 的事件数
当前 Node 兼容窗口:
1. 单手机号:`过去 1 天`
2. 单 IP`过去 1 小时`
### 7.2 验证失败次数限制
固定统计来源:
1. 按手机号统计 `action = verify_code AND success = false`
2. 按 IP 统计 `action = verify_code AND success = false`
当前 Node 兼容窗口:
1. 单手机号:`过去 1 小时`
2. 单 IP`过去 1 小时`
### 7.3 captcha 触发
固定统计来源:
1. 按手机号统计 `verify_code` 失败次数
2. 按 IP 统计 `verify_code` 失败次数
说明:
1. `captcha challenge` 自身不写进 `sms_auth_event`
2. `captcha_required` 审计事件继续写进 `auth_audit_log`
### 7.4 风险保护触发
固定统计来源:
1. 按手机号统计 `verify_code` 失败次数
2. 按 IP 统计 `verify_code` 失败次数
说明:
1. 风险保护命中后真正的当前态写进 `auth_risk_block`
2. `sms_auth_event` 只提供统计基础,不直接承载保护状态
## 8. 写入规则
### 8.1 `POST /api/auth/phone/send-code`
固定流程:
1. 先检查当前手机号 / 当前 IP 是否存在活跃 `auth_risk_block`
2. 再根据 `sms_auth_event` 统计发送频控
3. 再根据 `sms_auth_event` 统计决定是否需要 captcha
4. 短信 provider 返回发送成功后,写入一条:
- `scene = 当前请求场景`
- `action = send_code`
- `success = true`
### 8.2 `POST /api/auth/phone/login`
固定流程:
1. 先检查当前手机号 / 当前 IP 是否存在活跃 `auth_risk_block`
2. 再根据 `sms_auth_event` 统计失败次数限制
3. 校验成功时写入:
- `scene = login`
- `action = verify_code`
- `success = true`
4. 校验失败时写入:
- `scene = login`
- `action = verify_code`
- `success = false`
5. 校验失败写入完成后,再按统计结果决定是否触发 `auth_risk_block`
### 8.3 `POST /api/auth/wechat/bind-phone`
固定流程:
1. 与手机号登录复用同一套校验前检查
2. 校验成功写入 `bind_phone + verify_code + success = true`
3. 校验失败写入 `bind_phone + verify_code + success = false`
4. 校验失败后再决定是否触发 `auth_risk_block`
### 8.4 `POST /api/auth/phone/change`
固定流程:
1. 与手机号登录复用同一套校验前检查
2. 校验成功写入 `change_phone + verify_code + success = true`
3. 校验失败写入 `change_phone + verify_code + success = false`
4. 校验失败后再决定是否触发 `auth_risk_block`
## 9. 查询索引与统计要求
### 9.1 必须具备的唯一约束
1. `sms_event_id` 主键唯一
### 9.2 必须具备的查询索引
1. `(phone_e164, action, created_at DESC)`
作用:支撑按手机号统计发送次数与失败次数
2. `(ip, action, created_at DESC)`
作用:支撑按 IP 统计发送次数与失败次数
3. `(phone_e164, action, success, created_at DESC)`
作用:支撑按手机号统计 `verify_code` 失败窗口
4. `(ip, action, success, created_at DESC)`
作用:支撑按 IP 统计 `verify_code` 失败窗口
说明:
1. 当前阶段不强求单独按 `scene` 建主索引,因为已有统计主要按手机号/IP 与动作窗口展开。
2. `scene` 继续作为事件上下文字段保留,便于后续如果要细分某一场景的频控,再追加索引。
## 10. 读取规则
当前阶段 `sms_auth_event` 不直接对外暴露 DTO。
它只支撑后端内部这几类聚合查询:
1. `count_since_by_phone(phone_e164, action, success?, since)`
2. `count_since_by_ip(ip, action, success?, since)`
读取约束:
1. `ip = null` 时,按 IP 统计固定返回 `0`
2. 统计窗口由 Axum 应用层提供,不把“过去 1 小时”“过去 1 天”写死进表层
3. 表层只提供原子计数,不在表层拼装“是否需要 captcha / 是否需要封禁”的业务判断
## 11. 与其他鉴权表的协作规则
### 11.1 与 `auth_risk_block`
固定规则:
1. `sms_auth_event` 先累积失败事实
2. Axum 根据失败统计决定是否写入或刷新 `auth_risk_block`
3. 不能直接从 `auth_risk_block` 反推历史失败次数
### 11.2 与 `auth_audit_log`
固定规则:
1. `sms_auth_event` 不承担安全操作审计展示职责
2. `captcha_required``risk_block_phone``risk_block_ip` 等用户可见安全事件,继续写进 `auth_audit_log`
3. 不允许为了省表而把 `sms_auth_event` 直接拿去充当账号操作记录
## 12. 与当前 Node `sms_auth_events` 的映射关系
| Node 列 | 新字段 | 迁移规则 |
| --- | --- | --- |
| `id` | `sms_event_id` | 原样迁移。 |
| `phone_number` | `phone_e164` | 重命名迁移,值原样保留。 |
| `scene` | `scene` | 原样迁移。 |
| `action` | `action` | 原样迁移。 |
| `success` | `success` | 原样迁移。 |
| `ip` | `ip` | 原样迁移。 |
| `user_agent` | `user_agent` | 原样迁移。 |
| `created_at` | `created_at` | 原样迁移。 |
## 13. reducer / service 落地约束
### 13.1 `module-auth` reducer 层
必须至少具备:
1. `append_sms_auth_event`
说明:
1. 这是一张追加型统计源表,不做就地更新。
2. 后续若要做归档或清理,再单独增加 maintenance reducer 或离线清理任务。
### 13.2 Axum 应用层
固定负责:
1. 标准化手机号为 `E.164`
2. 组织 `scene``action``success`
3. 在正确的时机写入事件
4. 基于统计结果决定是否触发 captcha 与 `auth_risk_block`
## 14. 不允许的设计漂移
后续实现时禁止出现以下情况:
1. 把验证码明文、验证码 hash 写进 `sms_auth_event`
2. 把阿里云 provider 的完整响应 JSON 直接写进 `sms_auth_event`
3. 把当前是否被保护的状态写进 `sms_auth_event`
4. 发送失败也无条件入表,却继续沿用当前“按 `send_code` 总量限流”的统计口径
5. 为了展示账号安全记录,直接把 `sms_auth_event` 暴露给前端
## 15. 本任务完成定义
当以下条件满足时,`设计 sms_auth_event` 视为完成:
1. 发送与校验事件的写入范围已经固定。
2. 当前发送频控、失败限制、captcha 触发与风险保护的统计口径已经固定。
3. 已和 `auth_risk_block``auth_audit_log` 明确切开职责。
4. 后续可以直接按这份文档编码 reducer、计数查询与 Axum 应用层判断逻辑。
## 16. 依据文件
1. `server-node/src/repositories/smsAuthEventRepository.ts`
2. `server-node/src/auth/authService.ts`
3. `server-node/src/services/smsVerificationService.ts`
4. `server-node/src/routes/authRoutes.ts`
5. `server-node/src/db/migrations.ts`
6. `packages/shared/src/contracts/auth.ts`
7. `docs/prd/ACCOUNT_SYSTEM_AND_LOGIN_ENTRY_PRD_2026-04-09.md`
8. `docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md`
9. `docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md`
10. `docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md`

View File

@@ -0,0 +1,324 @@
# `wechat_auth_state` 表设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于完成 `M2` 的第七条任务:`设计 wechat_auth_state`
目标是把当前只存在于 Node 进程内存中的微信 OAuth `state` 临时仓,升级为一张可跨实例、可过期、可单次消费的 `SpacetimeDB private table`,并固定:
1. 微信登录 `state` 的职责边界
2. `wechat/start``wechat/callback` 的写入/消费顺序
3. 活跃态、已消费态、已过期态的判定规则
4. 它与 `auth_identity``refresh_session``auth_audit_log` 的边界
## 2. 当前基线
当前 Node 后端并没有数据库表,而是使用进程内临时 `Map` 维护微信登录状态:
1. `server-node/src/services/wechatAuthStateStore.ts`
2. `server-node/src/auth/authService.ts`
3. `server-node/src/routes/authRoutes.ts`
当前 Node `WechatAuthStateStore` 字段基线只有三项:
1. `state`
2. `redirectPath`
3. `createdAt`
当前 Node 已落地行为基线:
1. `GET /api/auth/wechat/start` 创建一个随机 `state`,并把 `redirectPath` 放进内存仓
2. `WechatAuthService.buildAuthorizationUrl(...)` 使用该 `state` 生成微信授权 URL
3. `GET /api/auth/wechat/callback` 进入时先 `consume(state)`,命中则立刻从内存仓删除
4.`state` 未命中,则回退到默认 `redirectPath` 并带 `auth_error`
5. 即使后续微信 `code` 兑换、账号创建或绑定失败,当前 `state` 也不会恢复成可再次使用
当前实现的主要问题:
1. 状态仅存在于单进程内存,无法支撑 Axum 多实例部署
2. 进程重启后所有未完成的微信登录都会失效
3. 当前没有显式过期时间与清理策略
4. 当前 `startWechatLogin(...)` 会先创建 `state`,再校验授权场景;若是“普通手机浏览器非微信内打开”,会产生无法使用的脏状态
## 3. 表职责边界
### 3.1 `wechat_auth_state` 负责
1. 保存一次微信登录发起时生成的随机 `state`
2. 保存与这次 `state` 绑定的 `redirect_path`
3. 保存本次授权场景 `scene`
4. 保存 `state` 的过期时间与消费时间
5. 作为 `wechat/callback` 单次消费判定的唯一事实来源
### 3.2 它不负责
1. 保存微信 `code``access_token``refresh_token`
2. 保存微信用户资料或 provider 身份绑定结果
3. 保存长期登录会话
4. 承担账号安全审计展示
### 3.3 与其他表的边界
1. `wechat_auth_state` 只负责“这次 OAuth 跳转是否合法、是否还能被消费”
2. `auth_identity` 负责“微信 provider 身份最终绑定到哪个账号”
3. `refresh_session` 负责“微信登录成功后生成的长期浏览器登录态”
4. `auth_audit_log` 负责“微信登录成功、绑定手机号等长期可追溯安全事件”
## 4. 访问级别
`wechat_auth_state` 固定为 `private table`
原因:
1. `state` 本身就是一次性安全令牌,不应该暴露给前端以外的查询面
2. `redirect_path``request_user_agent` 都属于登录上下文数据
3. 这张表只服务于 Axum 鉴权应用层,不应被前端直接查询或订阅
## 5. 字段设计
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `wechat_state_id` | `String` | 是 | 主键,建议沿用 `wxstate_*` 前缀。 |
| `state_token` | `String` | 是 | 发往微信 OAuth 的随机 `state` 原文,固定唯一。 |
| `redirect_path` | `String` | 是 | 已归一化后的相对跳转路径,始终为站内路径。 |
| `scene` | `String` | 是 | 授权场景,枚举固定为 `desktop``wechat_in_app`。 |
| `request_user_agent` | `Option<String>` | 否 | 发起授权时的 UA 原文快照;缺失时为 `null`。 |
| `expires_at` | `String` | 是 | 当前 `state` 的失效时间UTC RFC3339。 |
| `consumed_at` | `Option<String>` | 否 | 首次被成功消费的时间;未消费时为 `null`。 |
| `created_at` | `String` | 是 | 创建时间。 |
| `updated_at` | `String` | 是 | 最近一次状态变更时间;创建时等于 `created_at`,消费时更新。 |
补充约束:
1. `state_token` 继续兼容当前 Node 语义,默认由 `18` 字节随机数生成 `36` 位十六进制字符串。
2. `redirect_path` 必须先经过当前 `normalizeRedirectPath(...)` 规则归一化后再落表,不保存原始外部 URL。
3. `request_user_agent` 仅用于短期问题排查与场景回放,建议在 Axum 侧截断到 `1024` 字节以内。
4. 当前阶段不保存 `request_ip`,避免把短期 OAuth 状态表扩成额外的风控上下文表;若后续需要 IP 维度审计,应由 `auth_audit_log` 或独立风控表承担。
## 6. 场景与状态语义设计
### 6.1 `scene`
当前阶段固定只支持:
1. `desktop`
2. `wechat_in_app`
解释:
1. `desktop` 对应桌面浏览器发起的微信登录
2. `wechat_in_app` 对应微信内浏览器发起的微信登录
补充约束:
1. 普通手机浏览器且非微信内打开,不进入 `wechat_auth_state` 创建流程,直接按当前产品规则返回错误。
2. `scene` 必须在 `wechat/start` 创建表记录前先解析完成,避免写入无效状态。
### 6.2 活跃态
一条 `wechat_auth_state` 同时满足以下条件,才视为活跃可用:
1. `consumed_at = null`
2. `expires_at > now`
### 6.3 已消费态
满足以下条件时,视为已消费:
1. `consumed_at != null`
说明:
1. 已消费态不区分后续微信回调业务成功还是失败。
2. 只要进入 callback 并通过单次消费校验,这条 `state` 就不能再次复用。
### 6.4 已过期态
满足以下条件时,视为已过期:
1. `consumed_at = null`
2. `expires_at <= now`
说明:
1. 过期态不要求立即更新行状态。
2. 读取活跃态时自然排除。
## 7. 时效与清理策略
### 7.1 `state` 有效期
当前阶段固定设计为短时有效态:
1. 默认 TTL`15` 分钟
2. 实际值由 Axum 配置提供,建议新增 `wechat_auth.state_ttl_minutes`
设计原因:
1. 需要覆盖桌面端扫码与微信内授权的正常完成窗口
2. 不能无限期保留可回放的 OAuth `state`
### 7.2 清理保留期
当前阶段建议保留短期排障窗口后再清理:
1. 已消费记录:`consumed_at` 之后保留 `24` 小时
2. 已过期未消费记录:`expires_at` 之后保留 `24` 小时
说明:
1. 不在消费成功时立即删行,避免短期内无法排查重复回调、授权失败等问题
2. 这张表不是审计表,不允许无限期堆积
## 8. 写入与消费规则
### 8.1 `GET /api/auth/wechat/start`
固定流程:
1. 先归一化 `redirectPath`;空值或非法值回退到默认 `redirectPath`
2. 先根据 `userAgent` 解析 `scene`
3. 若场景是“普通手机浏览器且非微信内打开”,直接返回错误,不写表
4. 生成随机 `state_token`
5. 计算 `expires_at = now + ttl`
6. 写入一条新的 `wechat_auth_state`
7. 使用 `state_token + scene + callbackUrl` 生成最终授权 URL
关键约束:
1. 每次点击微信登录都创建新记录,不复用旧记录。
2. 不按 `redirect_path` 去重,允许同一用户在多个标签页并行发起微信登录。
3. 只有场景校验通过后才允许写表,避免制造无意义脏数据。
### 8.2 `GET /api/auth/wechat/callback`
固定流程:
1. 读取请求里的 `state`
2.`state` 为空,直接使用默认 `redirectPath` 重定向并带 `auth_error`
3.`state_token` 查询对应记录
4. 若记录不存在、已过期或已消费,直接使用默认 `redirectPath` 重定向并带 `auth_error`
5. 若命中活跃记录,先缓存其 `redirect_path`
6. 在进行微信 `code` 兑换、身份查找、账号创建前,先执行单次消费:
- `consumed_at = now`
- `updated_at = now`
7. 若单次消费成功,再继续后续微信登录主链
8. 后续主链无论成功还是失败,都使用第 `5` 步缓存的 `redirect_path` 进行最终跳转
关键约束:
1. `state` 必须“先消费、后换取微信用户资料”,保持和当前 Node 行为一致,避免同一 `state` 被重复回放。
2. 消费成功后,即使后续 provider 失败、用户创建失败或绑定失败,也不回滚 `consumed_at`
3. 竞争消费时只允许首个请求成功,后到请求一律视为无效或已失效回调。
## 9. 唯一约束与索引
### 9.1 必须具备的唯一约束
1. `wechat_state_id` 主键唯一
2. `state_token` 全局唯一
### 9.2 必须具备的查询索引
1. `(state_token)`
作用:支撑 callback 按 `state` 精确查找
2. `(expires_at)`
作用:支撑过期数据清理
3. `(consumed_at)`
作用:支撑已消费数据清理
说明:
1. 当前阶段不需要按 `redirect_path``scene` 建业务查询索引,因为主链只按 `state_token` 精确查找。
2. 清理作业以时间窗口为主,不需要复杂多列排序索引。
## 10. 读取规则
当前阶段 `wechat_auth_state` 不直接对外暴露 DTO。
它只支撑后端内部这几类读取:
1. `find_by_state_token(state_token)`
2. `find_active_by_state_token(state_token)`
3. `list_expired_before(deadline)`
4. `list_consumed_before(deadline)`
读取约束:
1. “是否活跃”由应用层按 `consumed_at``expires_at` 判定,不引入额外状态枚举列。
2. 读取命中后,`redirect_path` 只用于当前 callback 的最终跳转,不向前端原样暴露为查询接口。
3. 前端不允许直接查看自己仍持有多少个待消费微信 `state`
## 11. 与当前 Node 内存状态仓的映射关系
| Node 字段/行为 | 新字段/行为 | 迁移规则 |
| --- | --- | --- |
| `state` | `state_token` | 原语义保留,继续作为微信 OAuth 回调校验值。 |
| `redirectPath` | `redirect_path` | 重命名迁移;写入前必须先归一化为站内路径。 |
| `createdAt` | `created_at` | 原样迁移为 UTC RFC3339。 |
| 无 | `wechat_state_id` | 新增内部主键。 |
| 无 | `scene` | 新增授权场景字段,值来自 `userAgent` 解析。 |
| 无 | `request_user_agent` | 新增请求上下文字段,用于短期排障。 |
| 无 | `expires_at` | 新增显式过期时间。 |
| `consume(state)` 后直接删除 | `consumed_at` 标记消费 | 改为“标记消费 + 延迟清理”,不再立刻硬删除。 |
| 无 | `updated_at` | 新增状态更新时间,用于消费与清理追踪。 |
## 12. reducer / service 落地约束
### 12.1 `module-auth` reducer 层
必须至少具备:
1. `create_wechat_auth_state`
2. `consume_wechat_auth_state`
3. `purge_wechat_auth_state`
说明:
1. `create_wechat_auth_state` 只负责插入新的待消费记录。
2. `consume_wechat_auth_state` 只允许消费“当前仍活跃”的记录。
3. `purge_wechat_auth_state` 只负责清理保留期外的已消费或已过期记录。
### 12.2 Axum 应用层
固定负责:
1. 归一化 `redirectPath`
2. 根据 `userAgent` 判定 `scene`
3. 生成随机 `state_token`
4. 计算 `expires_at`
5. 在 callback 中先读取、再执行单次消费、再继续微信 provider 主链
6. 对“无效 state / 过期 state / 已消费 state”统一生成兼容当前前端的 `auth_error` 跳转结果
## 13. 不允许的设计漂移
后续实现时禁止出现以下情况:
1. 继续把微信 OAuth `state` 只放在 Axum 进程内存里,当成多实例时代的真相源
2. 在普通手机浏览器非微信内打开时仍然先创建 `wechat_auth_state`
3. 为了省步骤,把 `state` 改成“登录成功后再消费”
4. 把微信 `code``access_token`、用户资料 JSON 直接写进 `wechat_auth_state`
5. 消费成功后立刻硬删除记录,导致无法区分“重复回调”与“从未发起过该 state”
6.`wechat_auth_state` 暴露成前端可直接查询的公共表或订阅面
## 14. 本任务完成定义
当以下条件满足时,`设计 wechat_auth_state` 视为完成:
1. 当前 Node 内存状态仓已被完整映射为可落表的短期状态模型。
2. `wechat/start``wechat/callback` 的写入、消费、过期、清理规则已固定。
3. 已和 `auth_identity``refresh_session``auth_audit_log` 明确切开职责。
4. 后续可以直接按这份文档编码 `module-auth` reducer、Axum 仓储接口与清理任务。
## 15. 依据文件
1. `server-node/src/services/wechatAuthStateStore.ts`
2. `server-node/src/auth/authService.ts`
3. `server-node/src/routes/authRoutes.ts`
4. `server-node/src/services/wechatAuthService.ts`
5. `packages/shared/src/contracts/auth.ts`
6. `docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md`
7. `docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md`
8. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`

254
server-rs/Cargo.lock generated
View File

@@ -23,14 +23,15 @@ version = "0.1.0"
dependencies = [
"axum",
"http-body-util",
"platform-auth",
"serde",
"serde_json",
"shared-logging",
"time",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
@@ -40,6 +41,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.9"
@@ -92,6 +99,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -110,6 +123,16 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[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"
@@ -131,6 +154,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[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"
@@ -179,6 +208,19 @@ dependencies = [
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@@ -327,6 +369,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -386,7 +443,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -395,7 +452,17 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
@@ -404,12 +471,40 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -422,6 +517,15 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "platform-auth"
version = "0.1.0"
dependencies = [
"jsonwebtoken",
"serde",
"time",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
@@ -479,6 +583,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -572,6 +690,31 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "shared-logging"
version = "0.1.0"
dependencies = [
"tracing-subscriber",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -591,7 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -611,6 +754,26 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -662,7 +825,7 @@ dependencies = [
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -794,13 +957,19 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom",
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
@@ -920,6 +1089,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -929,6 +1107,70 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"

View File

@@ -1,13 +1,15 @@
# 当前阶段先建立虚拟 workspace。
# 后续按“主工程 apps + 独立模块 packages”模式逐项补充 members。
# 后续按“主工程 crate + 独立模块 crate”模式逐项补充 members。
[workspace]
resolver = "2"
members = [
"apps/api-server",
"crates/api-server",
"crates/platform-auth",
"crates/shared-logging",
]
[workspace.package]
edition = "2021"
edition = "2024"
version = "0.1.0"
license = "UNLICENSED"

View File

@@ -14,46 +14,47 @@
## 2. 当前阶段说明
当前目录已经完成以下三十项初始化:
当前目录已经完成以下三十项初始化:
1. 为新后端预留正式目录并把路径固定到仓库结构中。
2. 创建虚拟 workspace `Cargo.toml`,后续 package 会逐项挂入。
3. 明确内部采用“`apps/*` 主工程 + `packages/*` 独立模块包”的多 package 组织方式。
4. 创建 `apps/api-server/` 目录占位,固定 Axum 主工程落位。
5. 创建 `apps/spacetime-module/` 目录占位,固定 SpacetimeDB 主工程落位。
6. 创建 `packages/module-auth/` 目录占位,固定鉴权模块 package 落位。
7. 创建 `packages/module-runtime/` 目录占位,固定运行时状态基座模块 package 落位。
8. 创建 `packages/module-story/` 目录占位,固定故事主循环模块 package 落位。
9. 创建 `packages/module-combat/` 目录占位,固定战斗规则模块 package 落位。
10. 创建 `packages/module-inventory/` 目录占位,固定背包与物品变更模块 package 落位。
11. 创建 `packages/module-npc/` 目录占位,固定 NPC 状态与互动模块 package 落位。
12. 创建 `packages/module-progression/` 目录占位,固定成长与章节推进模块 package 落位。
13. 创建 `packages/module-quest/` 目录占位,固定任务运行时模块 package 落位。
14. 创建 `packages/module-runtime-item/` 目录占位,固定运行时物品模块 package 落位。
15. 创建 `packages/module-custom-world/` 目录占位,固定自定义世界与 agent 模块 package 落位。
16. 创建 `packages/module-assets/` 目录占位,固定资产任务与对象绑定模块 package 落位。
17. 创建 `packages/module-ai/` 目录占位,固定 AI 编排模块 package 落位。
18. 创建 `packages/shared-contracts/` 目录占位,固定前后端兼容 contract 共享 package 落位。
19. 创建 `packages/shared-kernel/` 目录占位,固定跨模块共享领域内核 package 落位。
20. 创建 `packages/platform-auth/` 目录占位,固定鉴权平台适配 package 落位。
21. 创建 `packages/platform-oss/` 目录占位,固定 OSS 平台适配 package 落位。
22. 创建 `packages/platform-llm/` 目录占位,固定大模型平台适配 package 落位。
23. 创建 `packages/spacetime-client/` 目录占位,固定 SpacetimeDB 客户端适配 package 落位。
24. 创建 `packages/tests-support/` 目录占位,固定测试支撑共享 package 落位。
25. 创建 `scripts/dev.ps1`,固定 Windows 本地开发入口
26. 创建 `scripts/dev.sh`,固定 Unix-like 本地开发入口。
27. 创建 `scripts/test.ps1`,固定 Windows 本地测试入口。
28. 创建 `scripts/test.sh`,固定 Unix-like 本地测试入口。
29. 创建 `scripts/check.ps1`,固定 Windows 本地统一检查入口。
30. 创建 `scripts/check.sh`,固定 Unix-like 本地统一检查入口。
31. 创建 `scripts/smoke.ps1`,固定 Windows 本地冒烟验证入口。
32. 创建 `scripts/smoke.sh`,固定 Unix-like 本地冒烟验证入口。
33. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
34. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
3. 明确内部采用“`crates/*` 统一承载主工程 crate 与独立模块 crate”的多 crate 组织方式。
4. 创建 `crates/api-server/` 目录占位,固定 Axum 主工程 crate 落位。
5. 创建 `crates/spacetime-module/` 目录占位,固定 SpacetimeDB 主工程 crate 落位。
6. 创建 `crates/module-auth/` 目录占位,固定鉴权模块 crate 落位。
7. 创建 `crates/module-runtime/` 目录占位,固定运行时状态基座模块 crate 落位。
8. 创建 `crates/module-story/` 目录占位,固定故事主循环模块 crate 落位。
9. 创建 `crates/module-combat/` 目录占位,固定战斗规则模块 crate 落位。
10. 创建 `crates/module-inventory/` 目录占位,固定背包与物品变更模块 crate 落位。
11. 创建 `crates/module-npc/` 目录占位,固定 NPC 状态与互动模块 crate 落位。
12. 创建 `crates/module-progression/` 目录占位,固定成长与章节推进模块 crate 落位。
13. 创建 `crates/module-quest/` 目录占位,固定任务运行时模块 crate 落位。
14. 创建 `crates/module-runtime-item/` 目录占位,固定运行时物品模块 crate 落位。
15. 创建 `crates/module-custom-world/` 目录占位,固定自定义世界与 agent 模块 crate 落位。
16. 创建 `crates/module-assets/` 目录占位,固定资产任务与对象绑定模块 crate 落位。
17. 创建 `crates/module-ai/` 目录占位,固定 AI 编排模块 crate 落位。
18. 创建 `crates/shared-contracts/` 目录占位,固定前后端兼容 contract 共享 crate 落位。
19. 创建 `crates/shared-kernel/` 目录占位,固定跨模块共享领域内核 crate 落位。
20. 创建 `crates/shared-logging/` 目录占位,固定工作区统一日志 crate 落位。
21. 创建 `crates/platform-auth/` 目录占位,固定鉴权平台适配 crate 落位。
22. 创建 `crates/platform-oss/` 目录占位,固定 OSS 平台适配 crate 落位。
23. 创建 `crates/platform-llm/` 目录占位,固定大模型平台适配 crate 落位。
24. 创建 `crates/spacetime-client/` 目录占位,固定 SpacetimeDB 客户端适配 crate 落位。
25. 创建 `crates/tests-support/` 目录占位,固定测试支撑共享 crate 落位
26. 创建 `scripts/dev.ps1`,固定 Windows 本地开发入口。
27. 创建 `scripts/dev.sh`,固定 Unix-like 本地开发入口。
28. 创建 `scripts/test.ps1`,固定 Windows 本地测试入口。
29. 创建 `scripts/test.sh`,固定 Unix-like 本地测试入口。
30. 创建 `scripts/check.ps1`,固定 Windows 本地统一检查入口。
31. 创建 `scripts/check.sh`,固定 Unix-like 本地统一检查入口。
32. 创建 `scripts/smoke.ps1`,固定 Windows 本地冒烟验证入口。
33. 创建 `scripts/smoke.sh`,固定 Unix-like 本地冒烟验证入口。
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
后续任务会继续在本目录内按顺序补齐:
1. `apps/spacetime-module` 的表、reducer、view 聚合入口
1. `crates/spacetime-module` 的表、reducer、view 聚合入口
2. `module-auth` 的身份表、JWT 与 refresh cookie 主链
## 3. 已冻结边界
@@ -62,11 +63,27 @@
1. 迁移期保留 `server-node/`,不提前删除。
2. 前端在 `M0 ~ M6` 期间只访问 Axum不直连 SpacetimeDB。
3. 外部副作用统一收口在 Axum / package 内应用层 / infra。
4. `apps/api-server` 只组合与暴露协议,不直接吞并业务模块实现。
5. `apps/spacetime-module` 只负责汇总各模块 package 的表、reducer、view。
3. 外部副作用统一收口在 Axum / crate 内应用层 / infra。
4. `crates/api-server` 只组合与暴露协议,不直接吞并业务模块实现。
5. `crates/spacetime-module` 只负责汇总各模块 crate 的表、reducer、view。
## 4. 关联文档
## 4. SpacetimeDB 实施约束
凡是涉及 `SpacetimeDB` 的工程修改、脚本执行、接口接入与前端绑定,统一要求显式使用以下 skill
1. [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md)
2. [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md)
3. [$spacetimedb-concepts](.codex\\skills\\spacetimedb-concepts\\SKILL.md)
4. [$spacetimedb-typescript](.codex\\skills\\spacetimedb-typescript\\SKILL.md)
执行口径:
1. `spacetime` CLI、发布、绑定生成、本地联调按 `spacetimedb-cli` 执行。
2. `crates/spacetime-module` 的 Rust 表、reducer、view 与模块 API 按 `spacetimedb-rust``spacetimedb-concepts` 执行。
3. 前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用按 `spacetimedb-typescript``spacetimedb-concepts` 执行。
4. 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。
## 5. 关联文档
1. [../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](../docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
2. [../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md](../backend-rewrite-tasklist/M0_REPOSITORY_BOUNDARY_DECISIONS_2026-04-20.md)

View File

@@ -1,52 +0,0 @@
use std::{env, net::SocketAddr};
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") {
if !bind_host.trim().is_empty() {
config.bind_host = bind_host;
}
}
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") {
if let Ok(parsed_port) = bind_port.parse::<u16>() {
config.bind_port = parsed_port;
}
}
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") {
if !log_filter.trim().is_empty() {
config.log_filter = log_filter;
}
}
config
}
pub fn bind_socket_addr(&self) -> SocketAddr {
let address = format!("{}:{}", self.bind_host, self.bind_port);
address
.parse()
.unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000)))
}
}

View File

@@ -1,19 +0,0 @@
use std::io;
use tracing_subscriber::{fmt, EnvFilter};
use crate::config::AppConfig;
// 统一在独立模块初始化 tracing避免入口层和后续测试入口重复散落 subscriber 配置。
pub fn init_tracing(config: &AppConfig) -> Result<(), io::Error> {
let env_filter = EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(config.log_filter.as_str()))
.unwrap_or_else(|_| EnvFilter::new("info"));
fmt()
.with_env_filter(env_filter)
.with_target(true)
.compact()
.try_init()
.map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}")))
}

View File

@@ -1,15 +0,0 @@
use crate::config::AppConfig;
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
pub config: AppConfig,
}
impl AppState {
pub fn new(config: AppConfig) -> Self {
Self { config }
}
}

View File

@@ -6,13 +6,14 @@ license.workspace = true
[dependencies]
axum = "0.8"
platform-auth = { path = "../platform-auth" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-logging = { path = "../shared-logging" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
time = { version = "0.3", features = ["formatting"] }
tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]

View File

@@ -1,10 +1,10 @@
# api-server 主工程 package 占位说明
# api-server 主工程 crate 占位说明
日期:`2026-04-20`
## 1. package 职责
## 1. crate 职责
`api-server` 是新后端的 Axum 主工程 package后续负责
`api-server` 是新后端的 Axum 主工程 crate后续负责
1. `main.rs` 启动入口
2. `Router` 装配
@@ -26,10 +26,10 @@
4. `src/app.rs`
5. `src/state.rs`
6. `src/config.rs`
7. `src/logging.rs`
8. 基础 `TraceLayer` 挂载与 `tracing subscriber` 初始化
7. 基础 `TraceLayer` 挂载
8. 接入 `shared-logging` 完成 `tracing subscriber` 初始化
后续与本 package 直接相关的任务包括:
后续与本 crate 直接相关的任务包括:
1. [x] 接入统一日志与 tracing
2. [x] 接入 `request_id`
@@ -39,7 +39,7 @@
当前 tracing 约定:
1. 进程启动时统一初始化 `tracing subscriber`
1. 进程启动时通过 `shared-logging` 统一初始化 `tracing subscriber`
2. 默认日志过滤器来自 `GENARRATIVE_API_LOG`,未提供时回落到 `info,tower_http=info`
3. HTTP 访问日志统一通过 Axum 路由层的 `TraceLayer` 输出,后续 `request_id`、响应头与错误中间件继续在同一层扩展。
@@ -78,9 +78,9 @@
当前本地检查链路约定:
1. `../../scripts/check.ps1``../../scripts/check.sh` 统一串联 `cargo fmt --all --check``cargo clippy``cargo check``cargo test`
2. 默认检查整个 `server-rs` workspace确保后续多 package 扩容时仍然保持统一口径。
3. 当只需聚焦单个 package 时,可通过 `-Package``SERVER_RS_CHECK_PACKAGE` 收窄 `clippy / check / test` 目标。
4. `cargo fmt --all --check` 仍固定覆盖整个 workspace避免多 package 下格式基线漂移。
2. 默认检查整个 `server-rs` workspace确保后续多 crate 扩容时仍然保持统一口径。
3. 当只需聚焦单个 crate 时,可通过 `-Package``SERVER_RS_CHECK_PACKAGE` 收窄 `clippy / check / test` 目标。
4. `cargo fmt --all --check` 仍固定覆盖整个 workspace避免多 crate 下格式基线漂移。
当前本地 smoke 链路约定:
@@ -91,6 +91,6 @@
## 3. 边界约束
1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。
2. 业务逻辑优先通过独立模块 package 暴露能力,再由主工程组合。
3. 外部副作用通过 `platform-auth``platform-oss``platform-llm` 与各模块 package 的应用层完成。
2. 业务逻辑优先通过独立模块 crate 暴露能力,再由主工程组合。
3. 外部副作用通过 `platform-auth``platform-oss``platform-llm` 与各模块 crate 的应用层完成。
4. 不把领域规则直接堆在 handler 中。

View File

@@ -1,7 +1,7 @@
use axum::Json;
use serde::Serialize;
use serde_json::{json, Value};
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
use serde_json::{Value, json};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{http_error::ApiErrorPayload, request_context::RequestContext};

View File

@@ -1,8 +1,9 @@
use axum::{body::Body, extract::Extension, http::Request, middleware, routing::get, Router};
use axum::{Router, body::Body, extract::Extension, http::Request, middleware, routing::get};
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tracing::{info_span, Level};
use tracing::{Level, info_span};
use crate::{
auth::{inspect_auth_claims, require_bearer_auth},
error_middleware::normalize_error_response,
health::health_check,
request_context::{attach_request_context, resolve_request_id},
@@ -19,6 +20,13 @@ pub fn build_router(state: AppState) -> Router {
health_check(Extension(request_context)).await
}),
)
.route(
"/_internal/auth/claims",
get(inspect_auth_claims).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
@@ -53,7 +61,11 @@ mod tests {
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use serde_json::Value;
use time::OffsetDateTime;
use tower::ServiceExt;
use crate::{config::AppConfig, state::AppState};
@@ -62,7 +74,7 @@ mod tests {
#[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()));
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
@@ -117,7 +129,7 @@ mod tests {
#[tokio::test]
async fn healthz_returns_standard_envelope_when_requested() {
let app = build_router(AppState::new(AppConfig::default()));
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
@@ -152,4 +164,79 @@ mod tests {
Value::String("req-health-envelope".to_string())
);
}
#[tokio::test]
async fn internal_auth_claims_rejects_missing_bearer_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/claims")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn internal_auth_claims_returns_verified_claims() {
let config = AppConfig::default();
let state = AppState::new(config.clone()).expect("state should build");
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "usr_auth_debug".to_string(),
session_id: "sess_auth_debug".to_string(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 7,
phone_verified: true,
binding_status: BindingStatus::Active,
display_name: Some("测试用户".to_string()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect("claims should build");
let token = sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign");
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/claims")
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["claims"]["sub"],
Value::String("usr_auth_debug".to_string())
);
assert_eq!(
payload["claims"]["sid"],
Value::String("sess_auth_debug".to_string())
);
assert_eq!(
payload["claims"]["ver"],
Value::Number(serde_json::Number::from(7))
);
}
}

View File

@@ -0,0 +1,119 @@
use axum::{
Json,
extract::{Extension, Request, State},
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
middleware::Next,
response::Response,
};
use platform_auth::{AccessTokenClaims, verify_access_token};
use serde_json::{Value, json};
use tracing::warn;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
state::AppState,
};
// 统一把已校验的 claims 写入 request extensions避免后续 handler 再次重复解析 Bearer token。
#[derive(Clone, Debug)]
pub struct AuthenticatedAccessToken {
claims: AccessTokenClaims,
}
impl AuthenticatedAccessToken {
pub fn new(claims: AccessTokenClaims) -> Self {
Self { claims }
}
pub fn claims(&self) -> &AccessTokenClaims {
&self.claims
}
}
pub async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let bearer_token = extract_bearer_token(request.headers())?;
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| {
warn!(
%request_id,
error = %error,
"Bearer JWT 校验失败"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
request
.extensions_mut()
.insert(AuthenticatedAccessToken::new(claims));
Ok(next.run(request).await)
}
pub async fn inspect_auth_claims(
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Json<Value> {
json_success_body(
Some(&request_context),
json!({
"claims": authenticated.claims(),
}),
)
}
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
let token = authorization
.strip_prefix("Bearer ")
.or_else(|| authorization.strip_prefix("bearer "))
.map(str::trim)
.filter(|token| !token.is_empty())
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
Ok(token.to_string())
}
#[cfg(test)]
mod tests {
use super::extract_bearer_token;
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
response::IntoResponse,
};
#[test]
fn extract_bearer_token_accepts_standard_header() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer token-value"),
);
let token = extract_bearer_token(&headers).expect("bearer token should be extracted");
assert_eq!(token, "token-value");
}
#[test]
fn extract_bearer_token_rejects_missing_scheme() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic abc"));
let error = extract_bearer_token(&headers).expect_err("basic auth should be rejected");
assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED);
}
}

View File

@@ -0,0 +1,123 @@
use std::{env, net::SocketAddr};
// 集中管理 api-server 的启动配置,避免入口层直接散落环境变量解析逻辑。
#[derive(Clone, Debug)]
pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
pub jwt_issuer: String,
pub jwt_secret: String,
pub jwt_access_token_ttl_seconds: u64,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
jwt_issuer: "https://auth.genarrative.local".to_string(),
jwt_secret: "genarrative-dev-secret".to_string(),
jwt_access_token_ttl_seconds: 2 * 60 * 60,
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(bind_host) = env::var("GENARRATIVE_API_HOST") {
if !bind_host.trim().is_empty() {
config.bind_host = bind_host;
}
}
if let Ok(bind_port) = env::var("GENARRATIVE_API_PORT") {
if let Ok(parsed_port) = bind_port.parse::<u16>() {
config.bind_port = parsed_port;
}
}
if let Ok(log_filter) = env::var("GENARRATIVE_API_LOG") {
if !log_filter.trim().is_empty() {
config.log_filter = log_filter;
}
}
if let Some(jwt_issuer) =
read_first_non_empty_env(&["GENARRATIVE_JWT_ISSUER", "JWT_ISSUER"])
{
config.jwt_issuer = jwt_issuer;
}
if let Some(jwt_secret) =
read_first_non_empty_env(&["GENARRATIVE_JWT_SECRET", "JWT_SECRET"])
{
config.jwt_secret = jwt_secret;
}
if let Some(ttl_seconds) = read_first_duration_seconds_env(&[
"GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS",
"JWT_EXPIRES_IN",
]) {
config.jwt_access_token_ttl_seconds = ttl_seconds;
}
config
}
pub fn bind_socket_addr(&self) -> SocketAddr {
let address = format!("{}:{}", self.bind_host, self.bind_port);
address
.parse()
.unwrap_or_else(|_| SocketAddr::from(([127, 0, 0, 1], 3000)))
}
}
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
env::var(key).ok().and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() {
return None;
}
Some(value)
})
})
}
fn read_first_duration_seconds_env(keys: &[&str]) -> Option<u64> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_duration_seconds(&value))
})
}
fn parse_duration_seconds(raw: &str) -> Option<u64> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
if let Ok(seconds) = raw.parse::<u64>() {
return Some(seconds);
}
let (number, unit) = raw.split_at(raw.len().checked_sub(1)?);
let unit = unit.to_ascii_lowercase();
let number = number.trim().parse::<u64>().ok()?;
let multiplier = match unit.as_str() {
"s" => 1,
"m" => 60,
"h" => 60 * 60,
"d" => 24 * 60 * 60,
_ => return None,
};
number.checked_mul(multiplier)
}

View File

@@ -3,7 +3,7 @@ use tracing::{error, warn};
use crate::{
http_error::AppError,
request_context::{resolve_request_id, RequestContext},
request_context::{RequestContext, resolve_request_id},
};
pub async fn normalize_error_response(request: Request, next: Next) -> Response {

View File

@@ -1,5 +1,5 @@
use axum::{extract::Extension, Json};
use serde_json::{json, Value};
use axum::{Json, extract::Extension};
use serde_json::{Value, json};
use crate::{api_response::json_success_body, request_context::RequestContext};

View File

@@ -1,29 +1,31 @@
mod api_response;
mod app;
mod auth;
mod config;
mod error_middleware;
mod health;
mod http_error;
mod logging;
mod request_context;
mod response_headers;
mod state;
use shared_logging::init_tracing;
use tokio::net::TcpListener;
use tracing::info;
use crate::{app::build_router, config::AppConfig, logging::init_tracing, state::AppState};
use crate::{app::build_router, config::AppConfig, state::AppState};
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env();
init_tracing(&config)?;
init_tracing(&config.log_filter)?;
let bind_address = config.bind_socket_addr();
let listener = TcpListener::bind(bind_address).await?;
let state = AppState::new(config);
let state = AppState::new(config)
.map_err(|error| std::io::Error::other(format!("初始化鉴权配置失败:{error}")))?;
let router = build_router(state);
info!(%bind_address, "api-server 已完成 tracing 初始化并开始监听");

View File

@@ -2,7 +2,7 @@ use std::time::{Duration, Instant};
use axum::{
extract::Request,
http::{header::HeaderName, HeaderValue, Request as HttpRequest},
http::{HeaderValue, Request as HttpRequest, header::HeaderName},
middleware::Next,
response::Response,
};

View File

@@ -1,13 +1,13 @@
use axum::{
extract::Request,
http::{header::HeaderName, HeaderValue},
http::{HeaderValue, header::HeaderName},
middleware::Next,
response::Response,
};
use crate::{
api_response::API_VERSION,
request_context::{resolve_request_id, RequestContext, X_REQUEST_ID_HEADER},
request_context::{RequestContext, X_REQUEST_ID_HEADER, resolve_request_id},
};
pub const API_VERSION_HEADER: &str = "x-api-version";

View File

@@ -0,0 +1,31 @@
use platform_auth::{JwtConfig, JwtError};
use crate::config::AppConfig;
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
// 配置会在后续中间件、路由和平台适配接入时逐步消费。
#[allow(dead_code)]
pub config: AppConfig,
auth_jwt_config: JwtConfig,
}
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, JwtError> {
let auth_jwt_config = JwtConfig::new(
config.jwt_issuer.clone(),
config.jwt_secret.clone(),
config.jwt_access_token_ttl_seconds,
)?;
Ok(Self {
config,
auth_jwt_config,
})
}
pub fn auth_jwt_config(&self) -> &JwtConfig {
&self.auth_jwt_config
}
}

View File

@@ -0,0 +1,40 @@
# module-auth 独立模块 crate 占位说明
日期:`2026-04-20`
## 1. crate 职责
`module-auth` 是鉴权与会话模块 crate后续负责
1. 用户身份、会话、风控、审计相关领域模型
2. 手机验证码、微信登录、密码登录的模块内用例编排
3.`crates/api-server` 的鉴权接口装配对接
4.`crates/spacetime-module` 的身份表、会话表聚合对接
## 2. 当前阶段说明
当前阶段已冻结前七张鉴权基础表设计,剩余重点收口在 JWT claims、refresh cookie 与旧接口兼容细节。
后续与本 crate 直接相关的任务包括:
1. 设计 `user_account``auth_identity``refresh_session`
2. 设计 `auth_audit_log``auth_risk_block`
3. 设计 `sms_auth_event``wechat_auth_state`
4. 落地 JWT claims、refresh cookie 与旧接口兼容
当前已冻结文档:
1. [../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
3. [../../../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)
4. [../../../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_AUDIT_LOG_TABLE_DESIGN_2026-04-21.md)
5. [../../../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_RISK_BLOCK_TABLE_DESIGN_2026-04-21.md)
6. [../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_SMS_AUTH_EVENT_TABLE_DESIGN_2026-04-21.md)
7. [../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_WECHAT_AUTH_STATE_TABLE_DESIGN_2026-04-21.md)
8. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
## 3. 边界约束
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
2. 短信、微信、JWT、Cookie 等平台适配优先通过 `crates/platform-auth` 承接。
3. 身份与会话状态最终由 `crates/spacetime-module` 聚合,前端接口由 `crates/api-server` 暴露。

View File

@@ -0,0 +1,10 @@
[package]
name = "platform-auth"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
time = { version = "0.3", features = ["std"] }

View File

@@ -0,0 +1,71 @@
# platform-auth 鉴权平台适配 crate 说明
日期:`2026-04-21`
## 1. crate 职责
`platform-auth` 是 Rust 工作区中的鉴权平台适配 crate当前与后续负责
1. Access token JWT 的 claims 结构、签发与校验适配。
2. refresh cookie 的读写、签名与轮换适配。
3. 手机验证码发送、校验与外部 provider 适配。
4. 微信 OAuth start / callback 的平台调用适配。
5.`module-auth``crates/api-server` 复用的鉴权基础设施能力。
## 2. 当前阶段已落地内容
本阶段已经完成 JWT 基础能力首版落地:
1. 新增 `JwtConfig`,统一管理 `issuer``secret` 与 access token TTL。
2. 新增 `AccessTokenClaimsInput``AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。
3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。
4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。
5. 增加单元测试,覆盖基本签发/校验、issuer 不匹配与空角色拒绝。
当前阶段仍未进入:
1. refresh cookie 读写与轮换。
2. 短信 provider 适配。
3. 微信 OAuth 适配。
4. `module-auth` 领域规则与数据库真相读取。
## 3. 本阶段 API
当前开放给工作区其它 crate 的最小 API
1. `JwtConfig::new(...)`
2. `AccessTokenClaims::from_input(...)`
3. `sign_access_token(...)`
4. `verify_access_token(...)`
5. `AuthProvider`
6. `BindingStatus`
## 4. 配置口径
当前 `api-server` 接入时采用以下环境变量口径:
1. `GENARRATIVE_JWT_ISSUER`
默认值:`https://auth.genarrative.local`
2. `GENARRATIVE_JWT_SECRET`
默认值:`genarrative-dev-secret`
3. `GENARRATIVE_JWT_ACCESS_TOKEN_TTL_SECONDS`
默认值:`7200`
4. 兼容读取旧变量:`JWT_ISSUER``JWT_SECRET``JWT_EXPIRES_IN`
说明:
1. `JWT_EXPIRES_IN` 当前兼容 `2h``30m``900` 这类简单时长格式。
2. 当前阶段保持 `HS256`,优先保证与旧 Node 方案迁移平滑。
## 5. 边界约束
1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。
2. `sub` 必须是稳定 `user_id``sid` 必须是会话 ID不能退化为一次 token 的随机 ID。
3. 不允许把手机号、openid、refresh token hash、风控状态等敏感或高频变化字段塞进 JWT。
4. 鉴权状态最终由 `module-auth``crates/spacetime-module` 管理,前端接口由 `crates/api-server` 暴露。
5. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。
## 6. 关联文档
1. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
2. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)

View File

@@ -0,0 +1,377 @@
use std::{collections::HashSet, error::Error, fmt};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind,
};
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthProvider {
Password,
Phone,
Wechat,
}
// 绑定状态只保留当前 JWT 需要透传的最小快照,不把完整账号状态枚举直接泄漏到 token 中。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BindingStatus {
Active,
PendingBindPhone,
}
// 用于签发 access token 的领域输入,和最终 JWT claims 解耦,避免业务层手动拼 iat/exp/iss。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AccessTokenClaimsInput {
pub user_id: String,
pub session_id: String,
pub provider: AuthProvider,
pub roles: Vec<String>,
pub token_version: u64,
pub phone_verified: bool,
pub binding_status: BindingStatus,
pub display_name: Option<String>,
}
// 直接映射最终 JWT payload字段名与文档冻结口径保持一致。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AccessTokenClaims {
pub iss: String,
pub sub: String,
pub sid: String,
pub provider: AuthProvider,
pub roles: Vec<String>,
pub ver: u64,
pub phone_verified: bool,
pub binding_status: BindingStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub iat: u64,
pub exp: u64,
}
// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JwtConfig {
issuer: String,
secret: String,
access_token_ttl_seconds: u64,
}
#[derive(Debug, PartialEq, Eq)]
pub enum JwtError {
InvalidConfig(&'static str),
InvalidClaims(&'static str),
SignFailed(String),
VerifyFailed(String),
}
impl JwtConfig {
pub fn new(
issuer: String,
secret: String,
access_token_ttl_seconds: u64,
) -> Result<Self, JwtError> {
let issuer = issuer.trim().to_string();
let secret = secret.trim().to_string();
if issuer.is_empty() {
return Err(JwtError::InvalidConfig("JWT issuer 不能为空"));
}
if secret.is_empty() {
return Err(JwtError::InvalidConfig("JWT secret 不能为空"));
}
if access_token_ttl_seconds == 0 {
return Err(JwtError::InvalidConfig(
"JWT access token 过期时间必须大于 0",
));
}
Ok(Self {
issuer,
secret,
access_token_ttl_seconds,
})
}
pub fn issuer(&self) -> &str {
&self.issuer
}
pub fn access_token_ttl_seconds(&self) -> u64 {
self.access_token_ttl_seconds
}
}
impl AccessTokenClaims {
pub fn from_input(
input: AccessTokenClaimsInput,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
let user_id = normalize_required_field(input.user_id, "JWT sub 不能为空")?;
let session_id = normalize_required_field(input.session_id, "JWT sid 不能为空")?;
let roles = normalize_roles(input.roles)?;
let display_name = normalize_optional_field(input.display_name);
let issued_at_unix = issued_at.unix_timestamp();
if issued_at_unix < 0 {
return Err(JwtError::InvalidClaims("JWT iat 不能早于 Unix epoch"));
}
let expires_at = issued_at
.checked_add(Duration::seconds(
i64::try_from(config.access_token_ttl_seconds()).map_err(|_| {
JwtError::InvalidConfig("JWT access token 过期时间超出 i64 上限")
})?,
))
.ok_or(JwtError::InvalidConfig("JWT 过期时间计算溢出"))?;
let expires_at_unix = expires_at.unix_timestamp();
if expires_at_unix <= issued_at_unix {
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
}
let claims = Self {
iss: config.issuer().to_string(),
sub: user_id,
sid: session_id,
provider: input.provider,
roles,
ver: input.token_version,
phone_verified: input.phone_verified,
binding_status: input.binding_status,
display_name,
iat: issued_at_unix as u64,
exp: expires_at_unix as u64,
};
claims.validate_for_config(config)?;
Ok(claims)
}
pub fn user_id(&self) -> &str {
&self.sub
}
pub fn session_id(&self) -> &str {
&self.sid
}
pub fn token_version(&self) -> u64 {
self.ver
}
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
if self.iss.trim() != config.issuer() {
return Err(JwtError::InvalidClaims("JWT iss 与当前配置不一致"));
}
normalize_required_field(self.sub.clone(), "JWT sub 不能为空")?;
normalize_required_field(self.sid.clone(), "JWT sid 不能为空")?;
normalize_roles(self.roles.clone())?;
if self.exp <= self.iat {
return Err(JwtError::InvalidClaims("JWT exp 必须晚于 iat"));
}
Ok(())
}
}
pub fn sign_access_token(
claims: &AccessTokenClaims,
config: &JwtConfig,
) -> Result<String, JwtError> {
claims.validate_for_config(config)?;
let header = Header {
alg: ACCESS_TOKEN_ALGORITHM,
typ: Some("JWT".to_string()),
..Header::default()
};
encode(
&header,
claims,
&EncodingKey::from_secret(config.secret.as_bytes()),
)
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
}
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
let token = token.trim();
if token.is_empty() {
return Err(JwtError::VerifyFailed("JWT 不能为空".to_string()));
}
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
validation.required_spec_claims = HashSet::from([
"exp".to_string(),
"iat".to_string(),
"iss".to_string(),
"sub".to_string(),
]);
validation.set_issuer(&[config.issuer()]);
let decoded = decode::<AccessTokenClaims>(
token,
&DecodingKey::from_secret(config.secret.as_bytes()),
&validation,
)
.map_err(map_verify_error)?;
decoded.claims.validate_for_config(config)?;
Ok(decoded.claims)
}
impl fmt::Display for JwtError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message) | Self::InvalidClaims(message) => f.write_str(message),
Self::SignFailed(message) | Self::VerifyFailed(message) => f.write_str(message),
}
}
}
impl Error for JwtError {}
fn normalize_required_field(
value: String,
error_message: &'static str,
) -> Result<String, JwtError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(JwtError::InvalidClaims(error_message));
}
Ok(value)
}
fn normalize_optional_field(value: Option<String>) -> Option<String> {
value.and_then(|field| {
let field = field.trim().to_string();
if field.is_empty() {
return None;
}
Some(field)
})
}
fn normalize_roles(roles: Vec<String>) -> Result<Vec<String>, JwtError> {
let roles = roles
.into_iter()
.map(|role| role.trim().to_string())
.filter(|role| !role.is_empty())
.collect::<Vec<_>>();
if roles.is_empty() {
return Err(JwtError::InvalidClaims("JWT roles 至少包含一个角色"));
}
Ok(roles)
}
fn map_verify_error(error: jsonwebtoken::errors::Error) -> JwtError {
let message = match error.kind() {
ErrorKind::ExpiredSignature => "JWT 已过期".to_string(),
ErrorKind::InvalidIssuer => "JWT 发行者不匹配".to_string(),
ErrorKind::InvalidSignature => "JWT 签名无效".to_string(),
ErrorKind::InvalidAlgorithm => "JWT 算法不匹配".to_string(),
ErrorKind::InvalidToken => "JWT 非法".to_string(),
ErrorKind::ImmatureSignature => "JWT 尚未生效".to_string(),
ErrorKind::MissingRequiredClaim(claim) => format!("JWT 缺少必填字段:{claim}"),
_ => format!("JWT 校验失败:{error}"),
};
JwtError::VerifyFailed(message)
}
#[cfg(test)]
mod tests {
use super::*;
fn build_jwt_config() -> JwtConfig {
JwtConfig::new(
"https://auth.genarrative.local".to_string(),
"genarrative-dev-secret".to_string(),
DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
)
.expect("jwt config should be valid")
}
fn build_claims_input() -> AccessTokenClaimsInput {
AccessTokenClaimsInput {
user_id: "usr_123".to_string(),
session_id: "sess_456".to_string(),
provider: AuthProvider::Wechat,
roles: vec!["user".to_string()],
token_version: 3,
phone_verified: false,
binding_status: BindingStatus::PendingBindPhone,
display_name: Some("微信旅人".to_string()),
}
}
#[test]
fn round_trip_sign_and_verify_access_token() {
let config = build_jwt_config();
let claims =
AccessTokenClaims::from_input(build_claims_input(), &config, OffsetDateTime::now_utc())
.expect("claims should build");
let token = sign_access_token(&claims, &config).expect("token should sign");
let verified = verify_access_token(&token, &config).expect("token should verify");
assert_eq!(verified, claims);
assert_eq!(verified.user_id(), "usr_123");
assert_eq!(verified.session_id(), "sess_456");
assert_eq!(verified.token_version(), 3);
}
#[test]
fn verify_rejects_invalid_issuer() {
let config = build_jwt_config();
let claims =
AccessTokenClaims::from_input(build_claims_input(), &config, OffsetDateTime::now_utc())
.expect("claims should build");
let token = sign_access_token(&claims, &config).expect("token should sign");
let other_config = JwtConfig::new(
"https://auth.other.local".to_string(),
"genarrative-dev-secret".to_string(),
DEFAULT_ACCESS_TOKEN_TTL_SECONDS,
)
.expect("other config should be valid");
let error = verify_access_token(&token, &other_config).expect_err("issuer should mismatch");
assert_eq!(
error,
JwtError::VerifyFailed("JWT 发行者不匹配".to_string())
);
}
#[test]
fn build_claims_rejects_empty_roles() {
let error = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
roles: Vec::new(),
..build_claims_input()
},
&build_jwt_config(),
OffsetDateTime::now_utc(),
)
.expect_err("empty roles should be rejected");
assert_eq!(error, JwtError::InvalidClaims("JWT roles 至少包含一个角色"));
}
}

View File

@@ -1,10 +1,10 @@
# shared-contracts 共享 package 占位说明
# shared-contracts 共享 crate 占位说明
日期:`2026-04-20`
## 1. package 职责
## 1. crate 职责
`shared-contracts` 是前后端兼容 contract 共享 package后续负责
`shared-contracts` 是前后端兼容 contract 共享 crate后续负责
1. HTTP 请求与响应 DTO
2. SSE 事件结构与事件名约定
@@ -15,7 +15,7 @@
当前提交仅完成目录占位,不提前进入 DTO、事件与兼容结构实现。
后续与本 package 直接相关的任务包括:
后续与本 crate 直接相关的任务包括:
1. 对齐现有前端直接依赖的响应头与 envelope
2. 对齐 story、custom world、chat 等 SSE 事件结构
@@ -25,5 +25,5 @@
## 3. 边界约束
1. `shared-contracts` 只放协议类型与兼容结构,不承接业务规则、供应商适配或状态写入逻辑。
2. 各模块 package 对外暴露的协议优先复用这里的共享定义,避免重复散落。
3. 前端兼容契约一旦进入本 package就必须与任务清单和基线文档同步维护。
2. 各模块 crate 对外暴露的协议优先复用这里的共享定义,避免重复散落。
3. 前端兼容契约一旦进入本 crate就必须与任务清单和基线文档同步维护。

View File

@@ -1,10 +1,10 @@
# shared-kernel 共享 package 占位说明
# shared-kernel 共享 crate 占位说明
日期:`2026-04-20`
## 1. package 职责
## 1. crate 职责
`shared-kernel` 是跨模块共享领域内核 package后续负责
`shared-kernel` 是跨模块共享领域内核 crate后续负责
1. 共享 ID、值对象、枚举与基础领域类型
2. 共享时间、状态、版本、通用校验等基础规则
@@ -14,7 +14,7 @@
当前提交仅完成目录占位,不提前进入具体共享类型与基础规则实现。
后续与本 package 直接相关的任务包括:
后续与本 crate 直接相关的任务包括:
1. 统一用户、会话、世界、角色、资产等核心 ID 类型
2. 统一时间戳、版本号、状态枚举等共享结构
@@ -24,5 +24,5 @@
## 3. 边界约束
1. `shared-kernel` 只放跨模块最小共享内核,不承接具体业务模块的私有规则。
2. 任何进入本 package 的类型都必须证明至少被多个模块稳定复用。
2. 任何进入本 crate 的类型都必须证明至少被多个模块稳定复用。
3. 不能把主模块实现重新堆进共享内核,避免形成新的“大公共垃圾桶”。

View File

@@ -0,0 +1,8 @@
[package]
name = "shared-logging"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }

View File

@@ -0,0 +1,32 @@
# shared-logging 共享日志 crate 占位说明
日期:`2026-04-21`
## 1. crate 职责
`shared-logging` 是 Rust 工作区统一日志基础设施 crate后续负责
1. 统一 `tracing subscriber` 初始化入口
2. 统一日志过滤器解析逻辑
3. 统一当前阶段日志输出风格
4.`api-server`、后续 `spacetime-module`、测试支撑与独立入口提供可复用日志初始化能力
## 2. 当前阶段说明
当前阶段已完成最小落地:
1. 提供 `resolve_env_filter(...)`
2. 提供 `init_tracing(...)`
3.`api-server` 的日志初始化逻辑迁出为共享 crate
后续与本 crate 直接相关的任务包括:
1. 根据环境扩展日志输出格式
2. 补充测试入口、worker 入口与其它主工程 crate 的统一接入
3. 视需要补充 JSON 输出、链路追踪或远端采集适配
## 3. 边界约束
1. `shared-logging` 只承接日志初始化与基础设施,不承接 HTTP 业务语义。
2. `TraceLayer`、request id、响应头、错误 envelope 等 HTTP 逻辑继续留在 `api-server`
3. 不允许把业务埋点、审计事件或供应商日志适配塞进本 crate。

View File

@@ -0,0 +1,22 @@
use std::io;
use tracing_subscriber::{EnvFilter, fmt};
// 统一解析工作区日志过滤器,优先环境变量,其次回落到调用方传入的默认值。
pub fn resolve_env_filter(default_filter: &str) -> EnvFilter {
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_filter))
.unwrap_or_else(|_| EnvFilter::new("info"))
}
// 统一初始化 tracing subscriber避免各入口重复散落相同配置。
pub fn init_tracing(default_filter: &str) -> Result<(), io::Error> {
let env_filter = resolve_env_filter(default_filter);
fmt()
.with_env_filter(env_filter)
.with_target(true)
.compact()
.try_init()
.map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}")))
}

View File

@@ -1,14 +1,14 @@
# spacetime-module 主工程 package 占位说明
# spacetime-module 主工程 crate 占位说明
日期:`2026-04-20`
## 1. package 职责
## 1. crate 职责
`spacetime-module` 是新后端的 SpacetimeDB 主工程 package后续负责
`spacetime-module` 是新后端的 SpacetimeDB 主工程 crate后续负责
1. 聚合各独立模块 package 的表定义
2. 聚合各独立模块 package 的 reducer
3. 聚合各独立模块 package 的 view / 读模型
1. 聚合各独立模块 crate 的表定义
2. 聚合各独立模块 crate 的 reducer
3. 聚合各独立模块 crate 的 view / 读模型
4. 生成可发布的 SpacetimeDB wasm 模块
5. 由 `../../scripts/spacetime-dev.ps1``../../scripts/spacetime-dev.sh` 驱动的本地 standalone 启动链路
@@ -16,22 +16,26 @@
当前阶段仍未进入具体 schema 与 reducer 实现,但已经补齐本地 standalone 启动脚本,先把 SpacetimeDB 进程入口固定下来。
后续与本 package 直接相关的任务包括:
后续与本 crate 直接相关的任务包括:
1. 建立模块聚合入口
2. 设计表、reducer、view 的聚合方式
3. 接入身份 claims 透传
4. 在实体 module scaffold 落地后接入 publish / dev 循环
当前身份透传设计依据:
1. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
当前本地开发脚本约定:
1. `../../scripts/spacetime-dev.ps1``../../scripts/spacetime-dev.sh` 当前固定执行 `spacetime start` 的 standalone 模式。
2. 默认监听 `127.0.0.1:3001`,避免与 `api-server` 默认 `3000` 端口冲突。
3. 本地数据目录固定到 `server-rs/.spacetimedb/local`,避免污染全局 SpacetimeDB 根目录。
4. 当前阶段暂不自动 publish `apps/spacetime-module`,待 module 实体 scaffold 与聚合入口落地后再扩展。
4. 当前阶段暂不自动 publish `crates/spacetime-module`,待 module 实体 scaffold 与聚合入口落地后再扩展。
## 3. 边界约束
1. `spacetime-module` 只聚合状态模型,不直接承接 HTTP、Cookie、Header、OSS、短信、微信、LLM 等外部副作用。
2. 每个业务模块优先在自己的 `packages/module-*` 中定义状态与规则,再由主工程聚合。
2. 每个业务模块优先在自己的 `crates/module-*` 中定义状态与规则,再由主工程聚合。
3. 主工程不重新吞并各模块实现细节,避免回到单大包结构。

View File

@@ -1,20 +1,20 @@
# tests-support 共享 package 占位说明
# tests-support 共享 crate 占位说明
日期:`2026-04-20`
## 1. package 职责
## 1. crate 职责
`tests-support` 是测试支撑共享 package后续负责
`tests-support` 是测试支撑共享 crate后续负责
1. contract、integration、smoke 测试的共享夹具与辅助工具
2. 测试环境配置、测试数据装配与断言工具
3. 供 `apps/api-server``apps/spacetime-module` 与各模块 package 复用的测试基础设施能力
3. 供 `crates/api-server``crates/spacetime-module` 与各模块 crate 复用的测试基础设施能力
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入测试夹具、断言工具与 smoke 支撑实现。
后续与本 package 直接相关的任务包括:
后续与本 crate 直接相关的任务包括:
1. 设计接口测试与 contract 回归共享夹具
2. 设计 reducer / view / projection 测试辅助
@@ -25,4 +25,4 @@
1. `tests-support` 只承接测试支撑能力,不承接业务规则实现。
2. 测试夹具要尽量贴近真实 contract 与真实模块边界,避免重新引入脱离现网的伪环境。
3. 不允许把测试辅助逻辑散落到各模块 package 中重复实现。
3. 不允许把测试辅助逻辑散落到各模块 crate 中重复实现。

View File

@@ -1,34 +0,0 @@
# module-auth 独立模块 package 占位说明
日期:`2026-04-20`
## 1. package 职责
`module-auth` 是鉴权与会话模块 package后续负责
1. 用户身份、会话、风控、审计相关领域模型
2. 手机验证码、微信登录、密码登录的模块内用例编排
3.`apps/api-server` 的鉴权接口装配对接
4.`apps/spacetime-module` 的身份表、会话表聚合对接
## 2. 当前阶段说明
当前阶段已先冻结第一张账号主表 `user_account` 的设计,其余身份表、会话表与 token 细节仍按顺序继续展开。
后续与本 package 直接相关的任务包括:
1. 设计 `user_account``auth_identity``refresh_session`
2. 设计 `auth_audit_log``auth_risk_block`
3. 设计 `sms_auth_event``wechat_auth_state`
4. 落地 JWT claims、refresh cookie 与旧接口兼容
当前已冻结文档:
1. [../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md)
2. [../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md](../../../docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md)
## 3. 边界约束
1. `module-auth` 负责鉴权领域规则与模块级编排,不直接把供应商 SDK 逻辑写进主工程。
2. 短信、微信、JWT、Cookie 等平台适配优先通过 `packages/platform-auth` 承接。
3. 身份与会话状态最终由 `apps/spacetime-module` 聚合,前端接口由 `apps/api-server` 暴露。

View File

@@ -1,30 +0,0 @@
# platform-auth 平台适配 package 占位说明
日期:`2026-04-20`
## 1. package 职责
`platform-auth` 是鉴权平台适配 package后续负责
1. JWT 签发与校验适配
2. refresh cookie 读写与轮换适配
3. 手机验证码发送与校验适配
4. 微信 OAuth 相关平台适配
5.`module-auth``apps/api-server` 复用的鉴权基础设施能力
## 2. 当前阶段说明
当前提交仅完成目录占位,不提前进入 JWT、Cookie、短信与微信平台实现。
后续与本 package 直接相关的任务包括:
1. 落地 JWT claims、签发与校验适配
2. 落地 refresh cookie 读取、写入与轮换适配
3. 落地短信发送、校验与风控适配
4. 落地微信 OAuth start / callback 适配
## 3. 边界约束
1. `platform-auth` 只承接平台适配,不承接 `module-auth` 的业务规则和状态真相。
2. 鉴权状态最终由 `module-auth``apps/spacetime-module` 管理,前端接口由 `apps/api-server` 暴露。
3. 不允许把短信、微信、Cookie、JWT 等外部细节重新散落到多个业务模块中各自实现。

View File

@@ -2,7 +2,8 @@
param(
[Alias("h")]
[switch]$Help,
[string]$Package = ""
[Alias("Package")]
[string]$Crate = ""
)
$ErrorActionPreference = "Stop"
@@ -11,12 +12,12 @@ function Write-Usage {
@(
'Usage:'
' ./server-rs/scripts/check.ps1'
' ./server-rs/scripts/check.ps1 -Package api-server'
' ./server-rs/scripts/check.ps1 -Crate api-server'
''
'Notes:'
' 1. Run cargo fmt --all --check for the whole server-rs workspace'
' 2. Run clippy/check/test for the whole workspace by default'
' 3. Use -Package to target one workspace package for clippy/check/test'
' 3. Use -Crate to target one workspace crate for clippy/check/test'
) -join [Environment]::NewLine
}
@@ -40,7 +41,7 @@ Push-Location $serverRsDir
try {
cargo fmt --all --check --manifest-path $manifestPath
if ([string]::IsNullOrWhiteSpace($Package)) {
if ([string]::IsNullOrWhiteSpace($Crate)) {
Write-Host "[server-rs:check] step: cargo clippy --workspace --all-targets --all-features -D warnings"
cargo clippy --workspace --manifest-path $manifestPath --all-targets --all-features -- -D warnings
@@ -51,15 +52,15 @@ try {
cargo test --workspace --manifest-path $manifestPath
}
else {
Write-Host "[server-rs:check] target package: $Package"
Write-Host "[server-rs:check] step: cargo clippy -p $Package --all-targets --all-features -D warnings"
cargo clippy -p $Package --manifest-path $manifestPath --all-targets --all-features -- -D warnings
Write-Host "[server-rs:check] target crate: $Crate"
Write-Host "[server-rs:check] step: cargo clippy -p $Crate --all-targets --all-features -D warnings"
cargo clippy -p $Crate --manifest-path $manifestPath --all-targets --all-features -- -D warnings
Write-Host "[server-rs:check] step: cargo check -p $Package"
cargo check -p $Package --manifest-path $manifestPath
Write-Host "[server-rs:check] step: cargo check -p $Crate"
cargo check -p $Crate --manifest-path $manifestPath
Write-Host "[server-rs:check] step: cargo test -p $Package"
cargo test -p $Package --manifest-path $manifestPath
Write-Host "[server-rs:check] step: cargo test -p $Crate"
cargo test -p $Crate --manifest-path $manifestPath
}
}
finally {

View File

@@ -8,13 +8,13 @@ usage() {
cat <<'EOF'
用法:
./server-rs/scripts/check.sh
SERVER_RS_CHECK_PACKAGE=api-server ./server-rs/scripts/check.sh
SERVER_RS_CHECK_CRATE=api-server ./server-rs/scripts/check.sh
说明:
1. 先执行整个 `server-rs` workspace 的 `cargo fmt --all --check`
2. 默认继续执行整个 workspace 的 `cargo clippy`、`cargo check`、`cargo test`
3. 可通过 `SERVER_RS_CHECK_PACKAGE` 将 clippy/check/test 收窄到单个 package
4. `cargo fmt --all --check` 始终覆盖整个 workspace避免多 package 下格式口径漂移
3. 可通过 `SERVER_RS_CHECK_CRATE` 将 clippy/check/test 收窄到单个 crate
4. `cargo fmt --all --check` 始终覆盖整个 workspace避免多 crate 下格式口径漂移
EOF
}
@@ -38,16 +38,18 @@ echo "[server-rs:check] 步骤: cargo fmt --all --check"
cd "${SERVER_RS_DIR}"
cargo fmt --all --check --manifest-path "${MANIFEST_PATH}"
if [[ -n "${SERVER_RS_CHECK_PACKAGE:-}" ]]; then
echo "[server-rs:check] 目标 package: ${SERVER_RS_CHECK_PACKAGE}"
echo "[server-rs:check] 步骤: cargo clippy -p ${SERVER_RS_CHECK_PACKAGE} --all-targets --all-features -D warnings"
cargo clippy -p "${SERVER_RS_CHECK_PACKAGE}" --manifest-path "${MANIFEST_PATH}" --all-targets --all-features -- -D warnings
TARGET_CRATE="${SERVER_RS_CHECK_CRATE:-${SERVER_RS_CHECK_PACKAGE:-}}"
echo "[server-rs:check] 步骤: cargo check -p ${SERVER_RS_CHECK_PACKAGE}"
cargo check -p "${SERVER_RS_CHECK_PACKAGE}" --manifest-path "${MANIFEST_PATH}"
if [[ -n "${TARGET_CRATE}" ]]; then
echo "[server-rs:check] 目标 crate: ${TARGET_CRATE}"
echo "[server-rs:check] 步骤: cargo clippy -p ${TARGET_CRATE} --all-targets --all-features -D warnings"
cargo clippy -p "${TARGET_CRATE}" --manifest-path "${MANIFEST_PATH}" --all-targets --all-features -- -D warnings
echo "[server-rs:check] 步骤: cargo test -p ${SERVER_RS_CHECK_PACKAGE}"
cargo test -p "${SERVER_RS_CHECK_PACKAGE}" --manifest-path "${MANIFEST_PATH}"
echo "[server-rs:check] 步骤: cargo check -p ${TARGET_CRATE}"
cargo check -p "${TARGET_CRATE}" --manifest-path "${MANIFEST_PATH}"
echo "[server-rs:check] 步骤: cargo test -p ${TARGET_CRATE}"
cargo test -p "${TARGET_CRATE}" --manifest-path "${MANIFEST_PATH}"
else
echo "[server-rs:check] 步骤: cargo clippy --workspace --all-targets --all-features -D warnings"
cargo clippy --workspace --manifest-path "${MANIFEST_PATH}" --all-targets --all-features -- -D warnings

View File

@@ -34,8 +34,8 @@ if ([string]::IsNullOrWhiteSpace($RootDir)) {
$RootDir = Join-Path $serverRsDir ".spacetimedb\local"
}
if (-not (Test-Path (Join-Path $serverRsDir "apps\spacetime-module\README.md"))) {
throw "Missing server-rs/apps/spacetime-module/README.md, cannot start SpacetimeDB local dev script."
if (-not (Test-Path (Join-Path $serverRsDir "crates\spacetime-module\README.md"))) {
throw "Missing server-rs/crates/spacetime-module/README.md, cannot start SpacetimeDB local dev script."
}
$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue
@@ -56,7 +56,7 @@ Write-Host "[server-rs:spacetime-dev] working dir: $serverRsDir"
Write-Host "[server-rs:spacetime-dev] root dir: $RootDir"
Write-Host "[server-rs:spacetime-dev] listen addr: $listenAddress"
Write-Host "[server-rs:spacetime-dev] mode: standalone"
Write-Host "[server-rs:spacetime-dev] note: module publish is deferred until apps/spacetime-module scaffold lands"
Write-Host "[server-rs:spacetime-dev] note: module publish is deferred until crates/spacetime-module scaffold lands"
Push-Location $serverRsDir
try {

View File

@@ -13,7 +13,7 @@ usage() {
说明:
1. 启动 Genarrative Rust 后端使用的本地 standalone SpacetimeDB
2. 默认把本地数据目录放到 `server-rs/.spacetimedb/local`
3. 当前阶段只负责启动 standalone server暂不自动 publish `apps/spacetime-module`
3. 当前阶段只负责启动 standalone server暂不自动 publish `crates/spacetime-module`
EOF
}
@@ -28,8 +28,8 @@ LISTEN_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}"
PORT="${GENARRATIVE_SPACETIME_PORT:-3001}"
ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SERVER_RS_DIR}/.spacetimedb/local}"
if [[ ! -f "${SERVER_RS_DIR}/apps/spacetime-module/README.md" ]]; then
echo "[server-rs:spacetime-dev] 未找到 apps/spacetime-module/README.md无法启动本地 SpacetimeDB 脚本。" >&2
if [[ ! -f "${SERVER_RS_DIR}/crates/spacetime-module/README.md" ]]; then
echo "[server-rs:spacetime-dev] 未找到 crates/spacetime-module/README.md无法启动本地 SpacetimeDB 脚本。" >&2
exit 1
fi
@@ -46,7 +46,7 @@ echo "[server-rs:spacetime-dev] 工作目录: ${SERVER_RS_DIR}"
echo "[server-rs:spacetime-dev] 数据目录: ${ROOT_DIR}"
echo "[server-rs:spacetime-dev] 监听地址: ${LISTEN_HOST}:${PORT}"
echo "[server-rs:spacetime-dev] 模式: standalone"
echo "[server-rs:spacetime-dev] 说明: 当前阶段暂不自动 publish apps/spacetime-module"
echo "[server-rs:spacetime-dev] 说明: 当前阶段暂不自动 publish crates/spacetime-module"
cd "${SERVER_RS_DIR}"
spacetime --root-dir "${ROOT_DIR}" start --edition standalone --listen-addr "${LISTEN_HOST}:${PORT}"

View File

@@ -2,7 +2,8 @@
param(
[Alias("h")]
[switch]$Help,
[string]$Package = ""
[Alias("Package")]
[string]$Crate = ""
)
$ErrorActionPreference = "Stop"
@@ -11,11 +12,11 @@ function Write-Usage {
@(
'Usage:'
' ./server-rs/scripts/test.ps1'
' ./server-rs/scripts/test.ps1 -Package api-server'
' ./server-rs/scripts/test.ps1 -Crate api-server'
''
'Notes:'
' 1. Run cargo test for the server-rs workspace by default'
' 2. Use -Package to target one workspace package only'
' 2. Use -Crate to target one workspace crate only'
) -join [Environment]::NewLine
}
@@ -36,12 +37,12 @@ Write-Host "[server-rs:test] working dir: $serverRsDir"
Push-Location $serverRsDir
try {
if ([string]::IsNullOrWhiteSpace($Package)) {
if ([string]::IsNullOrWhiteSpace($Crate)) {
cargo test --manifest-path $manifestPath
}
else {
Write-Host "[server-rs:test] target package: $Package"
cargo test -p $Package --manifest-path $manifestPath
Write-Host "[server-rs:test] target crate: $Crate"
cargo test -p $Crate --manifest-path $manifestPath
}
}
finally {

View File

@@ -6,11 +6,11 @@ usage() {
cat <<'EOF'
Usage:
./server-rs/scripts/test.sh
SERVER_RS_TEST_PACKAGE=api-server ./server-rs/scripts/test.sh
SERVER_RS_TEST_CRATE=api-server ./server-rs/scripts/test.sh
Notes:
1. Run cargo test for the server-rs workspace by default
2. Use SERVER_RS_TEST_PACKAGE to target one workspace package only
2. Use SERVER_RS_TEST_CRATE to target one workspace crate only
EOF
}
@@ -32,9 +32,11 @@ echo "[server-rs:test] working dir: ${SERVER_RS_DIR}"
cd "${SERVER_RS_DIR}"
if [[ -n "${SERVER_RS_TEST_PACKAGE:-}" ]]; then
echo "[server-rs:test] target package: ${SERVER_RS_TEST_PACKAGE}"
cargo test -p "${SERVER_RS_TEST_PACKAGE}" --manifest-path "${MANIFEST_PATH}"
TARGET_CRATE="${SERVER_RS_TEST_CRATE:-${SERVER_RS_TEST_PACKAGE:-}}"
if [[ -n "${TARGET_CRATE}" ]]; then
echo "[server-rs:test] target crate: ${TARGET_CRATE}"
cargo test -p "${TARGET_CRATE}" --manifest-path "${MANIFEST_PATH}"
else
cargo test --manifest-path "${MANIFEST_PATH}"
fi