From 51ad2f234e732dfb6b730f0b8d8c95217f838e90 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 18 Jul 2025 13:25:40 -0700 Subject: [PATCH 01/16] Add system table for credentials --- .../locking_tx_datastore/committed_state.rs | 9 +++- .../src/locking_tx_datastore/datastore.rs | 14 ++++-- crates/datastore/src/system_tables.rs | 49 ++++++++++++++++++- crates/sdk/examples/quickstart-chat/main.rs | 12 ++--- 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/crates/datastore/src/locking_tx_datastore/committed_state.rs b/crates/datastore/src/locking_tx_datastore/committed_state.rs index a074d43c124..ed8b15ec1f5 100644 --- a/crates/datastore/src/locking_tx_datastore/committed_state.rs +++ b/crates/datastore/src/locking_tx_datastore/committed_state.rs @@ -6,6 +6,7 @@ use super::{ tx_state::{IndexIdMap, PendingSchemaChange, TxState}, IterByColEqTx, }; +use crate::system_tables::{ST_CONNECTION_CREDENTIALS_ID, ST_CONNECTION_CREDENTIALS_IDX}; use crate::{ db_metrics::DB_METRICS, error::{IndexError, TableError}, @@ -25,7 +26,7 @@ use core::{convert::Infallible, ops::RangeBounds}; use itertools::Itertools; use spacetimedb_data_structures::map::{HashSet, IntMap}; use spacetimedb_lib::{ - db::auth::{StAccess, StTableType}, + db::auth::StTableType, Identity, }; use spacetimedb_primitives::{ColList, ColSet, IndexId, TableId}; @@ -183,7 +184,7 @@ impl CommittedState { table_id, table_name: schema.table_name.clone(), table_type: StTableType::System, - table_access: StAccess::Public, + table_access: schema.table_access, table_primary_key: schema.primary_key.map(Into::into), }; let row = ProductValue::from(row); @@ -272,6 +273,10 @@ impl CommittedState { self.create_table(ST_SCHEDULED_ID, schemas[ST_SCHEDULED_IDX].clone()); self.create_table(ST_ROW_LEVEL_SECURITY_ID, schemas[ST_ROW_LEVEL_SECURITY_IDX].clone()); + self.create_table( + ST_CONNECTION_CREDENTIALS_ID, + schemas[ST_CONNECTION_CREDENTIALS_IDX].clone(), + ); // IMPORTANT: It is crucial that the `st_sequences` table is created last diff --git a/crates/datastore/src/locking_tx_datastore/datastore.rs b/crates/datastore/src/locking_tx_datastore/datastore.rs index d9b2ac32fe2..392ec7bad22 100644 --- a/crates/datastore/src/locking_tx_datastore/datastore.rs +++ b/crates/datastore/src/locking_tx_datastore/datastore.rs @@ -1147,9 +1147,10 @@ mod tests { use crate::error::IndexError; use crate::locking_tx_datastore::tx_state::PendingSchemaChange; use crate::system_tables::{ - system_tables, StColumnRow, StConstraintData, StConstraintFields, StConstraintRow, StIndexAlgorithm, - StIndexFields, StIndexRow, StRowLevelSecurityFields, StScheduledFields, StSequenceFields, StSequenceRow, - StTableRow, StVarFields, ST_CLIENT_NAME, ST_COLUMN_ID, ST_COLUMN_NAME, ST_CONSTRAINT_ID, ST_CONSTRAINT_NAME, + system_tables, StColumnRow, StConnectionCredentialsFields, StConstraintData, StConstraintFields, + StConstraintRow, StIndexAlgorithm, StIndexFields, StIndexRow, StRowLevelSecurityFields, StScheduledFields, + StSequenceFields, StSequenceRow, StTableRow, StVarFields, ST_CLIENT_NAME, ST_COLUMN_ID, ST_COLUMN_NAME, + ST_CONNECTION_CREDENTIALS_ID, ST_CONNECTION_CREDENTIALS_NAME, ST_CONSTRAINT_ID, ST_CONSTRAINT_NAME, ST_INDEX_ID, ST_INDEX_NAME, ST_MODULE_NAME, ST_RESERVED_SEQUENCE_RANGE, ST_ROW_LEVEL_SECURITY_ID, ST_ROW_LEVEL_SECURITY_NAME, ST_SCHEDULED_ID, ST_SCHEDULED_NAME, ST_SEQUENCE_ID, ST_SEQUENCE_NAME, ST_TABLE_NAME, ST_VAR_ID, ST_VAR_NAME, @@ -1603,6 +1604,7 @@ mod tests { TableRow { id: ST_VAR_ID.into(), name: ST_VAR_NAME, ty: StTableType::System, access: StAccess::Public, primary_key: Some(StVarFields::Name.into()) }, TableRow { id: ST_SCHEDULED_ID.into(), name: ST_SCHEDULED_NAME, ty: StTableType::System, access: StAccess::Public, primary_key: Some(StScheduledFields::ScheduleId.into()) }, TableRow { id: ST_ROW_LEVEL_SECURITY_ID.into(), name: ST_ROW_LEVEL_SECURITY_NAME, ty: StTableType::System, access: StAccess::Public, primary_key: Some(StRowLevelSecurityFields::Sql.into()) }, + TableRow { id: ST_CONNECTION_CREDENTIALS_ID.into(), name: ST_CONNECTION_CREDENTIALS_NAME, ty: StTableType::System, access: StAccess::Private, primary_key: Some(StConnectionCredentialsFields::ConnectionId.into()) }, ])); #[rustfmt::skip] assert_eq!(query.scan_st_columns()?, map_array([ @@ -1658,6 +1660,9 @@ mod tests { ColRow { table: ST_ROW_LEVEL_SECURITY_ID.into(), pos: 0, name: "table_id", ty: TableId::get_type() }, ColRow { table: ST_ROW_LEVEL_SECURITY_ID.into(), pos: 1, name: "sql", ty: AlgebraicType::String }, + + ColRow { table: ST_CONNECTION_CREDENTIALS_ID.into(), pos: 0, name: "connection_id", ty: AlgebraicType::U128 }, + ColRow { table: ST_CONNECTION_CREDENTIALS_ID.into(), pos: 1, name: "jwt_payload", ty: AlgebraicType::String }, ])); #[rustfmt::skip] assert_eq!(query.scan_st_indexes()?, map_array([ @@ -1673,6 +1678,7 @@ mod tests { IndexRow { id: 10, table: ST_SCHEDULED_ID.into(), col: col(1), name: "st_scheduled_table_id_idx_btree", }, IndexRow { id: 11, table: ST_ROW_LEVEL_SECURITY_ID.into(), col: col(0), name: "st_row_level_security_table_id_idx_btree", }, IndexRow { id: 12, table: ST_ROW_LEVEL_SECURITY_ID.into(), col: col(1), name: "st_row_level_security_sql_idx_btree", }, + IndexRow { id: 13, table: ST_CONNECTION_CREDENTIALS_ID.into(), col: col(0), name: "st_connection_credentials_connection_id_idx_btree", }, ])); let start = FIRST_NON_SYSTEM_ID as i128; #[rustfmt::skip] @@ -1702,6 +1708,7 @@ mod tests { ConstraintRow { constraint_id: 9, table_id: ST_SCHEDULED_ID.into(), unique_columns: col(0), constraint_name: "st_scheduled_schedule_id_key", }, ConstraintRow { constraint_id: 10, table_id: ST_SCHEDULED_ID.into(), unique_columns: col(1), constraint_name: "st_scheduled_table_id_key", }, ConstraintRow { constraint_id: 11, table_id: ST_ROW_LEVEL_SECURITY_ID.into(), unique_columns: col(1), constraint_name: "st_row_level_security_sql_key", }, + ConstraintRow { constraint_id: 12, table_id: ST_CONNECTION_CREDENTIALS_ID.into(), unique_columns: col(0), constraint_name: "st_connection_credentials_connection_id_key", }, ])); // Verify we get back the tables correctly with the proper ids... @@ -2099,6 +2106,7 @@ mod tests { IndexRow { id: 10, table: ST_SCHEDULED_ID.into(), col: col(1), name: "st_scheduled_table_id_idx_btree", }, IndexRow { id: 11, table: ST_ROW_LEVEL_SECURITY_ID.into(), col: col(0), name: "st_row_level_security_table_id_idx_btree", }, IndexRow { id: 12, table: ST_ROW_LEVEL_SECURITY_ID.into(), col: col(1), name: "st_row_level_security_sql_idx_btree", }, + IndexRow { id: 13, table: ST_CONNECTION_CREDENTIALS_ID.into(), col: col(0), name: "st_connection_credentials_connection_id_idx_btree", }, IndexRow { id: seq_start, table: FIRST_NON_SYSTEM_ID, col: col(0), name: "Foo_id_idx_btree", }, IndexRow { id: seq_start + 1, table: FIRST_NON_SYSTEM_ID, col: col(1), name: "Foo_name_idx_btree", }, IndexRow { id: seq_start + 2, table: FIRST_NON_SYSTEM_ID, col: col(2), name: "Foo_age_idx_btree", }, diff --git a/crates/datastore/src/system_tables.rs b/crates/datastore/src/system_tables.rs index 1c56df19a65..1c6fdbbb036 100644 --- a/crates/datastore/src/system_tables.rs +++ b/crates/datastore/src/system_tables.rs @@ -60,6 +60,11 @@ pub const ST_SCHEDULED_ID: TableId = TableId(9); /// The static ID of the table that defines the row level security (RLS) policies pub const ST_ROW_LEVEL_SECURITY_ID: TableId = TableId(10); + +/// The static ID of the table that stores the credentials for each connection. +pub const ST_CONNECTION_CREDENTIALS_ID: TableId = TableId(11); + +pub(crate) const ST_CONNECTION_CREDENTIALS_NAME: &str = "st_connection_credentials"; pub const ST_TABLE_NAME: &str = "st_table"; pub const ST_COLUMN_NAME: &str = "st_column"; pub const ST_SEQUENCE_NAME: &str = "st_sequence"; @@ -97,7 +102,7 @@ pub enum SystemTable { st_row_level_security, } -pub fn system_tables() -> [TableSchema; 10] { +pub fn system_tables() -> [TableSchema; 11] { [ // The order should match the `id` of the system table, that start with [ST_TABLE_IDX]. st_table_schema(), @@ -109,6 +114,7 @@ pub fn system_tables() -> [TableSchema; 10] { st_var_schema(), st_scheduled_schema(), st_row_level_security_schema(), + st_connection_credential_schema(), // Is important this is always last, so the starting sequence for each // system table is correct. st_sequence_schema(), @@ -149,8 +155,9 @@ pub(crate) const ST_CLIENT_IDX: usize = 5; pub(crate) const ST_VAR_IDX: usize = 6; pub(crate) const ST_SCHEDULED_IDX: usize = 7; pub(crate) const ST_ROW_LEVEL_SECURITY_IDX: usize = 8; +pub(crate) const ST_CONNECTION_CREDENTIALS_IDX: usize = 9; // Must be the last index in the array. -pub(crate) const ST_SEQUENCE_IDX: usize = 9; +pub(crate) const ST_SEQUENCE_IDX: usize = 10; macro_rules! st_fields_enum { ($(#[$attr:meta])* enum $ty_name:ident { $($name:expr, $var:ident = $discr:expr,)* }) => { @@ -248,6 +255,13 @@ st_fields_enum!(enum StClientFields { "identity", Identity = 0, "connection_id", ConnectionId = 1, }); + +// WARNING: For a stable schema, don't change the field names and discriminants. +st_fields_enum!(enum StConnectionCredentialsFields { + "connection_id", ConnectionId = 0, + "jwt_payload", JwtPayload = 1, +}); + // WARNING: For a stable schema, don't change the field names and discriminants. st_fields_enum!(enum StVarFields { "name", Name = 0, @@ -341,6 +355,19 @@ fn system_module_def() -> ModuleDef { .with_type(TableType::System); // TODO: add empty unique constraint here, once we've implemented those. + let st_connection_credentials_type = builder.add_type::(); + // let st_connection_credentials_unique_cols = [StConnectionCredentialsFields::ConnectionId]; + builder + .build_table( + ST_CONNECTION_CREDENTIALS_NAME, + *st_connection_credentials_type.as_ref().expect("should be ref"), + ) + .with_type(TableType::System) + .with_unique_constraint(StConnectionCredentialsFields::ConnectionId) + .with_index_no_accessor_name(btree(StConnectionCredentialsFields::ConnectionId)) + .with_access(v9::TableAccess::Private) + .with_primary_key(StConnectionCredentialsFields::ConnectionId); + let st_client_type = builder.add_type::(); let st_client_unique_cols = [StClientFields::Identity, StClientFields::ConnectionId]; builder @@ -382,6 +409,7 @@ fn system_module_def() -> ModuleDef { validate_system_table::(&result, ST_CLIENT_NAME); validate_system_table::(&result, ST_VAR_NAME); validate_system_table::(&result, ST_SCHEDULED_NAME); + validate_system_table::(&result, ST_CONNECTION_CREDENTIALS_NAME); result } @@ -442,6 +470,10 @@ fn st_client_schema() -> TableSchema { st_schema(ST_CLIENT_NAME, ST_CLIENT_ID) } +fn st_connection_credential_schema() -> TableSchema { + st_schema(ST_CONNECTION_CREDENTIALS_NAME, ST_CONNECTION_CREDENTIALS_ID) +} + fn st_scheduled_schema() -> TableSchema { st_schema(ST_SCHEDULED_NAME, ST_SCHEDULED_ID) } @@ -466,6 +498,7 @@ pub(crate) fn system_table_schema(table_id: TableId) -> Option { ST_ROW_LEVEL_SECURITY_ID => Some(st_row_level_security_schema()), ST_MODULE_ID => Some(st_module_schema()), ST_CLIENT_ID => Some(st_client_schema()), + ST_CONNECTION_CREDENTIALS_ID => Some(st_connection_credential_schema()), ST_VAR_ID => Some(st_var_schema()), ST_SCHEDULED_ID => Some(st_scheduled_schema()), _ => None, @@ -927,6 +960,18 @@ pub struct StClientRow { pub connection_id: ConnectionIdViaU128, } +/// System table [ST_CONNECTION_CREDENTIALS_NAME] +/// +/// | connection_id | jwt_payload | +/// |------------------------------------|---------------------------------------------------------| +/// | 0x6bdea3ab517f5857dc9b1b5fe99e1b14 | '{"iss":"issuer","sub":"user-id","iat":1629212345,...}' | +#[derive(Clone, Debug, Eq, PartialEq, SpacetimeType)] +#[sats(crate = spacetimedb_lib)] +pub struct StConnectionCredentialsRow { + pub connection_id: ConnectionIdViaU128, + pub jwt_payload: String, +} + impl From for ProductValue { fn from(var: StClientRow) -> Self { to_product_value(&var) diff --git a/crates/sdk/examples/quickstart-chat/main.rs b/crates/sdk/examples/quickstart-chat/main.rs index e04ff9cb3cc..57ed21818f5 100644 --- a/crates/sdk/examples/quickstart-chat/main.rs +++ b/crates/sdk/examples/quickstart-chat/main.rs @@ -63,7 +63,7 @@ fn creds_store() -> credentials::File { /// Our `on_connect` callback: save our credentials to a file. fn on_connected(_ctx: &DbConnection, _identity: Identity, token: &str) { if let Err(e) = creds_store().save(token) { - eprintln!("Failed to save credentials: {:?}", e); + eprintln!("Failed to save credentials: {e:?}"); } } @@ -71,14 +71,14 @@ fn on_connected(_ctx: &DbConnection, _identity: Identity, token: &str) { /// Our `on_connect_error` callback: print the error, then exit the process. fn on_connect_error(_ctx: &ErrorContext, err: Error) { - eprintln!("Connection error: {}", err); + eprintln!("Connection error: {err}"); std::process::exit(1); } /// Our `on_disconnect` callback: print a note, then exit the process. fn on_disconnected(_ctx: &ErrorContext, err: Option) { if let Some(err) = err { - eprintln!("Disconnected: {}", err); + eprintln!("Disconnected: {err}"); std::process::exit(1); } else { println!("Disconnected."); @@ -166,14 +166,14 @@ fn print_message(ctx: &impl RemoteDbContext, message: &Message) { /// Our `on_set_name` callback: print a warning if the reducer failed. fn on_name_set(ctx: &ReducerEventContext, name: &String) { if let Status::Failed(err) = &ctx.event.status { - eprintln!("Failed to change name to {:?}: {}", name, err); + eprintln!("Failed to change name to {name:?}: {err}"); } } /// Our `on_send_message` callback: print a warning if the reducer failed. fn on_message_sent(ctx: &ReducerEventContext, text: &String) { if let Status::Failed(err) = &ctx.event.status { - eprintln!("Failed to send message {:?}: {}", text, err); + eprintln!("Failed to send message {text:?}: {err}"); } } @@ -206,7 +206,7 @@ fn on_sub_applied(ctx: &SubscriptionEventContext) { /// Or `on_error` callback: /// print the error, then exit the process. fn on_sub_error(_ctx: &ErrorContext, err: Error) { - eprintln!("Subscription failed: {}", err); + eprintln!("Subscription failed: {err}"); std::process::exit(1); } From cd6625a85a65a93d6735b980771b63329e95af10 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 21 Jul 2025 13:01:22 -0700 Subject: [PATCH 02/16] Add jwt payload to SpacetimeAuth --- Cargo.lock | 1 + crates/client-api/Cargo.toml | 1 + crates/client-api/src/auth.rs | 69 ++++++++++++++++++++- crates/sdk/examples/quickstart-chat/main.rs | 12 ++-- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5f1c04fc08..899f9080456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5433,6 +5433,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", + "base64 0.21.7", "bytes", "bytestring", "chrono", diff --git a/crates/client-api/Cargo.toml b/crates/client-api/Cargo.toml index b791ee05334..1d4aaf26918 100644 --- a/crates/client-api/Cargo.toml +++ b/crates/client-api/Cargo.toml @@ -15,6 +15,7 @@ spacetimedb-lib = { workspace = true, features = ["serde"] } spacetimedb-paths.workspace = true spacetimedb-schema.workspace = true +base64.workspace = true tokio = { version = "1.2", features = ["full"] } lazy_static = "1.4.0" log = "0.4.4" diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index 61031625867..08b7e77226c 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -1,5 +1,4 @@ -use std::time::{Duration, SystemTime}; - +use anyhow::anyhow; use axum::extract::{Query, Request, State}; use axum::middleware::Next; use axum::response::IntoResponse; @@ -15,9 +14,11 @@ use spacetimedb::auth::token_validation::{ use spacetimedb::auth::JwtKeys; use spacetimedb::energy::EnergyQuanta; use spacetimedb::identity::Identity; +use std::time::{Duration, SystemTime}; use uuid::Uuid; use crate::{log_and_500, ControlStateDelegate, NodeDelegate}; +use base64::{engine::general_purpose, Engine}; /// Credentials for login for a spacetime identity, represented as a JWT. /// @@ -41,6 +42,19 @@ impl SpacetimeCreds { Self { token } } + fn extract_jwt_payload_string(&self) -> Option { + let parts: Vec<&str> = self.token.split('.').collect(); + if parts.len() != 3 { + return None; + } + + let payload_encoded = parts[1]; + let decoded_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload_encoded).ok()?; + let json_str = String::from_utf8(decoded_bytes).ok()?; + + Some(json_str) + } + pub fn to_header_value(&self) -> HeaderValue { let mut val = HeaderValue::try_from(["Bearer ", self.token()].concat()).unwrap(); val.set_sensitive(true); @@ -73,6 +87,8 @@ pub struct SpacetimeAuth { pub identity: Identity, pub subject: String, pub issuer: String, + // The decoded JWT payload. + pub raw_payload: String, } use jsonwebtoken; @@ -148,12 +164,17 @@ impl SpacetimeAuth { let token = claims.encode_and_sign(ctx.jwt_auth_provider()).map_err(log_and_500)?; SpacetimeCreds::from_signed_token(token) }; + // Pulling out the payload should never fail, since we just made it. + let payload = creds + .extract_jwt_payload_string() + .ok_or_else(|| log_and_500("internal error"))?; Ok(Self { creds, identity, subject, issuer: ctx.jwt_auth_provider().local_issuer().to_string(), + raw_payload: payload, }) } @@ -237,9 +258,11 @@ impl JwtAuthProvider for JwtKeyAuthProvider Result<(), anyhow::Error> { + let kp = JwtKeys::generate()?; + + let dummy_audience = "spacetimedb".to_string(); + let claims = TokenClaims { + issuer: "localhost".to_string(), + subject: "test-subject".to_string(), + audience: vec![dummy_audience.clone()], + }; + let token = claims.encode_and_sign(&kp.private)?; + let st_creds = SpacetimeCreds::from_signed_token(token); + let payload = st_creds + .extract_jwt_payload_string() + .ok_or_else(|| anyhow::anyhow!("Failed to extract JWT payload"))?; + // Make sure it is valid json. + let parsed: serde_json::Value = serde_json::from_str(&payload)?; + assert_eq!(parsed.get("iss").unwrap().as_str().unwrap(), claims.issuer); + assert_eq!(parsed.get("sub").unwrap().as_str().unwrap(), claims.subject); + assert_eq!( + parsed.get("aud").unwrap().as_array().unwrap()[0].as_str().unwrap(), + dummy_audience + ); + let as_object = parsed + .as_object() + .ok_or_else(|| anyhow::anyhow!("Failed to parse JWT payload as object"))?; + let keys: HashSet = as_object.keys().map(|s| s.to_string()).collect(); + let expected_keys = vec!["iss", "sub", "aud", "iat", "exp", "hex_identity"] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + assert_eq!(keys, expected_keys); + Ok(()) + } } pub struct SpacetimeAuthHeader { @@ -279,11 +338,15 @@ impl axum::extract::FromRequestParts for Space .await .map_err(AuthorizationRejection::Custom)?; + let payload = creds.extract_jwt_payload_string().ok_or_else(|| { + AuthorizationRejection::Custom(TokenValidationError::Other(anyhow!("Internal error parsing token"))) + })?; let auth = SpacetimeAuth { creds, identity: claims.identity, subject: claims.subject, issuer: claims.issuer, + raw_payload: payload, }; Ok(Self { auth: Some(auth) }) } diff --git a/crates/sdk/examples/quickstart-chat/main.rs b/crates/sdk/examples/quickstart-chat/main.rs index e04ff9cb3cc..57ed21818f5 100644 --- a/crates/sdk/examples/quickstart-chat/main.rs +++ b/crates/sdk/examples/quickstart-chat/main.rs @@ -63,7 +63,7 @@ fn creds_store() -> credentials::File { /// Our `on_connect` callback: save our credentials to a file. fn on_connected(_ctx: &DbConnection, _identity: Identity, token: &str) { if let Err(e) = creds_store().save(token) { - eprintln!("Failed to save credentials: {:?}", e); + eprintln!("Failed to save credentials: {e:?}"); } } @@ -71,14 +71,14 @@ fn on_connected(_ctx: &DbConnection, _identity: Identity, token: &str) { /// Our `on_connect_error` callback: print the error, then exit the process. fn on_connect_error(_ctx: &ErrorContext, err: Error) { - eprintln!("Connection error: {}", err); + eprintln!("Connection error: {err}"); std::process::exit(1); } /// Our `on_disconnect` callback: print a note, then exit the process. fn on_disconnected(_ctx: &ErrorContext, err: Option) { if let Some(err) = err { - eprintln!("Disconnected: {}", err); + eprintln!("Disconnected: {err}"); std::process::exit(1); } else { println!("Disconnected."); @@ -166,14 +166,14 @@ fn print_message(ctx: &impl RemoteDbContext, message: &Message) { /// Our `on_set_name` callback: print a warning if the reducer failed. fn on_name_set(ctx: &ReducerEventContext, name: &String) { if let Status::Failed(err) = &ctx.event.status { - eprintln!("Failed to change name to {:?}: {}", name, err); + eprintln!("Failed to change name to {name:?}: {err}"); } } /// Our `on_send_message` callback: print a warning if the reducer failed. fn on_message_sent(ctx: &ReducerEventContext, text: &String) { if let Status::Failed(err) = &ctx.event.status { - eprintln!("Failed to send message {:?}: {}", text, err); + eprintln!("Failed to send message {text:?}: {err}"); } } @@ -206,7 +206,7 @@ fn on_sub_applied(ctx: &SubscriptionEventContext) { /// Or `on_error` callback: /// print the error, then exit the process. fn on_sub_error(_ctx: &ErrorContext, err: Error) { - eprintln!("Subscription failed: {}", err); + eprintln!("Subscription failed: {err}"); std::process::exit(1); } From 4c1807f2fb9f2865ff1e0b0a8cf806161ef68704 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 23 Jul 2025 10:17:29 -0700 Subject: [PATCH 03/16] Plumb the jwt payload around --- crates/auth/Cargo.toml | 1 + crates/auth/src/identity.rs | 20 +++++++- crates/client-api/src/auth.rs | 54 ++++++++++++--------- crates/client-api/src/routes/database.rs | 41 +++++++++------- crates/client-api/src/routes/energy.rs | 6 +-- crates/client-api/src/routes/identity.rs | 6 +-- crates/client-api/src/routes/subscribe.rs | 15 ++++-- crates/core/src/client.rs | 3 +- crates/core/src/client/client_connection.rs | 19 ++++++-- crates/core/src/error.rs | 3 +- crates/core/src/host/module_host.rs | 9 ++-- crates/testing/src/modules.rs | 2 +- 12 files changed, 120 insertions(+), 59 deletions(-) diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index a5592c08f5f..f3db2f86af7 100644 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -11,6 +11,7 @@ spacetimedb-lib = { workspace = true, features = ["serde"] } anyhow.workspace = true serde.workspace = true +serde_json.workspace = true serde_with.workspace = true jsonwebtoken.workspace = true diff --git a/crates/auth/src/identity.rs b/crates/auth/src/identity.rs index 286d28582dd..576e850d7ee 100644 --- a/crates/auth/src/identity.rs +++ b/crates/auth/src/identity.rs @@ -6,9 +6,27 @@ use serde::{Deserialize, Serialize}; use spacetimedb_lib::Identity; use std::time::SystemTime; +#[derive(Debug, Clone)] +pub struct ConnectionAuthCtx { + pub claims: SpacetimeIdentityClaims, + pub jwt_payload: String, +} + +impl TryFrom for ConnectionAuthCtx { + type Error = anyhow::Error; + fn try_from(claims: SpacetimeIdentityClaims) -> Result { + let payload = + serde_json::to_string(&claims).map_err(|e| anyhow::anyhow!("Failed to serialize claims: {}", e))?; + Ok(ConnectionAuthCtx { + claims, + jwt_payload: payload, + }) + } +} + // These are the claims that can be attached to a request/connection. #[serde_with::serde_as] -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct SpacetimeIdentityClaims { #[serde(rename = "hex_identity")] pub identity: Identity, diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index 08b7e77226c..ccf2218abf7 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -6,7 +6,7 @@ use axum_extra::typed_header::TypedHeader; use headers::{authorization, HeaderMapExt}; use http::{request, HeaderValue, StatusCode}; use serde::{Deserialize, Serialize}; -use spacetimedb::auth::identity::SpacetimeIdentityClaims; +use spacetimedb::auth::identity::{ConnectionAuthCtx, SpacetimeIdentityClaims}; use spacetimedb::auth::identity::{JwtError, JwtErrorKind}; use spacetimedb::auth::token_validation::{ new_validator, DefaultValidator, TokenSigner, TokenValidationError, TokenValidator, @@ -84,13 +84,25 @@ impl SpacetimeCreds { #[derive(Clone)] pub struct SpacetimeAuth { pub creds: SpacetimeCreds, + pub claims: SpacetimeIdentityClaims, + /* pub identity: Identity, pub subject: String, pub issuer: String, + */ // The decoded JWT payload. pub raw_payload: String, } +impl From for ConnectionAuthCtx { + fn from(auth: SpacetimeAuth) -> Self { + ConnectionAuthCtx { + claims: auth.claims, + jwt_payload: auth.raw_payload.clone(), + } + } +} + use jsonwebtoken; pub struct TokenClaims { @@ -100,10 +112,10 @@ pub struct TokenClaims { } impl From for TokenClaims { - fn from(claims: SpacetimeAuth) -> Self { + fn from(auth: SpacetimeAuth) -> Self { Self { - issuer: claims.issuer, - subject: claims.subject, + issuer: auth.claims.issuer, + subject: auth.claims.subject, // This will need to be changed when we care about audiencies. audience: Vec::new(), } @@ -128,7 +140,7 @@ impl TokenClaims { &self, signer: &impl TokenSigner, expiry: Option, - ) -> Result { + ) -> Result<(SpacetimeIdentityClaims, String), JwtError> { let iat = SystemTime::now(); let exp = expiry.map(|dur| iat + dur); let claims = SpacetimeIdentityClaims { @@ -139,10 +151,11 @@ impl TokenClaims { iat, exp, }; - signer.sign(&claims) + let token = signer.sign(&claims)?; + Ok((claims, token)) } - pub fn encode_and_sign(&self, signer: &impl TokenSigner) -> Result { + pub fn encode_and_sign(&self, signer: &impl TokenSigner) -> Result<(SpacetimeIdentityClaims, String), JwtError> { self.encode_and_sign_with_expiry(signer, None) } } @@ -159,11 +172,8 @@ impl SpacetimeAuth { audience: vec!["spacetimedb".to_string()], }; - let identity = claims.id(); - let creds = { - let token = claims.encode_and_sign(ctx.jwt_auth_provider()).map_err(log_and_500)?; - SpacetimeCreds::from_signed_token(token) - }; + let (claims, token) = claims.encode_and_sign(ctx.jwt_auth_provider()).map_err(log_and_500)?; + let creds = SpacetimeCreds::from_signed_token(token); // Pulling out the payload should never fail, since we just made it. let payload = creds .extract_jwt_payload_string() @@ -171,9 +181,7 @@ impl SpacetimeAuth { Ok(Self { creds, - identity, - subject, - issuer: ctx.jwt_auth_provider().local_issuer().to_string(), + claims, raw_payload: payload, }) } @@ -181,7 +189,7 @@ impl SpacetimeAuth { /// Get the auth credentials as headers to be returned from an endpoint. pub fn into_headers(self) -> (TypedHeader, TypedHeader) { ( - TypedHeader(SpacetimeIdentity(self.identity)), + TypedHeader(SpacetimeIdentity(self.claims.identity)), TypedHeader(SpacetimeIdentityToken(self.creds)), ) } @@ -189,7 +197,11 @@ impl SpacetimeAuth { // Sign a new token with the same claims and a new expiry. // Note that this will not change the issuer, so the private_key might not match. // We do this to create short-lived tokens that we will be able to verify. - pub fn re_sign_with_expiry(&self, signer: &impl TokenSigner, expiry: Duration) -> Result { + pub fn re_sign_with_expiry( + &self, + signer: &impl TokenSigner, + expiry: Duration, + ) -> Result<(SpacetimeIdentityClaims, String), JwtError> { TokenClaims::from(self.clone()).encode_and_sign_with_expiry(signer, Some(expiry)) } } @@ -275,7 +287,7 @@ mod tests { audience: vec!["spacetimedb".to_string()], }; let id = claims.id(); - let token = claims.encode_and_sign(&kp.private)?; + let (_, token) = claims.encode_and_sign(&kp.private)?; let decoded = kp.public.validate_token(&token).await?; assert_eq!(decoded.identity, id); @@ -293,7 +305,7 @@ mod tests { subject: "test-subject".to_string(), audience: vec![dummy_audience.clone()], }; - let token = claims.encode_and_sign(&kp.private)?; + let (_, token) = claims.encode_and_sign(&kp.private)?; let st_creds = SpacetimeCreds::from_signed_token(token); let payload = st_creds .extract_jwt_payload_string() @@ -343,9 +355,7 @@ impl axum::extract::FromRequestParts for Space })?; let auth = SpacetimeAuth { creds, - identity: claims.identity, - subject: claims.subject, - issuer: claims.issuer, + claims, raw_payload: payload, }; Ok(Self { auth: Some(auth) }) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index c25e476b076..5802b3abfdf 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -54,7 +54,7 @@ pub async fn call( if content_type != headers::ContentType::json() { return Err(axum::extract::rejection::MissingJsonContentType::default().into()); } - let caller_identity = auth.identity; + let caller_identity = auth.claims.identity; let args = ReducerArgs::Json(body); @@ -78,7 +78,7 @@ pub async fn call( // so generate one. let connection_id = generate_random_connection_id(); - match module.call_identity_connected(caller_identity, connection_id).await { + match module.call_identity_connected(auth.into(), connection_id).await { // If `call_identity_connected` returns `Err(Rejected)`, then the `client_connected` reducer errored, // meaning the connection was refused. Return 403 forbidden. Err(ClientConnectedError::Rejected(msg)) => return Err((StatusCode::FORBIDDEN, msg).into()), @@ -225,7 +225,7 @@ where }; Ok(( - TypedHeader(SpacetimeIdentity(auth.identity)), + TypedHeader(SpacetimeIdentity(auth.claims.identity)), TypedHeader(SpacetimeIdentityToken(auth.creds)), response_json, )) @@ -300,13 +300,13 @@ where .await? .ok_or(NO_SUCH_DATABASE)?; - if database.owner_identity != auth.identity { + if database.owner_identity != auth.claims.identity { return Err(( StatusCode::BAD_REQUEST, format!( "Identity does not own database, expected: {} got: {}", database.owner_identity.to_hex(), - auth.identity.to_hex() + auth.claims.identity.to_hex() ), ) .into()); @@ -402,7 +402,7 @@ where .await? .ok_or(NO_SUCH_DATABASE)?; - let auth = AuthCtx::new(database.owner_identity, auth.identity); + let auth = AuthCtx::new(database.owner_identity, auth.claims.identity); log::debug!("auth: {auth:?}"); let host = worker_ctx @@ -481,10 +481,13 @@ fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { if !require_spacetime_auth_for_creation() { return Ok(()); } - if auth.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { + if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { Ok(()) } else { - log::trace!("Rejecting creation request because auth issuer is {}", auth.issuer); + log::trace!( + "Rejecting creation request because auth issuer is {}", + auth.claims.issuer + ); Err(( StatusCode::UNAUTHORIZED, "To create a database, you must be logged in with a SpacetimeDB account.", @@ -511,9 +514,13 @@ pub async fn publish( // exists yet. Create it now with a fresh identity. allow_creation(&auth)?; let database_auth = SpacetimeAuth::alloc(&ctx).await?; - let database_identity = database_auth.identity; + let database_identity = database_auth.claims.identity; let tld: name::Tld = name.clone().into(); - let tld = match ctx.register_tld(&auth.identity, tld).await.map_err(log_and_500)? { + let tld = match ctx + .register_tld(&auth.claims.identity, tld) + .await + .map_err(log_and_500)? + { name::RegisterTldResult::Success { domain } | name::RegisterTldResult::AlreadyRegistered { domain } => domain, name::RegisterTldResult::Unauthorized { .. } => { @@ -525,7 +532,7 @@ pub async fn publish( } }; let res = ctx - .create_dns_record(&auth.identity, &tld.into(), &database_identity) + .create_dns_record(&auth.claims.identity, &tld.into(), &database_identity) .await .map_err(log_and_500)?; match res { @@ -541,7 +548,7 @@ pub async fn publish( }, None => { let database_auth = SpacetimeAuth::alloc(&ctx).await?; - let database_identity = database_auth.identity; + let database_identity = database_auth.claims.identity; (database_identity, None) } }; @@ -558,7 +565,7 @@ pub async fn publish( } if clear && exists { - ctx.delete_database(&auth.identity, &database_identity) + ctx.delete_database(&auth.claims.identity, &database_identity) .await .map_err(log_and_500)?; } @@ -580,7 +587,7 @@ pub async fn publish( let maybe_updated = ctx .publish_database( - &auth.identity, + &auth.claims.identity, DatabaseDef { database_identity, program_bytes: body.into(), @@ -626,7 +633,7 @@ pub async fn delete_database( ) -> axum::response::Result { let database_identity = name_or_identity.resolve(&ctx).await?; - ctx.delete_database(&auth.identity, &database_identity) + ctx.delete_database(&auth.claims.identity, &database_identity) .await .map_err(log_and_500)?; @@ -648,7 +655,7 @@ pub async fn add_name( let database_identity = name_or_identity.resolve(&ctx).await?; let response = ctx - .create_dns_record(&auth.identity, &name.into(), &database_identity) + .create_dns_record(&auth.claims.identity, &name.into(), &database_identity) .await // TODO: better error code handling .map_err(log_and_500)?; @@ -691,7 +698,7 @@ pub async fn set_names( )); }; - if database.owner_identity != auth.identity { + if database.owner_identity != auth.claims.identity { return Ok(( StatusCode::UNAUTHORIZED, axum::Json(name::SetDomainsResult::NotYourDatabase { diff --git a/crates/client-api/src/routes/energy.rs b/crates/client-api/src/routes/energy.rs index 16e66963cf6..34098987c68 100644 --- a/crates/client-api/src/routes/energy.rs +++ b/crates/client-api/src/routes/energy.rs @@ -49,14 +49,14 @@ pub async fn add_energy( })?; if let Some(satoshi) = amount { - ctx.add_energy(&auth.identity, EnergyQuanta::new(satoshi)) + ctx.add_energy(&auth.claims.identity, EnergyQuanta::new(satoshi)) .await .map_err(log_and_500)?; } // TODO: is this guaranteed to pull the updated balance? let balance = ctx - .get_energy_balance(&auth.identity) + .get_energy_balance(&auth.claims.identity) .map_err(log_and_500)? .map_or(0, |quanta| quanta.get()); @@ -87,7 +87,7 @@ pub async fn set_energy_balance( // This will be a natural rate limiter until we can begin to sell energy. // No one is able to be the dummy identity so this always returns unauthorized. - if auth.identity != Identity::__dummy() { + if auth.claims.identity != Identity::__dummy() { return Err(StatusCode::UNAUTHORIZED.into()); } diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index 69b27661fbe..be9adde55f9 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -24,7 +24,7 @@ pub async fn create_identity( let auth = SpacetimeAuth::alloc(&ctx).await?; let identity_response = CreateIdentityResponse { - identity: auth.identity, + identity: auth.claims.identity, token: auth.creds.token().to_owned(), }; Ok(axum::Json(identity_response)) @@ -103,7 +103,7 @@ pub async fn create_websocket_token( SpacetimeAuthRequired(auth): SpacetimeAuthRequired, ) -> axum::response::Result { let expiry = Duration::from_secs(60); - let token = auth + let (_, token) = auth .re_sign_with_expiry(ctx.jwt_auth_provider(), expiry) .map_err(log_and_500)?; // let token = encode_token_with_expiry(ctx.private_key(), auth.identity, Some(expiry)).map_err(log_and_500)?; @@ -121,7 +121,7 @@ pub async fn validate_token( ) -> axum::response::Result { let identity = Identity::from(identity); - if auth.identity != identity { + if auth.claims.identity != identity { return Err(StatusCode::BAD_REQUEST.into()); } diff --git a/crates/client-api/src/routes/subscribe.rs b/crates/client-api/src/routes/subscribe.rs index a76d3adacea..97e884beb71 100644 --- a/crates/client-api/src/routes/subscribe.rs +++ b/crates/client-api/src/routes/subscribe.rs @@ -136,8 +136,9 @@ where let module_rx = leader.module_watcher().await.map_err(log_and_500)?; + let client_identity = auth.claims.identity; let client_id = ClientActorId { - identity: auth.identity, + identity: client_identity, connection_id, name: ctx.client_actor_index().next_client_name(), }; @@ -164,7 +165,15 @@ where } let actor = |client, sendrx| ws_client_actor(client, ws, sendrx); - let client = match ClientConnection::spawn(client_id, client_config, leader.replica_id, module_rx, actor).await + let client = match ClientConnection::spawn( + client_id, + auth.into(), + client_config, + leader.replica_id, + module_rx, + actor, + ) + .await { Ok(s) => s, Err(e @ (ClientConnectedError::Rejected(_) | ClientConnectedError::OutOfEnergy)) => { @@ -183,7 +192,7 @@ where // Clients that receive the token from the response headers should ignore this // message. let message = IdentityTokenMessage { - identity: auth.identity, + identity: client_identity, token: identity_token, connection_id, }; diff --git a/crates/core/src/client.rs b/crates/core/src/client.rs index 11103824dca..1382b7882db 100644 --- a/crates/core/src/client.rs +++ b/crates/core/src/client.rs @@ -14,7 +14,8 @@ pub use client_connection_index::ClientActorIndex; pub use message_handlers::{MessageExecutionError, MessageHandleError}; use spacetimedb_lib::ConnectionId; -#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)] +// #[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)] +#[derive(Clone, Debug, Copy)] pub struct ClientActorId { pub identity: Identity, pub connection_id: ConnectionId, diff --git a/crates/core/src/client/client_connection.rs b/crates/core/src/client/client_connection.rs index acd9466d5b1..02d0da541cc 100644 --- a/crates/core/src/client/client_connection.rs +++ b/crates/core/src/client/client_connection.rs @@ -5,7 +5,7 @@ use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicBool, Ordering::Relaxed}; use std::sync::Arc; use std::task::{Context, Poll}; -use std::time::Instant; +use std::time::{Instant, SystemTime}; use super::messages::{OneOffQueryResponseMessage, SerializableMessage}; use super::{message_handlers, ClientActorId, MessageHandleError}; @@ -21,6 +21,7 @@ use bytestring::ByteString; use derive_more::From; use futures::prelude::*; use prometheus::{Histogram, IntCounter, IntGauge}; +use spacetimedb_auth::identity::{ConnectionAuthCtx, SpacetimeIdentityClaims}; use spacetimedb_client_api_messages::websocket::{ BsatnFormat, CallReducerFlags, Compression, FormatSwitch, JsonFormat, SubscribeMulti, SubscribeSingle, Unsubscribe, UnsubscribeMulti, @@ -78,6 +79,7 @@ impl ClientConfig { #[derive(Debug)] pub struct ClientConnectionSender { pub id: ClientActorId, + pub auth: ConnectionAuthCtx, pub config: ClientConfig, sendtx: mpsc::Sender, abort_handle: AbortHandle, @@ -146,8 +148,17 @@ impl ClientConnectionSender { let rx = MeteredReceiver::new(rx); let cancelled = AtomicBool::new(false); + let dummy_claims = SpacetimeIdentityClaims { + identity: id.identity, + subject: "".to_string(), + issuer: "".to_string(), + audience: vec![], + iat: SystemTime::now(), + exp: None, + }; let sender = Self { id, + auth: ConnectionAuthCtx::try_from(dummy_claims).expect("dummy claims should always be valid"), config, sendtx, abort_handle, @@ -396,6 +407,7 @@ impl ClientConnection { /// Returns an error if ModuleHost closed pub async fn spawn( id: ClientActorId, + auth: ConnectionAuthCtx, config: ClientConfig, replica_id: u64, mut module_rx: watch::Receiver, @@ -409,7 +421,7 @@ impl ClientConnection { // logically subscribed to the database, not any particular replica. We should handle failover for // them and stuff. Not right now though. let module = module_rx.borrow_and_update().clone(); - module.call_identity_connected(id.identity, id.connection_id).await?; + module.call_identity_connected(auth.clone(), id.connection_id).await?; let (sendtx, sendrx) = mpsc::channel::(CLIENT_CHANNEL_CAPACITY); @@ -417,6 +429,7 @@ impl ClientConnection { // weird dance so that we can get an abort_handle into ClientConnection let module_info = module.info.clone(); let database_identity = module_info.database_identity; + let client_identity = id.identity; let abort_handle = tokio::spawn(async move { let Ok(fut) = fut_rx.await else { return }; @@ -424,7 +437,6 @@ impl ClientConnection { module_info.metrics.ws_clients_spawned.inc(); scopeguard::defer! { let database_identity = module_info.database_identity; - let client_identity = id.identity; log::warn!("websocket connection aborted for client identity `{client_identity}` and database identity `{database_identity}`"); module_info.metrics.ws_clients_aborted.inc(); }; @@ -438,6 +450,7 @@ impl ClientConnection { let sender = Arc::new(ClientConnectionSender { id, + auth, config, sendtx, abort_handle, diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 4e550b234b3..3dfd4ec465b 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -26,7 +26,8 @@ use spacetimedb_vm::expr::Crud; pub use spacetimedb_datastore::error::{DatastoreError, IndexError, SequenceError, TableError}; -#[derive(Error, Debug, PartialEq, Eq)] +// #[derive(Error, Debug, PartialEq, Eq)] +#[derive(Error, Debug)] pub enum ClientError { #[error("Client not found: {0}")] NotFound(ClientActorId), diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index dc8a75640a6..d18c02356dc 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -25,6 +25,7 @@ use derive_more::From; use indexmap::IndexSet; use itertools::Itertools; use prometheus::{Histogram, IntGauge}; +use spacetimedb_auth::identity::ConnectionAuthCtx; use spacetimedb_client_api_messages::websocket::{ByteListLen, Compression, OneOffTable, QueryUpdate, WebsocketFormat}; use spacetimedb_data_structures::error_stream::ErrorStream; use spacetimedb_data_structures::map::{HashCollectionExt as _, IntMap}; @@ -684,7 +685,7 @@ impl ModuleHost { /// In this case, the caller should terminate the connection. pub async fn call_identity_connected( &self, - caller_identity: Identity, + caller_auth: ConnectionAuthCtx, caller_connection_id: ConnectionId, ) -> Result<(), ClientConnectedError> { let me = self.clone(); @@ -697,7 +698,7 @@ impl ModuleHost { // If the call fails (as in, something unexpectedly goes wrong with WASM execution), // abort the connection: we can't really recover. let reducer_outcome = me.call_reducer_inner_with_inst( - caller_identity, + caller_auth.claims.identity, Some(caller_connection_id), None, None, @@ -739,7 +740,7 @@ impl ModuleHost { let workload = Workload::Reducer(ReducerContext { name: reducer_name.to_owned(), - caller_identity, + caller_identity: caller_auth.claims.identity, caller_connection_id, timestamp: Timestamp::now(), arg_bsatn: Bytes::new(), @@ -748,7 +749,7 @@ impl ModuleHost { let stdb = me.module.replica_ctx().relational_db.clone(); stdb.with_auto_commit(workload, |mut_tx| { mut_tx - .insert_st_client(caller_identity, caller_connection_id) + .insert_st_client(caller_auth.claims.identity, caller_connection_id) .map_err(DBError::from) }) .inspect_err(|e| { diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index 22c55b9af8a..11665c1ec3e 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -184,7 +184,7 @@ impl CompiledModule { .unwrap(); // TODO: Fix this when we update identity generation. let identity = Identity::ZERO; - let db_identity = SpacetimeAuth::alloc(&env).await.unwrap().identity; + let db_identity = SpacetimeAuth::alloc(&env).await.unwrap().claims.identity; let connection_id = generate_random_connection_id(); let program_bytes = self.program_bytes().to_owned(); From c65bb786ec6f23c2dba15f80095741776ac391e0 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 23 Jul 2025 10:51:23 -0700 Subject: [PATCH 04/16] Store client credentials. --- crates/core/src/host/module_host.rs | 3 ++ .../src/host/wasm_common/module_host_actor.rs | 7 +++- .../locking_tx_datastore/committed_state.rs | 5 +-- .../src/locking_tx_datastore/mut_tx.rs | 35 +++++++++++++++++-- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index d18c02356dc..b5561724404 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -750,6 +750,9 @@ impl ModuleHost { stdb.with_auto_commit(workload, |mut_tx| { mut_tx .insert_st_client(caller_auth.claims.identity, caller_connection_id) + .map_err(DBError::from)?; + mut_tx + .insert_st_client_credentials(caller_connection_id, &caller_auth.jwt_payload) .map_err(DBError::from) }) .inspect_err(|e| { diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index c337140ac94..3a245d927d5 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -363,9 +363,14 @@ impl WasmModuleInstance { .with_label_values(&database_identity, reducer_name); let workload = Workload::Reducer(ReducerContext::from(op.clone())); - let tx = tx.unwrap_or_else(|| stdb.begin_mut_tx(IsolationLevel::Serializable, workload)); + let mut tx = tx.unwrap_or_else(|| stdb.begin_mut_tx(IsolationLevel::Serializable, workload)); let _guard = metric_reducer_plus_query_duration.with_timer(tx.timer); + if let Some(Lifecycle::OnConnect) = reducer_def.lifecycle { + tx.insert_st_client_credentials(caller_connection_id, &client.clone().unwrap().auth.jwt_payload) + .unwrap(); + }; + let mut tx_slot = self.instance.instance_env().tx.clone(); let reducer_span = tracing::trace_span!( diff --git a/crates/datastore/src/locking_tx_datastore/committed_state.rs b/crates/datastore/src/locking_tx_datastore/committed_state.rs index ed8b15ec1f5..c05cd558751 100644 --- a/crates/datastore/src/locking_tx_datastore/committed_state.rs +++ b/crates/datastore/src/locking_tx_datastore/committed_state.rs @@ -25,10 +25,7 @@ use anyhow::anyhow; use core::{convert::Infallible, ops::RangeBounds}; use itertools::Itertools; use spacetimedb_data_structures::map::{HashSet, IntMap}; -use spacetimedb_lib::{ - db::auth::StTableType, - Identity, -}; +use spacetimedb_lib::{db::auth::StTableType, Identity}; use spacetimedb_primitives::{ColList, ColSet, IndexId, TableId}; use spacetimedb_sats::memory_usage::MemoryUsage; use spacetimedb_sats::{AlgebraicValue, ProductValue}; diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index e1c92e6d204..884b07c7538 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -10,6 +10,7 @@ use super::{ }; use crate::execution_context::ExecutionContext; use crate::execution_context::Workload; +use crate::system_tables::{StConnectionCredentialsFields, StConnectionCredentialsRow, ST_CONNECTION_CREDENTIALS_ID}; use crate::traits::{InsertFlags, RowTypeForTable, TxData, UpdateFlags}; use crate::{ error::{IndexError, SequenceError, TableError}, @@ -1357,6 +1358,36 @@ impl MutTxId { self.insert_via_serialize_bsatn(ST_CLIENT_ID, row).map(|_| ()) } + pub fn insert_st_client_credentials(&mut self, connection_id: ConnectionId, jwt_payload: &str) -> Result<()> { + let row = &StConnectionCredentialsRow { + connection_id: connection_id.into(), + jwt_payload: jwt_payload.to_owned(), + }; + self.insert_via_serialize_bsatn(ST_CONNECTION_CREDENTIALS_ID, row) + .map(|_| ()) + } + + pub fn delete_st_client_credentials( + &mut self, + database_identity: Identity, + connection_id: ConnectionId, + ) -> Result<()> { + if let Some(ptr) = self + .iter_by_col_eq( + ST_CONNECTION_CREDENTIALS_ID, + StConnectionCredentialsFields::ConnectionId, + &connection_id.into(), + )? + .next() + .map(|row| row.pointer()) + { + self.delete(ST_CONNECTION_CREDENTIALS_ID, ptr).map(drop) + } else { + log::warn!("[{database_identity}]: delete_st_client_credentials: attempting to credentials for missing connection id ({connection_id})"); + Ok(()) + } + } + pub fn delete_st_client( &mut self, identity: Identity, @@ -1378,11 +1409,11 @@ impl MutTxId { .next() .map(|row| row.pointer()) { - self.delete(ST_CLIENT_ID, ptr).map(drop) + self.delete(ST_CLIENT_ID, ptr).map(drop)? } else { log::error!("[{database_identity}]: delete_st_client: attempting to delete client ({identity}, {connection_id}), but no st_client row for that client is resident"); - Ok(()) } + self.delete_st_client_credentials(database_identity, connection_id) } pub fn insert_via_serialize_bsatn<'a, T: Serialize>( From 168fd84d50b5a40458da70bcd12ef152620e62ad Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 24 Jul 2025 08:08:35 -0700 Subject: [PATCH 05/16] Tweak error handling --- crates/client-api/src/auth.rs | 5 ---- .../src/host/wasm_common/module_host_actor.rs | 26 +++++++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index ccf2218abf7..e8175ee7b5b 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -85,11 +85,6 @@ impl SpacetimeCreds { pub struct SpacetimeAuth { pub creds: SpacetimeCreds, pub claims: SpacetimeIdentityClaims, - /* - pub identity: Identity, - pub subject: String, - pub issuer: String, - */ // The decoded JWT payload. pub raw_payload: String, } diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 3a245d927d5..db899188d65 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -366,9 +366,31 @@ impl WasmModuleInstance { let mut tx = tx.unwrap_or_else(|| stdb.begin_mut_tx(IsolationLevel::Serializable, workload)); let _guard = metric_reducer_plus_query_duration.with_timer(tx.timer); + // For OnConnect, we insert the credentials before the reducer, so we can look them up + // inside that reducer. + // If the connection is rejected, this should get rolled back. if let Some(Lifecycle::OnConnect) = reducer_def.lifecycle { - tx.insert_st_client_credentials(caller_connection_id, &client.clone().unwrap().auth.jwt_payload) - .unwrap(); + let client_clone = match client.clone() { + Some(client) => client, + None => { + log::error!("OnConnect reducer called without a client"); + return ReducerCallResult { + outcome: ReducerOutcome::Failed("OnConnect reducer called without a client".into()), + energy_used: EnergyQuanta::ZERO, + execution_duration: Duration::ZERO, + }; + } + }; + if let Some(err) = tx + .insert_st_client_credentials(caller_connection_id, &client_clone.auth.jwt_payload) + .err() + { + return ReducerCallResult { + outcome: ReducerOutcome::Failed(format!("Error inserting client credentials: {err}")), + energy_used: EnergyQuanta::ZERO, + execution_duration: Duration::ZERO, + }; + } }; let mut tx_slot = self.instance.instance_env().tx.clone(); From a336ba855ad0e143b3d1d6666359de34799a21f0 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 30 Jul 2025 08:21:53 -0700 Subject: [PATCH 06/16] Fix some system table issues. --- crates/core/src/host/module_host.rs | 72 +++++++++++-------- .../src/host/wasm_common/module_host_actor.rs | 31 +------- .../src/locking_tx_datastore/mut_tx.rs | 24 +++---- crates/datastore/src/system_tables.rs | 6 ++ 4 files changed, 60 insertions(+), 73 deletions(-) diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index b5561724404..1c1e9b40d5f 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -691,6 +691,37 @@ impl ModuleHost { let me = self.clone(); self.call("call_identity_connected", move |inst| { let reducer_lookup = me.info.module_def.lifecycle_reducer(Lifecycle::OnConnect); + let stdb = me.module.replica_ctx().relational_db.clone(); + let workload = Workload::Reducer(ReducerContext { + name: "call_identity_connected".to_owned(), + caller_identity: caller_auth.claims.identity, + caller_connection_id, + timestamp: Timestamp::now(), + arg_bsatn: Bytes::new(), + }); + let mut mut_tx = stdb.begin_mut_tx( + IsolationLevel::Serializable, + workload + ); + mut_tx + .insert_st_client(caller_auth.claims.identity, caller_connection_id) + .inspect_err(|e| { + log::error!( + "`call_identity_connected`: fallback transaction to insert into `st_client` failed: {e:#?}" + ) + }) + .map_err(DBError::from)?; + mut_tx + .insert_st_client_credentials(caller_connection_id, &caller_auth.jwt_payload) + .inspect_err(|e| { + log::error!( + "`call_identity_connected`: fallback transaction to insert into `st_client_credetials` failed: {e:#?}" + ) + }) + .map_err(DBError::from)?; + + + // let mut tx = db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal); if let Some((reducer_id, reducer_def)) = reducer_lookup { // The module defined a lifecycle reducer to handle new connections. @@ -698,6 +729,7 @@ impl ModuleHost { // If the call fails (as in, something unexpectedly goes wrong with WASM execution), // abort the connection: we can't really recover. let reducer_outcome = me.call_reducer_inner_with_inst( + Some(mut_tx), caller_auth.claims.identity, Some(caller_connection_id), None, @@ -729,38 +761,16 @@ impl ModuleHost { } } else { // The module doesn't define a client_connected reducer. - // Commit a transaction to update `st_clients` - // and to ensure we always have those events paired in the commitlog. + // We need to commit the transaction to update st_clients and st_connection_credentials. // // This is necessary to be able to disconnect clients after a server crash. - let reducer_name = reducer_lookup - .as_ref() - .map(|(_, def)| &*def.name) - .unwrap_or("__identity_connected__"); - - let workload = Workload::Reducer(ReducerContext { - name: reducer_name.to_owned(), - caller_identity: caller_auth.claims.identity, - caller_connection_id, - timestamp: Timestamp::now(), - arg_bsatn: Bytes::new(), - }); - let stdb = me.module.replica_ctx().relational_db.clone(); - stdb.with_auto_commit(workload, |mut_tx| { - mut_tx - .insert_st_client(caller_auth.claims.identity, caller_connection_id) - .map_err(DBError::from)?; - mut_tx - .insert_st_client_credentials(caller_connection_id, &caller_auth.jwt_payload) - .map_err(DBError::from) - }) - .inspect_err(|e| { - log::error!( - "`call_identity_connected`: fallback transaction to insert into `st_client` failed: {e:#?}" - ) - }) - .map_err(Into::into) + // TODO: report the metrics. + // TODO: Is this being broadcast? Does it need to be, or are st_client table subscriptions + // not allowed? + // I don't think it was being broadcast previously. + mut_tx.commit(); + Ok(()) } }) .await @@ -814,6 +824,7 @@ impl ModuleHost { // If it succeeds, `WasmModuleInstance::call_reducer_with_tx` has already ensured // that `st_client` is updated appropriately. let result = me.call_reducer_inner_with_inst( + None, caller_identity, Some(caller_connection_id), None, @@ -918,6 +929,7 @@ impl ModuleHost { } fn call_reducer_inner_with_inst( &self, + tx: Option, caller_identity: Identity, caller_connection_id: Option, client: Option>, @@ -933,7 +945,7 @@ impl ModuleHost { let caller_connection_id = caller_connection_id.unwrap_or(ConnectionId::ZERO); Ok(module_instance.call_reducer( - None, + tx, CallReducerParams { timestamp: Timestamp::now(), caller_identity, diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index db899188d65..0b66ca197f1 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -363,36 +363,9 @@ impl WasmModuleInstance { .with_label_values(&database_identity, reducer_name); let workload = Workload::Reducer(ReducerContext::from(op.clone())); - let mut tx = tx.unwrap_or_else(|| stdb.begin_mut_tx(IsolationLevel::Serializable, workload)); + let tx = tx.unwrap_or_else(|| stdb.begin_mut_tx(IsolationLevel::Serializable, workload)); let _guard = metric_reducer_plus_query_duration.with_timer(tx.timer); - // For OnConnect, we insert the credentials before the reducer, so we can look them up - // inside that reducer. - // If the connection is rejected, this should get rolled back. - if let Some(Lifecycle::OnConnect) = reducer_def.lifecycle { - let client_clone = match client.clone() { - Some(client) => client, - None => { - log::error!("OnConnect reducer called without a client"); - return ReducerCallResult { - outcome: ReducerOutcome::Failed("OnConnect reducer called without a client".into()), - energy_used: EnergyQuanta::ZERO, - execution_duration: Duration::ZERO, - }; - } - }; - if let Some(err) = tx - .insert_st_client_credentials(caller_connection_id, &client_clone.auth.jwt_payload) - .err() - { - return ReducerCallResult { - outcome: ReducerOutcome::Failed(format!("Error inserting client credentials: {err}")), - energy_used: EnergyQuanta::ZERO, - execution_duration: Duration::ZERO, - }; - } - }; - let mut tx_slot = self.instance.instance_env().tx.clone(); let reducer_span = tracing::trace_span!( @@ -484,7 +457,7 @@ impl WasmModuleInstance { // and conversely removing from `st_clients` on disconnect. Ok(Ok(())) => { let res = match reducer_def.lifecycle { - Some(Lifecycle::OnConnect) => tx.insert_st_client(caller_identity, caller_connection_id), + Some(Lifecycle::OnConnect) => Ok(()), Some(Lifecycle::OnDisconnect) => { tx.delete_st_client(caller_identity, caller_connection_id, database_identity) } diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index 884b07c7538..d326ba9ad02 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -10,7 +10,9 @@ use super::{ }; use crate::execution_context::ExecutionContext; use crate::execution_context::Workload; -use crate::system_tables::{StConnectionCredentialsFields, StConnectionCredentialsRow, ST_CONNECTION_CREDENTIALS_ID}; +use crate::system_tables::{ + ConnectionIdViaU128, StConnectionCredentialsFields, StConnectionCredentialsRow, ST_CONNECTION_CREDENTIALS_ID, +}; use crate::traits::{InsertFlags, RowTypeForTable, TxData, UpdateFlags}; use crate::{ error::{IndexError, SequenceError, TableError}, @@ -1372,20 +1374,14 @@ impl MutTxId { database_identity: Identity, connection_id: ConnectionId, ) -> Result<()> { - if let Some(ptr) = self - .iter_by_col_eq( - ST_CONNECTION_CREDENTIALS_ID, - StConnectionCredentialsFields::ConnectionId, - &connection_id.into(), - )? - .next() - .map(|row| row.pointer()) - { - self.delete(ST_CONNECTION_CREDENTIALS_ID, ptr).map(drop) - } else { - log::warn!("[{database_identity}]: delete_st_client_credentials: attempting to credentials for missing connection id ({connection_id})"); - Ok(()) + if let Err(e) = self.delete_col_eq( + ST_CONNECTION_CREDENTIALS_ID, + StConnectionCredentialsFields::ConnectionId.col_id(), + &ConnectionIdViaU128::from(connection_id).into(), + ) { + log::error!("[{database_identity}]: delete_st_client_credentials: attempting to delete credentials for missing connection id ({connection_id}), error: {e}"); } + Ok(()) } pub fn delete_st_client( diff --git a/crates/datastore/src/system_tables.rs b/crates/datastore/src/system_tables.rs index 1c6fdbbb036..781c8835b0a 100644 --- a/crates/datastore/src/system_tables.rs +++ b/crates/datastore/src/system_tables.rs @@ -869,6 +869,12 @@ impl From for ConnectionIdViaU128 { } } +impl From for AlgebraicValue { + fn from(val: ConnectionIdViaU128) -> Self { + AlgebraicValue::U128(val.0.to_u128().into()) + } +} + /// A wrapper for [`Identity`] that acts like [`AlgebraicType::U256`] for serialization purposes. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct IdentityViaU256(pub Identity); From 14a61584ed5882022aa4e9297861be1f374dddcf Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 1 Aug 2025 12:41:19 -0700 Subject: [PATCH 07/16] fmt --- crates/client-api/src/routes/subscribe.rs | 20 +++++++++++++++++--- crates/core/src/client/client_connection.rs | 2 +- crates/core/src/host/module_host.rs | 2 +- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/client-api/src/routes/subscribe.rs b/crates/client-api/src/routes/subscribe.rs index 52aee06952d..07d444cd5fd 100644 --- a/crates/client-api/src/routes/subscribe.rs +++ b/crates/client-api/src/routes/subscribe.rs @@ -179,7 +179,13 @@ where log::debug!("websocket: New client connected from {client_log_string}"); - let connected = match ClientConnection::call_client_connected_maybe_reject(&mut module_rx, client_id, auth.clone().into()).await { + let connected = match ClientConnection::call_client_connected_maybe_reject( + &mut module_rx, + client_id, + auth.clone().into(), + ) + .await + { Ok(connected) => { log::debug!("websocket: client_connected returned Ok for {client_log_string}"); connected @@ -201,8 +207,16 @@ where ); let actor = |client, sendrx| ws_client_actor(ws_opts, client, ws, sendrx); - let client = - ClientConnection::spawn(client_id, auth.into(), client_config, leader.replica_id, module_rx, actor, connected).await; + let client = ClientConnection::spawn( + client_id, + auth.into(), + client_config, + leader.replica_id, + module_rx, + actor, + connected, + ) + .await; // Send the client their identity token message as the first message // NOTE: We're adding this to the protocol because some client libraries are diff --git a/crates/core/src/client/client_connection.rs b/crates/core/src/client/client_connection.rs index da9315a7af2..f6bcbc43784 100644 --- a/crates/core/src/client/client_connection.rs +++ b/crates/core/src/client/client_connection.rs @@ -426,7 +426,7 @@ impl ClientConnection { pub async fn call_client_connected_maybe_reject( module_rx: &mut watch::Receiver, id: ClientActorId, - auth: ConnectionAuthCtx + auth: ConnectionAuthCtx, ) -> Result { let module = module_rx.borrow_and_update().clone(); module.call_identity_connected(auth, id.connection_id).await?; diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index e19861903e9..ebe0b052573 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -768,7 +768,7 @@ impl ModuleHost { // TODO: Is this being broadcast? Does it need to be, or are st_client table subscriptions // not allowed? // I don't think it was being broadcast previously. - mut_tx.commit(); + let _ = mut_tx.commit(); Ok(()) } }) From 5df0d939fd4c67691eb60149a0424118772c7513 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Mon, 4 Aug 2025 14:10:54 -0700 Subject: [PATCH 08/16] Commit using the db, so it gets persisted. --- crates/core/src/host/module_host.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index ebe0b052573..e235727f45a 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -764,11 +764,15 @@ impl ModuleHost { // // This is necessary to be able to disconnect clients after a server crash. - // TODO: report the metrics. // TODO: Is this being broadcast? Does it need to be, or are st_client table subscriptions // not allowed? // I don't think it was being broadcast previously. - let _ = mut_tx.commit(); + let stdb = me.module.replica_ctx().relational_db.clone(); + stdb.finish_tx(mut_tx, Ok(())) + .map_err(|e: DBError| { + log::error!("`call_identity_connected`: finish transaction failed: {e:#?}"); + ClientConnectedError::DBError(e) + })?; Ok(()) } }) From 37467bfdcf67c47bd9960cfe43b33de2b66805fc Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Tue, 5 Aug 2025 09:25:07 -0700 Subject: [PATCH 09/16] Cleanup --- crates/client-api/src/auth.rs | 16 +++++++++++----- crates/core/src/host/module_host.rs | 6 +----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/client-api/src/auth.rs b/crates/client-api/src/auth.rs index e8175ee7b5b..2f0ca67ef75 100644 --- a/crates/client-api/src/auth.rs +++ b/crates/client-api/src/auth.rs @@ -85,15 +85,15 @@ impl SpacetimeCreds { pub struct SpacetimeAuth { pub creds: SpacetimeCreds, pub claims: SpacetimeIdentityClaims, - // The decoded JWT payload. - pub raw_payload: String, + /// The JWT payload as a json string (after base64 decoding). + pub jwt_payload: String, } impl From for ConnectionAuthCtx { fn from(auth: SpacetimeAuth) -> Self { ConnectionAuthCtx { claims: auth.claims, - jwt_payload: auth.raw_payload.clone(), + jwt_payload: auth.jwt_payload.clone(), } } } @@ -131,6 +131,9 @@ impl TokenClaims { Identity::from_claims(&self.issuer, &self.subject) } + /// Encode the claims into a JWT token and sign it with the provided signer. + /// This also adds claims for expiry and issued at time. + /// Returns an object representing the claims and the signed token. pub fn encode_and_sign_with_expiry( &self, signer: &impl TokenSigner, @@ -150,6 +153,9 @@ impl TokenClaims { Ok((claims, token)) } + /// Encode the claims into a JWT token and sign it with the provided signer. + /// This also adds a claim for issued at time. + /// Returns an object representing the claims and the signed token. pub fn encode_and_sign(&self, signer: &impl TokenSigner) -> Result<(SpacetimeIdentityClaims, String), JwtError> { self.encode_and_sign_with_expiry(signer, None) } @@ -177,7 +183,7 @@ impl SpacetimeAuth { Ok(Self { creds, claims, - raw_payload: payload, + jwt_payload: payload, }) } @@ -351,7 +357,7 @@ impl axum::extract::FromRequestParts for Space let auth = SpacetimeAuth { creds, claims, - raw_payload: payload, + jwt_payload: payload, }; Ok(Self { auth: Some(auth) }) } diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index e235727f45a..f39d341f16d 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -690,7 +690,7 @@ impl ModuleHost { let me = self.clone(); self.call("call_identity_connected", move |inst| { let reducer_lookup = me.info.module_def.lifecycle_reducer(Lifecycle::OnConnect); - let stdb = me.module.replica_ctx().relational_db.clone(); + let stdb = &me.module.replica_ctx().relational_db; let workload = Workload::Reducer(ReducerContext { name: "call_identity_connected".to_owned(), caller_identity: caller_auth.claims.identity, @@ -719,9 +719,6 @@ impl ModuleHost { }) .map_err(DBError::from)?; - - // let mut tx = db.begin_mut_tx(IsolationLevel::Serializable, Workload::Internal); - if let Some((reducer_id, reducer_def)) = reducer_lookup { // The module defined a lifecycle reducer to handle new connections. // Call this reducer. @@ -767,7 +764,6 @@ impl ModuleHost { // TODO: Is this being broadcast? Does it need to be, or are st_client table subscriptions // not allowed? // I don't think it was being broadcast previously. - let stdb = me.module.replica_ctx().relational_db.clone(); stdb.finish_tx(mut_tx, Ok(())) .map_err(|e: DBError| { log::error!("`call_identity_connected`: finish transaction failed: {e:#?}"); From b063fe3bb9e0631bf2df59c7890adcc1b45878f7 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Tue, 5 Aug 2025 15:30:37 -0700 Subject: [PATCH 10/16] Rollback on errors during identity connected. --- crates/core/src/host/module_host.rs | 49 ++++++++--------- .../subscription/module_subscription_actor.rs | 55 +++++++++---------- .../src/locking_tx_datastore/mut_tx.rs | 31 ++++++++--- smoketests/config.toml | 1 - 4 files changed, 70 insertions(+), 66 deletions(-) diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index f39d341f16d..6c52e92c496 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -26,6 +26,7 @@ use derive_more::From; use indexmap::IndexSet; use itertools::Itertools; use prometheus::{Histogram, IntGauge}; +use scopeguard::ScopeGuard; use spacetimedb_auth::identity::ConnectionAuthCtx; use spacetimedb_client_api_messages::websocket::{ByteListLen, Compression, OneOffTable, QueryUpdate}; use spacetimedb_data_structures::error_stream::ErrorStream; @@ -692,31 +693,27 @@ impl ModuleHost { let reducer_lookup = me.info.module_def.lifecycle_reducer(Lifecycle::OnConnect); let stdb = &me.module.replica_ctx().relational_db; let workload = Workload::Reducer(ReducerContext { - name: "call_identity_connected".to_owned(), - caller_identity: caller_auth.claims.identity, - caller_connection_id, - timestamp: Timestamp::now(), - arg_bsatn: Bytes::new(), - }); - let mut mut_tx = stdb.begin_mut_tx( - IsolationLevel::Serializable, - workload - ); - mut_tx - .insert_st_client(caller_auth.claims.identity, caller_connection_id) - .inspect_err(|e| { - log::error!( - "`call_identity_connected`: fallback transaction to insert into `st_client` failed: {e:#?}" - ) - }) - .map_err(DBError::from)?; + name: "call_identity_connected".to_owned(), + caller_identity: caller_auth.claims.identity, + caller_connection_id, + timestamp: Timestamp::now(), + arg_bsatn: Bytes::new(), + }); + let mut_tx = stdb.begin_mut_tx(IsolationLevel::Serializable, workload); + let mut mut_tx = scopeguard::guard(mut_tx, |mut_tx| { + // If we crash before committing, we need to ensure that the transaction is rolled back. + // This is necessary to avoid leaving the database in an inconsistent state. + log::debug!("call_identity_connected: rolling back transaction"); + let (metrics, reducer_name) = mut_tx.rollback(); + stdb.report_mut_tx_metrics(reducer_name, metrics, None); + }); + mut_tx - .insert_st_client_credentials(caller_connection_id, &caller_auth.jwt_payload) - .inspect_err(|e| { - log::error!( - "`call_identity_connected`: fallback transaction to insert into `st_client_credetials` failed: {e:#?}" - ) - }) + .insert_st_client( + caller_auth.claims.identity, + caller_connection_id, + &caller_auth.jwt_payload, + ) .map_err(DBError::from)?; if let Some((reducer_id, reducer_def)) = reducer_lookup { @@ -725,7 +722,7 @@ impl ModuleHost { // If the call fails (as in, something unexpectedly goes wrong with WASM execution), // abort the connection: we can't really recover. let reducer_outcome = me.call_reducer_inner_with_inst( - Some(mut_tx), + Some(ScopeGuard::into_inner(mut_tx)), caller_auth.claims.identity, Some(caller_connection_id), None, @@ -764,7 +761,7 @@ impl ModuleHost { // TODO: Is this being broadcast? Does it need to be, or are st_client table subscriptions // not allowed? // I don't think it was being broadcast previously. - stdb.finish_tx(mut_tx, Ok(())) + stdb.finish_tx(ScopeGuard::into_inner(mut_tx), Ok(())) .map_err(|e: DBError| { log::error!("`call_identity_connected`: finish transaction failed: {e:#?}"); ClientConnectedError::DBError(e) diff --git a/crates/core/src/subscription/module_subscription_actor.rs b/crates/core/src/subscription/module_subscription_actor.rs index 48c2af5fb95..65211305898 100644 --- a/crates/core/src/subscription/module_subscription_actor.rs +++ b/crates/core/src/subscription/module_subscription_actor.rs @@ -876,37 +876,16 @@ impl ModuleSubscriptions { return Ok(Err(WriteConflict)); }; *db_update = DatabaseUpdate::from_writes(&tx_data); - (read_tx, Some(tx_data), tx_metrics) + (read_tx, Arc::new(tx_data), tx_metrics) } EventStatus::Failed(_) | EventStatus::OutOfEnergy => { - let (tx_metrics, tx) = stdb.rollback_mut_tx_downgrade(tx, Workload::Update); - (tx, None, tx_metrics) - } - }; - - let tx_data = tx_data.map(Arc::new); - - // When we're done with this method, release the tx and report metrics. - let mut read_tx = scopeguard::guard(read_tx, |tx| { - let (tx_metrics_read, reducer) = self.relational_db.release_tx(tx); - self.relational_db - .report_tx_metrics(reducer, tx_data.clone(), Some(tx_metrics_mut), Some(tx_metrics_read)); - }); - // Create the delta transaction we'll use to eval updates against. - let delta_read_tx = tx_data - .as_ref() - .as_ref() - .map(|tx_data| DeltaTx::new(&read_tx, tx_data, subscriptions.index_ids_for_subscriptions())) - .unwrap_or_else(|| DeltaTx::from(&*read_tx)); + // If the transaction failed, we need to rollback the mutable tx. + // We don't need to do any subscription updates in this case, so we will exit early. - let event = Arc::new(event); - let mut update_metrics: ExecutionMetrics = ExecutionMetrics::default(); - - match &event.status { - EventStatus::Committed(_) => { - update_metrics = subscriptions.eval_updates_sequential(&delta_read_tx, event.clone(), caller); - } - EventStatus::Failed(_) => { + let event = Arc::new(event); + let (tx_metrics, reducer) = stdb.rollback_mut_tx(tx); + self.relational_db + .report_tx_metrics(reducer, None, Some(tx_metrics), None); if let Some(client) = caller { let message = TransactionUpdateMessage { event: Some(event.clone()), @@ -917,9 +896,25 @@ impl ModuleSubscriptions { } else { log::trace!("Reducer failed but there is no client to send the failure to!") } + return Ok(Ok((event, ExecutionMetrics::default()))); } - EventStatus::OutOfEnergy => {} // ? - } + }; + let event = Arc::new(event); + + // When we're done with this method, release the tx and report metrics. + let mut read_tx = scopeguard::guard(read_tx, |tx| { + let (tx_metrics_read, reducer) = self.relational_db.release_tx(tx); + self.relational_db.report_tx_metrics( + reducer, + Some(tx_data.clone()), + Some(tx_metrics_mut), + Some(tx_metrics_read), + ); + }); + // Create the delta transaction we'll use to eval updates against. + let delta_read_tx = DeltaTx::new(&read_tx, tx_data.as_ref(), subscriptions.index_ids_for_subscriptions()); + + let update_metrics = subscriptions.eval_updates_sequential(&delta_read_tx, event.clone(), caller); // Merge in the subscription evaluation metrics. read_tx.metrics.merge(update_metrics); diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index d326ba9ad02..9a3009222f4 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -1173,7 +1173,7 @@ impl MutTxId { /// - [`TxData`], the set of inserts and deletes performed by this transaction. /// - [`TxMetrics`], various measurements of the work performed by this transaction. /// - `String`, the name of the reducer which ran during this transaction. - pub fn commit(mut self) -> (TxData, TxMetrics, String) { + pub(super) fn commit(mut self) -> (TxData, TxMetrics, String) { let tx_data = self.committed_state_write_lock.merge(self.tx_state, &self.ctx); // Compute and keep enough info that we can @@ -1352,33 +1352,46 @@ impl<'a, I: Iterator>> Iterator for FilterDeleted<'a, I> { } impl MutTxId { - pub fn insert_st_client(&mut self, identity: Identity, connection_id: ConnectionId) -> Result<()> { + pub fn insert_st_client( + &mut self, + identity: Identity, + connection_id: ConnectionId, + jwt_payload: &str, + ) -> Result<()> { let row = &StClientRow { identity: identity.into(), connection_id: connection_id.into(), }; - self.insert_via_serialize_bsatn(ST_CLIENT_ID, row).map(|_| ()) + self.insert_via_serialize_bsatn(ST_CLIENT_ID, row) + .map(|_| ()) + .inspect_err(|e| { + log::error!( + "[{identity}]: insert_st_client: failed to insert client ({identity}, {connection_id}), error: {e}" + ); + })?; + self.insert_st_client_credentials(connection_id, jwt_payload) } - pub fn insert_st_client_credentials(&mut self, connection_id: ConnectionId, jwt_payload: &str) -> Result<()> { + fn insert_st_client_credentials(&mut self, connection_id: ConnectionId, jwt_payload: &str) -> Result<()> { let row = &StConnectionCredentialsRow { connection_id: connection_id.into(), jwt_payload: jwt_payload.to_owned(), }; self.insert_via_serialize_bsatn(ST_CONNECTION_CREDENTIALS_ID, row) .map(|_| ()) + .inspect_err(|e| { + log::error!("[{connection_id}]: insert_st_client_credentials: failed to insert client credentials for connection id ({connection_id}), error: {e}"); + }) } - pub fn delete_st_client_credentials( - &mut self, - database_identity: Identity, - connection_id: ConnectionId, - ) -> Result<()> { + fn delete_st_client_credentials(&mut self, database_identity: Identity, connection_id: ConnectionId) -> Result<()> { if let Err(e) = self.delete_col_eq( ST_CONNECTION_CREDENTIALS_ID, StConnectionCredentialsFields::ConnectionId.col_id(), &ConnectionIdViaU128::from(connection_id).into(), ) { + // This is possible on restart if the database was previously running a version + // before this system table was added. log::error!("[{database_identity}]: delete_st_client_credentials: attempting to delete credentials for missing connection id ({connection_id}), error: {e}"); } Ok(()) diff --git a/smoketests/config.toml b/smoketests/config.toml index b7c4ad31a45..5a37a8381b6 100644 --- a/smoketests/config.toml +++ b/smoketests/config.toml @@ -1,5 +1,4 @@ default_server = "127.0.0.1:3000" -spacetimedb_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJoZXhfaWRlbnRpdHkiOiJjMjAwYzc3NDY1NTE5MDM2MTE4M2JiNjFmMWMxYzY3NDUzMzYzY2MxMTY4MmM1NTUwNWZiNjdlYzI0ZWMyMWViIiwic3ViIjoiOTJlMmNkOGQtNTk5Ny00NjZlLWIwNmYtZDNjOGQ1NzU3ODI4IiwiaXNzIjoibG9jYWxob3N0IiwiYXVkIjpbInNwYWNldGltZWRiIl0sImlhdCI6MTc1MjA0NjgwMCwiZXhwIjpudWxsfQ.dgefoxC7eCOONVUufu2JTVFo9876zQ4Mqwm0ivZ0PQK7Hacm3Ip_xqyav4bilZ0vIEf8IM8AB0_xawk8WcbvMg" [[server_configs]] nickname = "localhost" From 2462a95213db62af98e89c4520caff2e70ded944 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 6 Aug 2025 08:22:40 -0700 Subject: [PATCH 11/16] Update comments. --- crates/core/src/error.rs | 1 - crates/datastore/src/locking_tx_datastore/mut_tx.rs | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 10bbeac171c..eff3b835f02 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -26,7 +26,6 @@ use spacetimedb_vm::expr::Crud; pub use spacetimedb_datastore::error::{DatastoreError, IndexError, SequenceError, TableError}; -// #[derive(Error, Debug, PartialEq, Eq)] #[derive(Error, Debug)] pub enum ClientError { #[error("Client not found: {0}")] diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index 9a3009222f4..076c9a1c17d 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -1167,7 +1167,8 @@ impl MutTxId { }) } - /// Commits this transaction, applying its changes to the committed state. + /// Commits this transaction in memory, applying its changes to the committed state. + /// This doesn't handle the persistence layer at all. /// /// Returns: /// - [`TxData`], the set of inserts and deletes performed by this transaction. From 4eda3d54858ff5c63349041ee779aa6f8e506128 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 8 Aug 2025 11:51:23 -0700 Subject: [PATCH 12/16] WIP on exposing. --- crates/bindings-sys/src/lib.rs | 12 ++++++++++++ crates/bindings/src/lib.rs | 4 ++++ crates/core/src/host/mod.rs | 1 + crates/core/src/host/module_host.rs | 8 ++++++++ crates/core/src/host/wasm_common.rs | 1 + .../core/src/host/wasmtime/wasm_instance_env.rs | 15 +++++++++++++++ modules/quickstart-chat/src/lib.rs | 5 +++++ 7 files changed, 46 insertions(+) diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index b901de7e5d7..7ebea24511e 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -588,6 +588,9 @@ pub mod raw { /// /// - `out_ptr` is NULL or `out` is not in bounds of WASM memory. pub fn identity(out_ptr: *mut u8); + + /// Check if the caller has a jwt. + pub fn has_jwt(out_ptr: *mut u8); } /// What strategy does the database index use? @@ -1089,6 +1092,15 @@ pub fn identity() -> [u8; 32] { buf } +#[inline] +pub fn has_jwt() -> bool { + let mut v: u8 = 0; + unsafe { + raw::has_jwt(&mut v) + } + v != 0 +} + pub struct RowIter { raw: raw::RowIter, } diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 4bd241ee732..50b65db4bdc 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -760,6 +760,10 @@ impl ReducerContext { // which reads the module identity out of the `InstanceEnv`. Identity::from_byte_array(spacetimedb_bindings_sys::identity()) } + + pub fn jwt(&self) -> bool { + spacetimedb_bindings_sys::has_jwt() + } } /// A handle on a database with a particular table schema. diff --git a/crates/core/src/host/mod.rs b/crates/core/src/host/mod.rs index 1434f50917c..ce34c0cc6ac 100644 --- a/crates/core/src/host/mod.rs +++ b/crates/core/src/host/mod.rs @@ -146,4 +146,5 @@ pub enum AbiCall { Identity, VolatileNonatomicScheduleImmediate, + HasJwt, } diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 6c52e92c496..7f02555ec1c 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -426,6 +426,14 @@ fn init_database( Ok(rcr) } +/* +enum ReducerAuthCtx { + /// The reducer is scheduled. In the future this would include calls triggered by procedures. + Internal, + Jwt() +} + + */ pub struct CallReducerParams { pub timestamp: Timestamp, pub caller_identity: Identity, diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index 666cc808ea3..6261f7bf9dc 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -392,6 +392,7 @@ macro_rules! abi_funcs { "spacetime_10.0"::datastore_btree_scan_bsatn, "spacetime_10.0"::datastore_delete_by_btree_scan_bsatn, "spacetime_10.0"::identity, + "spacetime_10.0"::has_jwt, // unstable: "spacetime_10.0"::volatile_nonatomic_schedule_immediate, diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index f4605274986..bea8d2d8c39 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -367,6 +367,7 @@ impl WasmInstanceEnv { }) } + /// Writes the number of rows currently in table identified by `table_id` to `out`. /// /// # Traps @@ -1208,6 +1209,20 @@ impl WasmInstanceEnv { }) } + pub fn has_jwt(caller: Caller<'_, Self>, out_ptr: WasmPtr) -> RtResult<()> { + log::info!("Calling has_jwt"); + Self::with_span(caller, AbiCall::HasJwt, |caller| { + caller.data().instance_env.tx.get().unwrap(). + // caller.data().reducer_name + let (mem, _env) = Self::mem_env(caller); + // We're implicitly casting `out_ptr` to `WasmPtr` here. + // (Both types are actually `u32`.) + // This works because `Identity::write_to` does not require an aligned pointer, + // as it gets a `&mut [u8]` from WASM memory and does `copy_from_slice` with it. + 0u8.write_to(mem, out_ptr)?; + Ok(()) + }) + } /// Writes the identity of the module into `out = out_ptr[..32]`. /// /// # Traps diff --git a/modules/quickstart-chat/src/lib.rs b/modules/quickstart-chat/src/lib.rs index cf986470569..89e2ffab9ad 100644 --- a/modules/quickstart-chat/src/lib.rs +++ b/modules/quickstart-chat/src/lib.rs @@ -65,6 +65,11 @@ pub fn init(_ctx: &ReducerContext) {} #[spacetimedb::reducer(client_connected)] pub fn identity_connected(ctx: &ReducerContext) { + if ctx.jwt() { + log::info!("has jwt!"); + } else { + log::info!("no jwt"); + } if let Some(user) = ctx.db.user().identity().find(ctx.sender) { // If this is a returning user, i.e. we already have a `User` with this `Identity`, // set `online: true`, but leave `name` and `identity` unchanged. From 5f3c70b60a3fe1451775fb3ec3d8d2bbc0555f45 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Tue, 12 Aug 2025 10:20:02 -0700 Subject: [PATCH 13/16] Get a version of has_jwt and get_jwt working --- crates/bindings-sys/src/lib.rs | 22 ++++++- crates/bindings/src/lib.rs | 12 +++- crates/core/src/host/wasm_common.rs | 1 + crates/core/src/host/wasmtime/mod.rs | 12 ++++ .../src/host/wasmtime/wasm_instance_env.rs | 58 ++++++++++++++++--- .../src/locking_tx_datastore/mut_tx.rs | 29 ++++++++++ modules/quickstart-chat/src/lib.rs | 11 +++- 7 files changed, 128 insertions(+), 17 deletions(-) diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index 7ebea24511e..6fd8e19fe6d 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -590,7 +590,10 @@ pub mod raw { pub fn identity(out_ptr: *mut u8); /// Check if the caller has a jwt. - pub fn has_jwt(out_ptr: *mut u8); + pub fn has_jwt(connection_id_ptr: *const u8, out_ptr: *mut u8); + + /// Write the jwt payload for the given connection id to the out_ptr. + pub fn get_jwt(connection_id_ptr: *const u8, target_ptr: *mut u8, target_ptr_len: *mut u32); } /// What strategy does the database index use? @@ -1093,14 +1096,27 @@ pub fn identity() -> [u8; 32] { } #[inline] -pub fn has_jwt() -> bool { +pub fn has_jwt(connection_id: [u8; 16]) -> bool { let mut v: u8 = 0; unsafe { - raw::has_jwt(&mut v) + raw::has_jwt(connection_id.as_ptr(), &mut v) } v != 0 } +#[inline] +pub fn get_jwt(connection_id: [u8; 16]) -> String { + // let mut buf = Vec::with_capacity(1024*1024); + let mut buf = vec![0u8; 1024 * 1024]; + + let mut v: u32 = buf.len() as u32; + // v = buf.capacity() as u32; + unsafe { + raw::get_jwt(connection_id.as_ptr(), buf.as_mut_ptr(), &mut v); + } + std::str::from_utf8(&buf[..v as usize]).unwrap().to_string() +} + pub struct RowIter { raw: raw::RowIter, } diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 50b65db4bdc..dcb501e5154 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -761,8 +761,16 @@ impl ReducerContext { Identity::from_byte_array(spacetimedb_bindings_sys::identity()) } - pub fn jwt(&self) -> bool { - spacetimedb_bindings_sys::has_jwt() + pub fn has_jwt(&self) -> bool { + match self.connection_id { + Some(ref id) => spacetimedb_bindings_sys::has_jwt(id.as_le_byte_array()), + None => false, + } + } + + pub fn jwt(&self) -> String { + let cid = self.connection_id.unwrap(); + spacetimedb_bindings_sys::get_jwt(cid.as_le_byte_array()) } } diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index 6261f7bf9dc..ede39684599 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -393,6 +393,7 @@ macro_rules! abi_funcs { "spacetime_10.0"::datastore_delete_by_btree_scan_bsatn, "spacetime_10.0"::identity, "spacetime_10.0"::has_jwt, + "spacetime_10.0"::get_jwt, // unstable: "spacetime_10.0"::volatile_nonatomic_schedule_immediate, diff --git a/crates/core/src/host/wasmtime/mod.rs b/crates/core/src/host/wasmtime/mod.rs index 5a61d5e23ab..63b780289d8 100644 --- a/crates/core/src/host/wasmtime/mod.rs +++ b/crates/core/src/host/wasmtime/mod.rs @@ -179,6 +179,18 @@ impl WasmPointee for spacetimedb_lib::Identity { } } +impl WasmPointee for spacetimedb_lib::ConnectionId { + type Pointer = u32; + fn write_to(self, mem: &mut MemView, ptr: Self::Pointer) -> Result<(), MemError> { + let bytes = self.as_le_byte_array(); + mem.deref_slice_mut(ptr, bytes.len() as u32)?.copy_from_slice(&bytes); + Ok(()) + } + fn read_from(mem: &mut MemView, ptr: Self::Pointer) -> Result { + Ok(Self::from_le_byte_array(*mem.deref_array(ptr)?)) + } +} + type WasmPtr = ::Pointer; /// Wraps access to WASM linear memory with some additional functionality. diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index bea8d2d8c39..8c0bdaed3c7 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -12,7 +12,7 @@ use crate::host::wasm_common::{ }; use crate::host::AbiCall; use anyhow::Context as _; -use spacetimedb_lib::Timestamp; +use spacetimedb_lib::{ConnectionId, Timestamp}; use spacetimedb_primitives::{errno, ColId}; use wasmtime::{AsContext, Caller, StoreContextMut}; @@ -1209,17 +1209,57 @@ impl WasmInstanceEnv { }) } - pub fn has_jwt(caller: Caller<'_, Self>, out_ptr: WasmPtr) -> RtResult<()> { + pub fn has_jwt(caller: Caller<'_, Self>, connection_id: WasmPtr, out_ptr: WasmPtr) -> RtResult<()> { log::info!("Calling has_jwt"); Self::with_span(caller, AbiCall::HasJwt, |caller| { - caller.data().instance_env.tx.get().unwrap(). + //caller.data_mut().instance_env.tx.get().unwrap().get_jwt_payload() + // caller.data_mut().instance_env.tx.get().unwrap().insert_st_client() + // caller.data().instance_env.tx.get().unwrap(). // caller.data().reducer_name - let (mem, _env) = Self::mem_env(caller); - // We're implicitly casting `out_ptr` to `WasmPtr` here. - // (Both types are actually `u32`.) - // This works because `Identity::write_to` does not require an aligned pointer, - // as it gets a `&mut [u8]` from WASM memory and does `copy_from_slice` with it. - 0u8.write_to(mem, out_ptr)?; + let (mem, env) = Self::mem_env(caller); + let cid = ConnectionId::read_from(mem, connection_id)?; + if env.instance_env.tx.get().unwrap().get_jwt_payload(cid)?.is_some() { + // Write `1` to `out_ptr` if JWT exists. + 1u8.write_to(mem, out_ptr)?; + } else { + // Write `0` to `out_ptr` if JWT does not exist. + 0u8.write_to(mem, out_ptr)?; + } + Ok(()) + }) + } + + pub fn get_jwt(caller: Caller<'_, Self>, connection_id: WasmPtr, target_ptr: WasmPtr, target_ptr_len: WasmPtr) -> RtResult<()> { + log::info!("Calling get_jwt"); + Self::with_span(caller, AbiCall::HasJwt, |caller| { + //caller.data_mut().instance_env.tx.get().unwrap().get_jwt_payload() + // caller.data_mut().instance_env.tx.get().unwrap().insert_st_client() + // caller.data().instance_env.tx.get().unwrap(). + // caller.data().reducer_name + let (mem, env) = Self::mem_env(caller); + let cid = ConnectionId::read_from(mem, connection_id)?; + let jwt = env.instance_env.tx.get().unwrap().get_jwt_payload(cid)?.ok_or_else(|| { + anyhow::anyhow!("no JWT payload found for connection ID: {:?}", cid) + })?; + log::info!("JWT payload found for connection ID: {:?}: {}", cid.to_hex(), jwt); + let jwt_len = jwt.len(); + // Read `buffer_len`, i.e., the capacity of `buffer` pointed to by `buffer_ptr`. + let buffer_len = u32::read_from(mem, target_ptr_len)?; + log::info!("buffer_len: {}", buffer_len); + if buffer_len < jwt_len as u32 { + return Err(anyhow::anyhow!("buffer too small to hold JWT payload")); + } + log::info!("About to write length"); + // Write the length of the JWT payload to the target pointer. + (jwt_len as u32).write_to(mem, target_ptr_len)?; + log::info!("wrote length of {}", jwt_len); + log::info!("Byte len {}", jwt.as_bytes().len()); + + // Write the JWT payload to the target pointer. + // Get a mutable view to the `buffer`. + let mut buffer = mem.deref_slice_mut(target_ptr, buffer_len)?; + buffer[..jwt_len].copy_from_slice(&jwt.as_bytes()); + log::info!("wrote jwt bytes to slice"); Ok(()) }) } diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index 076c9a1c17d..664d00d662c 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -60,6 +60,8 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; +use anyhow::anyhow; +use crate::error::DatastoreError; type DecodeResult = core::result::Result; @@ -1353,6 +1355,33 @@ impl<'a, I: Iterator>> Iterator for FilterDeleted<'a, I> { } impl MutTxId { + + pub fn get_jwt_payload( + &self, + connection_id: ConnectionId, + ) -> Result> { + log::info!("Getting JWT payload for connection id: {}", connection_id.to_hex()); + let mut buf: Vec = Vec::new(); + self.iter_by_col_eq( + ST_CONNECTION_CREDENTIALS_ID, + StConnectionCredentialsFields::ConnectionId, + &ConnectionIdViaU128::from(connection_id).into(), + )? + .next() + .map(|row| row.read_via_bsatn::(&mut buf).map(|r| r.jwt_payload)) + .transpose() + .map_err(|e| { + log::error!( + "[{connection_id}]: get_jwt_payload: failed to get JWT payload for connection id ({connection_id}), error: {e}" + ); + DatastoreError::Other( + anyhow!( + "Failed to get JWT payload for connection id ({connection_id}): {e}" + ) + ) + }) + } + pub fn insert_st_client( &mut self, identity: Identity, diff --git a/modules/quickstart-chat/src/lib.rs b/modules/quickstart-chat/src/lib.rs index 89e2ffab9ad..968670fb5dd 100644 --- a/modules/quickstart-chat/src/lib.rs +++ b/modules/quickstart-chat/src/lib.rs @@ -5,6 +5,7 @@ pub struct User { #[primary_key] identity: Identity, name: Option, + jwt: Option, online: bool, } @@ -65,15 +66,18 @@ pub fn init(_ctx: &ReducerContext) {} #[spacetimedb::reducer(client_connected)] pub fn identity_connected(ctx: &ReducerContext) { - if ctx.jwt() { + let jwt = if ctx.has_jwt() { log::info!("has jwt!"); + log::info!("jwt is {}", ctx.jwt()); + Some(ctx.jwt()) } else { log::info!("no jwt"); - } + None + }; if let Some(user) = ctx.db.user().identity().find(ctx.sender) { // If this is a returning user, i.e. we already have a `User` with this `Identity`, // set `online: true`, but leave `name` and `identity` unchanged. - ctx.db.user().identity().update(User { online: true, ..user }); + ctx.db.user().identity().update(User { online: true, jwt: jwt, ..user }); } else { // If this is a new user, create a `User` row for the `Identity`, // which is online, but hasn't set a name. @@ -81,6 +85,7 @@ pub fn identity_connected(ctx: &ReducerContext) { name: None, identity: ctx.sender, online: true, + jwt, }); } } From 254e6b6a07b2a9f9be0c20e14eb63328c9a79daf Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Wed, 13 Aug 2025 09:31:48 -0700 Subject: [PATCH 14/16] Add JwtClaims and sender_auth, and use it in the quickstart-chat example. --- Cargo.lock | 1 + crates/bindings-sys/src/lib.rs | 14 +- crates/bindings/Cargo.toml | 2 + crates/bindings/src/lib.rs | 144 +++++++++++++- crates/bindings/src/rt.rs | 4 + .../src/host/wasmtime/wasm_instance_env.rs | 39 ++-- .../src/locking_tx_datastore/mut_tx.rs | 10 +- modules/quickstart-chat/src/lib.rs | 22 ++- .../examples/quickstart-chat/src/App.tsx | 3 +- .../identity_connected_reducer.ts | 2 +- .../identity_disconnected_reducer.ts | 2 +- .../src/module_bindings/index.ts | 2 +- .../src/module_bindings/message_table.ts | 2 +- .../src/module_bindings/message_type.ts | 2 +- .../module_bindings/send_message_reducer.ts | 2 +- .../src/module_bindings/set_name_reducer.ts | 2 +- .../src/module_bindings/user_table.ts | 2 +- .../src/module_bindings/user_type.ts | 7 +- sdks/typescript/pnpm-lock.yaml | 182 +----------------- 19 files changed, 216 insertions(+), 228 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0913492abe2..a96771f9773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5269,6 +5269,7 @@ dependencies = [ "log", "rand 0.8.5", "scoped-tls", + "serde_json", "spacetimedb-bindings-macro", "spacetimedb-bindings-sys", "spacetimedb-lib", diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index 6fd8e19fe6d..43d9e9774a3 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -1098,23 +1098,23 @@ pub fn identity() -> [u8; 32] { #[inline] pub fn has_jwt(connection_id: [u8; 16]) -> bool { let mut v: u8 = 0; - unsafe { - raw::has_jwt(connection_id.as_ptr(), &mut v) - } + unsafe { raw::has_jwt(connection_id.as_ptr(), &mut v) } v != 0 } #[inline] -pub fn get_jwt(connection_id: [u8; 16]) -> String { - // let mut buf = Vec::with_capacity(1024*1024); +pub fn get_jwt(connection_id: [u8; 16]) -> Option { + // TODO: I just picked a big number, but we could get the size of it beforehand. let mut buf = vec![0u8; 1024 * 1024]; let mut v: u32 = buf.len() as u32; - // v = buf.capacity() as u32; unsafe { raw::get_jwt(connection_id.as_ptr(), buf.as_mut_ptr(), &mut v); } - std::str::from_utf8(&buf[..v as usize]).unwrap().to_string() + if v == 0 { + return None; // No JWT found. + } + Some(std::str::from_utf8(&buf[..v as usize]).unwrap().to_string()) } pub struct RowIter { diff --git a/crates/bindings/Cargo.toml b/crates/bindings/Cargo.toml index 8e75cc3fce3..015184eb3af 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/bindings/Cargo.toml @@ -24,6 +24,7 @@ spacetimedb-bindings-sys.workspace = true spacetimedb-lib.workspace = true spacetimedb-bindings-macro.workspace = true spacetimedb-primitives.workspace = true +serde_json.workspace = true bytemuck.workspace = true derive_more.workspace = true @@ -39,6 +40,7 @@ getrandom02 = { workspace = true, optional = true, features = ["custom"] } [dev-dependencies] insta.workspace = true trybuild.workspace = true +serde_json.workspace = true [lints] workspace = true diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index dcb501e5154..0c14ea092bd 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -13,7 +13,7 @@ pub mod rt; pub mod table; use spacetimedb_lib::bsatn; -use std::cell::RefCell; +use std::cell::{OnceCell, RefCell}; pub use log; #[cfg(feature = "rand")] @@ -732,11 +732,32 @@ pub struct ReducerContext { /// See the [`#[table]`](macro@crate::table) macro for more information. pub db: Local, + /// The authentication information for the caller of this reducer. + /// This can be accessed via the sender_auth() method. + sender_auth: Box, + #[cfg(feature = "rand08")] rng: std::cell::OnceCell, } impl ReducerContext { + #[doc(hidden)] + pub fn new(db: Local, sender: Identity, connection_id: Option, timestamp: Timestamp) -> Self { + let sender_auth: Box = match connection_id { + Some(cid) => Box::new(ConnectionIdBasedAuthCtx::new(cid)), + None => Box::new(InternalAuthCtx), + }; + Self { + db, + sender, + timestamp, + connection_id, + sender_auth, + #[cfg(feature = "rand08")] + rng: std::cell::OnceCell::new(), + } + } + #[doc(hidden)] pub fn __dummy() -> Self { Self { @@ -744,6 +765,7 @@ impl ReducerContext { sender: Identity::__dummy(), timestamp: Timestamp::UNIX_EPOCH, connection_id: None, + sender_auth: Box::new(InternalAuthCtx), rng: std::cell::OnceCell::new(), } } @@ -768,9 +790,8 @@ impl ReducerContext { } } - pub fn jwt(&self) -> String { - let cid = self.connection_id.unwrap(); - spacetimedb_bindings_sys::get_jwt(cid.as_le_byte_array()) + pub fn sender_auth(&self) -> &dyn AuthCtx { + self.sender_auth.as_ref() } } @@ -883,6 +904,121 @@ impl std::ops::DerefMut for IterBuf { } } +#[non_exhaustive] +pub struct JwtClaims { + payload: String, + parsed: OnceCell, + audience: OnceCell>, +} + +impl JwtClaims { + fn new(jwt: String) -> Self { + Self { + payload: jwt, + parsed: OnceCell::new(), + audience: OnceCell::new(), + } + } + + fn get_parsed(&self) -> &serde_json::Value { + self.parsed + .get_or_init(|| serde_json::from_str(&self.payload).expect("Failed to parse JWT payload")) + } + + pub fn subject(&self) -> &str { + // This is a placeholder; actual implementation would parse the JWT claims. + self.get_parsed().get("sub").unwrap().as_str().unwrap() + } + + pub fn issuer(&self) -> &str { + self.get_parsed().get("iss").unwrap().as_str().unwrap() + } + + fn extract_audience(&self) -> Vec { + let aud = self.get_parsed().get("aud").unwrap(); + match aud { + serde_json::Value::String(s) => vec![s.clone()], + serde_json::Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(), + _ => panic!("Unexpected type for 'aud' claim in JWT"), + } + } + + pub fn audience(&self) -> &[String] { + self.audience.get_or_init(|| self.extract_audience()) + } + + // A convenience method, since this may not be in the token. + pub fn identity(&self) -> Identity { + Identity::from_claims(self.issuer(), self.subject()) + } + + // We can expose the whole payload for users that want to parse custom claims. + pub fn raw_payload(&self) -> &str { + &self.payload + } +} + +/// AuthCtx represents the authentication information for a caller. +pub trait AuthCtx { + // True if this reducer was spawned from inside the database. + fn is_internal(&self) -> bool; + // Check if there is a JWT without loading it. + // If is_internal is true, this will be false. + fn has_jwt(&self) -> bool; + // Load the jwt. + fn jwt(&self) -> Option<&JwtClaims>; +} + +struct ConnectionIdBasedAuthCtx { + connection_id: ConnectionId, + claims: std::cell::OnceCell>, +} + +impl ConnectionIdBasedAuthCtx { + fn new(connection_id: ConnectionId) -> Self { + Self { + connection_id, + claims: std::cell::OnceCell::new(), + } + } +} + +struct InternalAuthCtx; +impl AuthCtx for InternalAuthCtx { + fn is_internal(&self) -> bool { + true + } + + fn has_jwt(&self) -> bool { + false + } + + fn jwt(&self) -> Option<&JwtClaims> { + None + } +} + +impl AuthCtx for ConnectionIdBasedAuthCtx { + fn is_internal(&self) -> bool { + false + } + + fn has_jwt(&self) -> bool { + if let Some(maybe_claims) = self.claims.get() { + return maybe_claims.is_some(); + } + spacetimedb_bindings_sys::has_jwt(self.connection_id.as_le_byte_array()) + } + + fn jwt(&self) -> Option<&JwtClaims> { + self.claims + .get_or_init(|| { + spacetimedb_bindings_sys::get_jwt(self.connection_id.as_le_byte_array()).map(JwtClaims::new) + }) + .as_ref() + } +} + #[cfg(feature = "unstable")] #[macro_export] macro_rules! volatile_nonatomic_schedule_immediate { diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index 9c3507b6583..1e7ed2a4a28 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -484,6 +484,8 @@ extern "C" fn __call_reducer__( // Assemble the `ReducerContext`. let timestamp = Timestamp::from_micros_since_unix_epoch(timestamp as i64); + let ctx = ReducerContext::new(crate::Local {}, sender, conn_id, timestamp); + /* let ctx = ReducerContext { db: crate::Local {}, sender, @@ -492,6 +494,8 @@ extern "C" fn __call_reducer__( rng: std::cell::OnceCell::new(), }; + */ + // Fetch reducer function. let reducers = REDUCERS.get().unwrap(); // Dispatch to it with the arguments read. diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index 8c0bdaed3c7..ac6ad8996d9 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -367,7 +367,6 @@ impl WasmInstanceEnv { }) } - /// Writes the number of rows currently in table identified by `table_id` to `out`. /// /// # Traps @@ -1209,7 +1208,11 @@ impl WasmInstanceEnv { }) } - pub fn has_jwt(caller: Caller<'_, Self>, connection_id: WasmPtr, out_ptr: WasmPtr) -> RtResult<()> { + pub fn has_jwt( + caller: Caller<'_, Self>, + connection_id: WasmPtr, + out_ptr: WasmPtr, + ) -> RtResult<()> { log::info!("Calling has_jwt"); Self::with_span(caller, AbiCall::HasJwt, |caller| { //caller.data_mut().instance_env.tx.get().unwrap().get_jwt_payload() @@ -1229,36 +1232,42 @@ impl WasmInstanceEnv { }) } - pub fn get_jwt(caller: Caller<'_, Self>, connection_id: WasmPtr, target_ptr: WasmPtr, target_ptr_len: WasmPtr) -> RtResult<()> { + pub fn get_jwt( + caller: Caller<'_, Self>, + connection_id: WasmPtr, + target_ptr: WasmPtr, + target_ptr_len: WasmPtr, + ) -> RtResult<()> { log::info!("Calling get_jwt"); Self::with_span(caller, AbiCall::HasJwt, |caller| { - //caller.data_mut().instance_env.tx.get().unwrap().get_jwt_payload() - // caller.data_mut().instance_env.tx.get().unwrap().insert_st_client() - // caller.data().instance_env.tx.get().unwrap(). - // caller.data().reducer_name let (mem, env) = Self::mem_env(caller); let cid = ConnectionId::read_from(mem, connection_id)?; - let jwt = env.instance_env.tx.get().unwrap().get_jwt_payload(cid)?.ok_or_else(|| { - anyhow::anyhow!("no JWT payload found for connection ID: {:?}", cid) - })?; + let jwt = match env.instance_env.tx.get().unwrap().get_jwt_payload(cid)? { + None => { + // Consider logging here, since this should only happen during an upgrade. + 0u32.write_to(mem, target_ptr_len)?; + return Ok(()); + } + Some(jwt) => jwt, + }; log::info!("JWT payload found for connection ID: {:?}: {}", cid.to_hex(), jwt); let jwt_len = jwt.len(); // Read `buffer_len`, i.e., the capacity of `buffer` pointed to by `buffer_ptr`. let buffer_len = u32::read_from(mem, target_ptr_len)?; - log::info!("buffer_len: {}", buffer_len); + log::info!("buffer_len: {buffer_len}"); if buffer_len < jwt_len as u32 { return Err(anyhow::anyhow!("buffer too small to hold JWT payload")); } log::info!("About to write length"); // Write the length of the JWT payload to the target pointer. (jwt_len as u32).write_to(mem, target_ptr_len)?; - log::info!("wrote length of {}", jwt_len); - log::info!("Byte len {}", jwt.as_bytes().len()); + log::info!("wrote length of {jwt_len}"); + log::info!("Byte len {}", jwt.len()); // Write the JWT payload to the target pointer. // Get a mutable view to the `buffer`. - let mut buffer = mem.deref_slice_mut(target_ptr, buffer_len)?; - buffer[..jwt_len].copy_from_slice(&jwt.as_bytes()); + let buffer = mem.deref_slice_mut(target_ptr, buffer_len)?; + buffer[..jwt_len].copy_from_slice(jwt.as_bytes()); log::info!("wrote jwt bytes to slice"); Ok(()) }) diff --git a/crates/datastore/src/locking_tx_datastore/mut_tx.rs b/crates/datastore/src/locking_tx_datastore/mut_tx.rs index 664d00d662c..06864a85bdb 100644 --- a/crates/datastore/src/locking_tx_datastore/mut_tx.rs +++ b/crates/datastore/src/locking_tx_datastore/mut_tx.rs @@ -8,6 +8,7 @@ use super::{ tx_state::{IndexIdMap, PendingSchemaChange, TxState, TxTableForInsertion}, SharedMutexGuard, SharedWriteGuard, }; +use crate::error::DatastoreError; use crate::execution_context::ExecutionContext; use crate::execution_context::Workload; use crate::system_tables::{ @@ -24,6 +25,7 @@ use crate::{ ST_SEQUENCE_ID, ST_TABLE_ID, }, }; +use anyhow::anyhow; use core::ops::RangeBounds; use core::{cell::RefCell, mem}; use core::{iter, ops::Bound}; @@ -60,8 +62,6 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; -use anyhow::anyhow; -use crate::error::DatastoreError; type DecodeResult = core::result::Result; @@ -1355,11 +1355,7 @@ impl<'a, I: Iterator>> Iterator for FilterDeleted<'a, I> { } impl MutTxId { - - pub fn get_jwt_payload( - &self, - connection_id: ConnectionId, - ) -> Result> { + pub fn get_jwt_payload(&self, connection_id: ConnectionId) -> Result> { log::info!("Getting JWT payload for connection id: {}", connection_id.to_hex()); let mut buf: Vec = Vec::new(); self.iter_by_col_eq( diff --git a/modules/quickstart-chat/src/lib.rs b/modules/quickstart-chat/src/lib.rs index 968670fb5dd..0b2039eb948 100644 --- a/modules/quickstart-chat/src/lib.rs +++ b/modules/quickstart-chat/src/lib.rs @@ -66,18 +66,32 @@ pub fn init(_ctx: &ReducerContext) {} #[spacetimedb::reducer(client_connected)] pub fn identity_connected(ctx: &ReducerContext) { + let auth_ctx = ctx.sender_auth(); + let jwt = match auth_ctx.jwt() { + Some(claims) => claims.subject().to_string(), + None => { + log::warn!("Client connected without JWT"); + "no jwt".to_string() + } + }; + /* let jwt = if ctx.has_jwt() { log::info!("has jwt!"); - log::info!("jwt is {}", ctx.jwt()); - Some(ctx.jwt()) + log::info!("jwt is {}", ctx.jwt().raw_payload()); + Some(ctx.jwt().subject().to_string()) } else { log::info!("no jwt"); None }; + */ if let Some(user) = ctx.db.user().identity().find(ctx.sender) { // If this is a returning user, i.e. we already have a `User` with this `Identity`, // set `online: true`, but leave `name` and `identity` unchanged. - ctx.db.user().identity().update(User { online: true, jwt: jwt, ..user }); + ctx.db.user().identity().update(User { + online: true, + jwt: Some(jwt), + ..user + }); } else { // If this is a new user, create a `User` row for the `Identity`, // which is online, but hasn't set a name. @@ -85,7 +99,7 @@ pub fn identity_connected(ctx: &ReducerContext) { name: None, identity: ctx.sender, online: true, - jwt, + jwt: Some(jwt), }); } } diff --git a/sdks/typescript/examples/quickstart-chat/src/App.tsx b/sdks/typescript/examples/quickstart-chat/src/App.tsx index fa5998cd1eb..d41ac8a9373 100644 --- a/sdks/typescript/examples/quickstart-chat/src/App.tsx +++ b/sdks/typescript/examples/quickstart-chat/src/App.tsx @@ -109,8 +109,7 @@ function App() { setConnected(true); localStorage.setItem('auth_token', token); console.log( - 'Connected to SpacetimeDB with identity:', - identity.toHexString() + `Connected to SpacetimeDB with identity: ${identity.toHexString()} and connection id ${conn.connectionId.toHexString()}` ); conn.reducers.onSendMessage(() => { console.log('Message sent.'); diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_connected_reducer.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_connected_reducer.ts index f48a557e632..3e2346d6583 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_connected_reducer.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_connected_reducer.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_disconnected_reducer.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_disconnected_reducer.ts index 54c9432e688..9d163078cd4 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_disconnected_reducer.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/identity_disconnected_reducer.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/index.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/index.ts index 0d6bf6db0f7..fa56172034a 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/index.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/index.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_table.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_table.ts index f7e66b91fed..41be6af4cb2 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_table.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_table.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_type.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_type.ts index c600a534306..1a4bf8d2133 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_type.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/message_type.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/send_message_reducer.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/send_message_reducer.ts index 46f3272ddbd..4a7889d8181 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/send_message_reducer.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/send_message_reducer.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/set_name_reducer.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/set_name_reducer.ts index 65013ae06ab..033d9a3a76b 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/set_name_reducer.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/set_name_reducer.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_table.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_table.ts index 9b68d845c2c..1eaa8f9248d 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_table.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_table.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ diff --git a/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_type.ts b/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_type.ts index 23da72cc1bc..a5f6bfaee0a 100644 --- a/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_type.ts +++ b/sdks/typescript/examples/quickstart-chat/src/module_bindings/user_type.ts @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 1.3.0 (commit e18b2dc4dd1debb07349a53a515ca2ef07fbcb2b). +// This was generated using spacetimedb cli version 1.3.0 (commit f796f2fb01134d711ffe8d37eab70b92fd8072ef). /* eslint-disable */ /* tslint:disable */ @@ -35,6 +35,7 @@ import { export type User = { identity: Identity; name: string | undefined; + jwt: string | undefined; online: boolean; }; @@ -53,6 +54,10 @@ export namespace User { 'name', AlgebraicType.createOptionType(AlgebraicType.createStringType()) ), + new ProductTypeElement( + 'jwt', + AlgebraicType.createOptionType(AlgebraicType.createStringType()) + ), new ProductTypeElement('online', AlgebraicType.createBoolType()), ]); } diff --git a/sdks/typescript/pnpm-lock.yaml b/sdks/typescript/pnpm-lock.yaml index 3b8919b815a..6c4e0b84e1d 100644 --- a/sdks/typescript/pnpm-lock.yaml +++ b/sdks/typescript/pnpm-lock.yaml @@ -97,77 +97,6 @@ importers: specifier: ^6.0.5 version: 6.0.11(@types/node@22.15.0)(terser@5.34.1)(tsx@4.19.1) - examples/repro-07032025: - dependencies: - '@clockworklabs/spacetimedb-sdk': - specifier: workspace:* - version: link:../../packages/sdk - devDependencies: - '@types/node': - specifier: ^22.13.9 - version: 22.15.0 - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@22.15.0)(typescript@5.8.3) - typescript: - specifier: ^5.8.2 - version: 5.8.3 - - examples/repro2: - dependencies: - '@clockworklabs/spacetimedb-sdk': - specifier: workspace:* - version: link:../../packages/sdk - devDependencies: - '@eslint/js': - specifier: ^9.17.0 - version: 9.18.0 - '@testing-library/jest-dom': - specifier: ^6.6.3 - version: 6.6.3 - '@testing-library/react': - specifier: ^16.2.0 - version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@testing-library/user-event': - specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.0) - '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 - '@types/react': - specifier: ^18.3.18 - version: 18.3.18 - '@types/react-dom': - specifier: ^18.3.5 - version: 18.3.5(@types/react@18.3.18) - '@vitejs/plugin-react': - specifier: ^4.3.4 - version: 4.3.4(vite@6.0.11(@types/node@22.15.0)(terser@5.34.1)(tsx@4.19.1)) - eslint: - specifier: ^9.17.0 - version: 9.18.0 - eslint-plugin-react-hooks: - specifier: ^5.0.0 - version: 5.1.0(eslint@9.18.0) - eslint-plugin-react-refresh: - specifier: ^0.4.16 - version: 0.4.18(eslint@9.18.0) - globals: - specifier: ^15.14.0 - version: 15.14.0 - jsdom: - specifier: ^26.0.0 - version: 26.0.0 - typescript: - specifier: ~5.6.2 - version: 5.6.2 - typescript-eslint: - specifier: ^8.18.2 - version: 8.21.0(eslint@9.18.0)(typescript@5.6.2) - vite: - specifier: ^6.0.5 - version: 6.0.11(@types/node@22.15.0)(terser@5.34.1)(tsx@4.19.1) - packages/sdk: dependencies: '@zxing/text-encoding': @@ -461,10 +390,6 @@ packages: '@clockworklabs/test-app@file:packages/test-app': resolution: {directory: packages/test-app, type: directory} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - '@csstools/color-helpers@5.0.1': resolution: {integrity: sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==} engines: {node: '>=18'} @@ -1016,9 +941,6 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1153,18 +1075,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1327,10 +1237,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - acorn@8.12.1: resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} engines: {node: '>=0.4.0'} @@ -1383,9 +1289,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -1533,9 +1436,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} @@ -1599,10 +1499,6 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -2079,9 +1975,6 @@ packages: magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2609,20 +2502,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - tsup@8.3.0: resolution: {integrity: sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag==} engines: {node: '>=18'} @@ -2688,9 +2567,6 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - vite-node@2.1.2: resolution: {integrity: sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2875,10 +2751,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3301,10 +3173,6 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - '@csstools/color-helpers@5.0.1': {} '@csstools/css-calc@2.1.1(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': @@ -3644,11 +3512,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.25.7 @@ -3765,14 +3628,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 - '@tsconfig/node10@1.0.11': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -3994,10 +3849,6 @@ snapshots: dependencies: acorn: 8.14.0 - acorn-walk@8.3.4: - dependencies: - acorn: 8.14.0 - acorn@8.12.1: {} acorn@8.14.0: {} @@ -4036,8 +3887,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - arg@4.1.3: {} - argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -4177,8 +4026,6 @@ snapshots: convert-source-map@2.0.0: {} - create-require@1.1.1: {} - cross-spawn@5.1.0: dependencies: lru-cache: 4.1.5 @@ -4231,8 +4078,6 @@ snapshots: diff-sequences@29.6.3: {} - diff@4.0.2: {} - dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -4797,8 +4642,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - make-error@1.3.6: {} - merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -5241,24 +5084,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@types/node@22.15.0)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.0 - acorn: 8.14.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - tsup@8.3.0(postcss@8.5.1)(tsx@4.19.1)(typescript@5.6.2): dependencies: bundle-require: 5.0.0(esbuild@0.23.1) @@ -5336,7 +5161,8 @@ snapshots: typescript@5.6.2: {} - typescript@5.8.3: {} + typescript@5.8.3: + optional: true undici-types@6.21.0: {} @@ -5354,8 +5180,6 @@ snapshots: dependencies: punycode: 2.3.1 - v8-compile-cache-lib@3.0.1: {} - vite-node@2.1.2(@types/node@22.15.0)(terser@5.34.1): dependencies: cac: 6.7.14 @@ -5498,6 +5322,4 @@ snapshots: yallist@3.1.1: {} - yn@3.1.1: {} - yocto-queue@0.1.0: {} From 8ee5f6e4fe6dc6ea1d023e279e8cc7fe8e77f2aa Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Thu, 14 Aug 2025 09:28:37 -0700 Subject: [PATCH 15/16] Replace has_jwt with jwt_len to create a buffer of the right size. --- crates/bindings-sys/src/lib.rs | 23 +++++++++------ crates/bindings/src/lib.rs | 9 +----- crates/core/src/host/mod.rs | 3 +- crates/core/src/host/wasm_common.rs | 2 +- .../src/host/wasmtime/wasm_instance_env.rs | 28 +++++++++---------- .../examples/quickstart-chat/src/App.tsx | 23 ++++++++------- 6 files changed, 45 insertions(+), 43 deletions(-) diff --git a/crates/bindings-sys/src/lib.rs b/crates/bindings-sys/src/lib.rs index 43d9e9774a3..e1d50781e1b 100644 --- a/crates/bindings-sys/src/lib.rs +++ b/crates/bindings-sys/src/lib.rs @@ -589,8 +589,9 @@ pub mod raw { /// - `out_ptr` is NULL or `out` is not in bounds of WASM memory. pub fn identity(out_ptr: *mut u8); - /// Check if the caller has a jwt. - pub fn has_jwt(connection_id_ptr: *const u8, out_ptr: *mut u8); + /// Check the size of the jwt associated with the given connection. + /// Returns 0 if there is no jwt for the connection. + pub fn jwt_len(connection_id_ptr: *const u8, out_ptr: *mut u32); /// Write the jwt payload for the given connection id to the out_ptr. pub fn get_jwt(connection_id_ptr: *const u8, target_ptr: *mut u8, target_ptr_len: *mut u32); @@ -1096,16 +1097,22 @@ pub fn identity() -> [u8; 32] { } #[inline] -pub fn has_jwt(connection_id: [u8; 16]) -> bool { - let mut v: u8 = 0; - unsafe { raw::has_jwt(connection_id.as_ptr(), &mut v) } - v != 0 +pub fn jwt_length(connection_id: [u8; 16]) -> Option { + let mut v: u32 = 0; + unsafe { raw::jwt_len(connection_id.as_ptr(), &mut v) } + if v == 0 { + None + } else { + Some(v) + } } #[inline] pub fn get_jwt(connection_id: [u8; 16]) -> Option { - // TODO: I just picked a big number, but we could get the size of it beforehand. - let mut buf = vec![0u8; 1024 * 1024]; + let Some(jwt_len) = jwt_length(connection_id) else { + return None; // No JWT found. + }; + let mut buf = vec![0u8; jwt_len as usize]; let mut v: u32 = buf.len() as u32; unsafe { diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 0c14ea092bd..12a7daf110e 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -783,13 +783,6 @@ impl ReducerContext { Identity::from_byte_array(spacetimedb_bindings_sys::identity()) } - pub fn has_jwt(&self) -> bool { - match self.connection_id { - Some(ref id) => spacetimedb_bindings_sys::has_jwt(id.as_le_byte_array()), - None => false, - } - } - pub fn sender_auth(&self) -> &dyn AuthCtx { self.sender_auth.as_ref() } @@ -1007,7 +1000,7 @@ impl AuthCtx for ConnectionIdBasedAuthCtx { if let Some(maybe_claims) = self.claims.get() { return maybe_claims.is_some(); } - spacetimedb_bindings_sys::has_jwt(self.connection_id.as_le_byte_array()) + spacetimedb_bindings_sys::jwt_length(self.connection_id.as_le_byte_array()).is_some() } fn jwt(&self) -> Option<&JwtClaims> { diff --git a/crates/core/src/host/mod.rs b/crates/core/src/host/mod.rs index ce34c0cc6ac..598b374c397 100644 --- a/crates/core/src/host/mod.rs +++ b/crates/core/src/host/mod.rs @@ -146,5 +146,6 @@ pub enum AbiCall { Identity, VolatileNonatomicScheduleImmediate, - HasJwt, + JwtLength, + GetJwt, } diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index ede39684599..ac4b49415a9 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -392,8 +392,8 @@ macro_rules! abi_funcs { "spacetime_10.0"::datastore_btree_scan_bsatn, "spacetime_10.0"::datastore_delete_by_btree_scan_bsatn, "spacetime_10.0"::identity, - "spacetime_10.0"::has_jwt, "spacetime_10.0"::get_jwt, + "spacetime_10.0"::jwt_len, // unstable: "spacetime_10.0"::volatile_nonatomic_schedule_immediate, diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index ac6ad8996d9..d186420ed3c 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -1208,26 +1208,24 @@ impl WasmInstanceEnv { }) } - pub fn has_jwt( + pub fn jwt_len( caller: Caller<'_, Self>, connection_id: WasmPtr, - out_ptr: WasmPtr, + out_ptr: WasmPtr, ) -> RtResult<()> { log::info!("Calling has_jwt"); - Self::with_span(caller, AbiCall::HasJwt, |caller| { - //caller.data_mut().instance_env.tx.get().unwrap().get_jwt_payload() - // caller.data_mut().instance_env.tx.get().unwrap().insert_st_client() - // caller.data().instance_env.tx.get().unwrap(). - // caller.data().reducer_name + Self::with_span(caller, AbiCall::JwtLength, |caller| { let (mem, env) = Self::mem_env(caller); let cid = ConnectionId::read_from(mem, connection_id)?; - if env.instance_env.tx.get().unwrap().get_jwt_payload(cid)?.is_some() { - // Write `1` to `out_ptr` if JWT exists. - 1u8.write_to(mem, out_ptr)?; - } else { - // Write `0` to `out_ptr` if JWT does not exist. - 0u8.write_to(mem, out_ptr)?; - } + let length = env + .instance_env + .tx + .get() + .unwrap() + .get_jwt_payload(cid)? + .map(|p| p.len() as u32) + .unwrap_or(0u32); + length.write_to(mem, out_ptr)?; Ok(()) }) } @@ -1239,7 +1237,7 @@ impl WasmInstanceEnv { target_ptr_len: WasmPtr, ) -> RtResult<()> { log::info!("Calling get_jwt"); - Self::with_span(caller, AbiCall::HasJwt, |caller| { + Self::with_span(caller, AbiCall::GetJwt, |caller| { let (mem, env) = Self::mem_env(caller); let cid = ConnectionId::read_from(mem, connection_id)?; let jwt = match env.instance_env.tx.get().unwrap().get_jwt_payload(cid)? { diff --git a/sdks/typescript/examples/quickstart-chat/src/App.tsx b/sdks/typescript/examples/quickstart-chat/src/App.tsx index d41ac8a9373..0bfc3bdec12 100644 --- a/sdks/typescript/examples/quickstart-chat/src/App.tsx +++ b/sdks/typescript/examples/quickstart-chat/src/App.tsx @@ -127,16 +127,19 @@ function App() { console.log('Error connecting to SpacetimeDB:', err); }; - setConn( - DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('quickstart-chat') - .withToken(localStorage.getItem('auth_token') || '') - .onConnect(onConnect) - .onDisconnect(onDisconnect) - .onConnectError(onConnectError) - .build() - ); + let connection = DbConnection.builder() + .withUri('ws://localhost:3000') + .withModuleName('quickstart-chat') + .withToken(localStorage.getItem('auth_token') || '') + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError) + .build(); + setConn(connection); + return () => { + console.log('Cleaning up connection...'); + connection.disconnect(); + }; }, []); useEffect(() => { From cae8e1cfd2823df71e7d86c51420c439b754eb35 Mon Sep 17 00:00:00 2001 From: Jeffrey Dallatezza Date: Fri, 15 Aug 2025 10:15:06 -0700 Subject: [PATCH 16/16] Add an impl version. --- Cargo.lock | 18 +++++++++++++++--- crates/bindings/Cargo.toml | 2 ++ crates/bindings/src/lib.rs | 9 ++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a96771f9773..85d0e6c0e0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -935,7 +946,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -4092,7 +4103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.12.1", "proc-macro2", "quote", "syn 2.0.101", @@ -5262,6 +5273,7 @@ dependencies = [ name = "spacetimedb" version = "1.3.0" dependencies = [ + "auto_impl", "bytemuck", "derive_more", "getrandom 0.2.16", @@ -7856,7 +7868,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/bindings/Cargo.toml b/crates/bindings/Cargo.toml index 015184eb3af..4e59fbcc981 100644 --- a/crates/bindings/Cargo.toml +++ b/crates/bindings/Cargo.toml @@ -30,6 +30,7 @@ bytemuck.workspace = true derive_more.workspace = true log.workspace = true scoped-tls.workspace = true +auto_impl = "1.3.0" rand08 = { workspace = true, optional = true } # we depend on getrandom and enable the `custom` feature, so that @@ -41,6 +42,7 @@ getrandom02 = { workspace = true, optional = true, features = ["custom"] } insta.workspace = true trybuild.workspace = true serde_json.workspace = true +auto_impl = "1.3.0" [lints] workspace = true diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index 12a7daf110e..a70c1b7751a 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -11,6 +11,7 @@ mod rng; pub mod rt; #[doc(hidden)] pub mod table; +use auto_impl::auto_impl; use spacetimedb_lib::bsatn; use std::cell::{OnceCell, RefCell}; @@ -786,6 +787,10 @@ impl ReducerContext { pub fn sender_auth(&self) -> &dyn AuthCtx { self.sender_auth.as_ref() } + + pub fn sender_auth_impl_version(&self) -> impl AuthCtx + '_ { + &self.sender_auth + } } /// A handle on a database with a particular table schema. @@ -919,7 +924,7 @@ impl JwtClaims { } pub fn subject(&self) -> &str { - // This is a placeholder; actual implementation would parse the JWT claims. + // TODO: Add more error messages here. self.get_parsed().get("sub").unwrap().as_str().unwrap() } @@ -952,6 +957,7 @@ impl JwtClaims { } /// AuthCtx represents the authentication information for a caller. +#[auto_impl(&, Box)] pub trait AuthCtx { // True if this reducer was spawned from inside the database. fn is_internal(&self) -> bool; @@ -962,6 +968,7 @@ pub trait AuthCtx { fn jwt(&self) -> Option<&JwtClaims>; } +/// The auth information is fetched from system tables for the connection id. struct ConnectionIdBasedAuthCtx { connection_id: ConnectionId, claims: std::cell::OnceCell>,